redux-saga is a library that aims to make side effects (i.e. asynchronous things like data fetching and impure things like accessing the browser cache) in React/Redux applications easier and better.
作为一个Redux中间件,想让Redux应用中的副作用(即依赖/影响外部环境的不纯的部分)处理起来更优雅
Saga像个独立线程一样,专门负责处理副作用,多个Saga可以串行/并行组合起来,redux-saga负责调度管理
Saga来头不小(1W star不是浪得的),是某篇论文中提出的一种分布式事务机制,用来管理长期运行的业务进程
P.S.关于Saga背景的更多信息,请查看Background on the Saga concept
利用generator,让异步流程控制易读、优雅、易测试
In redux-saga, Sagas are implemented using Generator functions. To express the Saga logic we yield plain JavaScript Objects from the Generator.
实现上,关键点是:
function\* + yield
),把一系列的串行/并行操作通过yield
拆分开iter.next()
)分步执行iter.next(result)
),注入异步操作结果iter.throw(error)
),注入异步操作异常用generator/iterator
实现是因为它非常适合流程控制的场景,体现在:
yield
让描述串行/并行的异步操作变得很优雅P.S.关于generator与iterator的关系及generator基础用法,可以参考generator(生成器)_ES6笔记2
例如:
const ts = Date.now();
function asyncFn(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log(`${id} at ${Date.now() - ts}`);
resolve(id);
}, 1000);
});
}function* gen() {
// 串行异步
let A = yield asyncFn('A');
console.log(A);
let B = yield asyncFn('B');
console.log(B);
// 并行异步
let C = yield Promise.all([asyncFn('C1'), asyncFn('C2')]);
console.log(C);
// 串行/并行组合异步
let D = yield Promise.all([
asyncFn('D1-1').then(() => {
return asyncFn('D1-2');
}),
asyncFn('D2')
]);
console.log(D);
}// test
let iter = gen();
// 尾触发顺序执行iter.next
let next = function(prevResult) {
let {value: result, done} = iter.next(prevResult);
if (result instanceof Promise) {
result.then((res) => {
if (!done) next(res);
}, (err) => {
iter.throw(err);
});
}
else {
if (!done) next(result);
}
};
next();
实际结果符合预期:
A at 1002
A
B at 2012
B
C1 at 3015
C2 at 3015
["C1", "C2"]
D1-1 at 4019
D2 at 4020
D1-2 at 5022
["D1-2", "D2"]
执行顺序为:A -> B -> C1,C2 -> D1-1 -> D2 -> D1-2
redux-saga的核心控制部分与上面示例类似(没错,就是这么像co),从实现上看,其异步控制的关键是尾触发顺序执行iter.next。示例没添Effect这一层描述对象,从功能上讲Effect并不重要(Effect的作用见下面术语概念部分)
Effect层要实现的东西包括2部分:
put
、把方法调用包装成call/apply
[Effect1, Effect2]
转换为并行调用类似于装箱(把业务操作用Effect包起来)拆箱(执行Effect里的业务操作),此外,完整的redux-saga还要实现:
差不多是一个大而全的异步流程控制库了,从实现上看,相当于一个增强版的co
Effect指的是描述对象,相当于redux-saga中间件可识别的操作指令,例如调用指定的业务方法(call(myFn)
)、dispatch指定action(put(action)
)
An Effect is simply an object which contains some information to be interpreted by the middleware.
Effect层存在的主要意义是为了易测试性,所以用简单的描述对象来表示操作,多这样一层指令
虽然可以直接yield Promise(比如上面核心实现里的示例),但测试case中无法比较两个promise是否等价。所以添一层描述对象来解决这个问题,测试case中可以简单比较描述对象,实际起作用的Promise由redux-saga内部生成
这样做的好处是单测中不用mock异步方法(一般单测中会把所有异步方法替换掉,只比较传入参数是否相同,而不做实际操作),可以简单比较操作指令(Effect)是否等价。从单元测试的角度来看,Effect相当于把参数提出去了,让“比较传入参数是否相同”这一步可以在外面统一进行,而不用逐个mock替换
P.S.关于易测试性的更多信息,请查看Testing Sagas
另外,mock测试不但比较麻烦,还不可靠,毕竟与真实场景/流程有差异。通过框架约束,多一层描述对象来避免mock
这样做并不十分完美,还存在2个问题:
yield promise/dispatch action
,而都要用框架提供的creator(call, put
)包起来)例如:
// 直接
const userInfo = yield API.fetch('user/info', userId);// 包一层creator
const userInfo = yield call(API.fetch, 'user/info', userId);
// 并指定context,默认是null
const userInfo = yield call([myContext, API.fetch], 'user/info', userId);
形式上与fn.call
类似(实际上也提供了一个apply creator,形式与fn.apply
类似),内部处理也是类似的:
// call返回的描述对象(Effect)
{
@@redux-saga/IO: true,
CALL: {
args: ["user/info", userId],
context: myContext,
fn: fetch
}
}// 实际执行
result = fn.apply(context, args)
写起来不那么直接,但比起易测试性带来的好处(不用mock异步函数),这不很过分
注意,不需要mock异步函数只是简化了单元测试的一个环节,即便使用这种对比描述对象的方式,仍然需要提供预期的数据,例如:
// 测试场景直接执行
const iterator = fetchProducts()// expects a call instruction
assert.deepEqual(
iterator.next().value,
call(Api.fetch, '/products'),
"fetchProducts should yield an Effect call(Api.fetch, './products')"
)// 预期接口返回数据
const products = {}// expects a dispatch instruction
assert.deepEqual(
iterator.next(products).value,
put({ type: 'PRODUCTS_RECEIVED', products }),
"fetchProducts should yield an Effect put({ type: 'PRODUCTS_RECEIVED', products })"
)
P.S.这种描述对象的套路,和Flux/Redux的action如出一辙:Effect相当于Action,Effect creator相当于Action Creator。区别是Flux用action描述消息(发生了什么),而redux-saga用Effect描述操作指令(要做什么)
redux-saga/effects
提供了很多用来生成Effect的工具方法。常用的Effect creator如下:
大多creator语义都很直白,只有一个需要额外说明下:
join
用来获取非阻塞的task的返回结果其中fork
与spawn
都是非阻塞型方法调用,二者的区别是:
spawn
执行的task完全独立,与当前saga无关
当前saga不管它执行完了没,发生cancel/error
也不会影响当前saga
效果相当于让指定task独立在顶层执行,与middleware.run(rootSaga)
类似fork
执行的task与当前saga有关
fork
所在的saga会等待forked task,只有在所有forked task都执行结束后,当前saga才会结束
fork
的执行机制与all
完全一致,包括cancel和error的传递方式,所以如果任一task有未捕获的error,当前saga也会结束另外,cancel机制比较有意思:
对于执行中的task序列,所有task自然完成时,把结果向上传递到队首,作为上层某个yield
的返回值。如果task序列在处理过程中被cancel掉了,会把cancel信号向下传递,取消执行所有pending task。另外,还会把cancel信号沿着join链向上传递,取消执行所有依赖该task的task
简言之:complete信号沿调用链反向传递,而cancel信号沿task链正向传递,沿join链反向传递
注意:yield cancel(task)
也是非阻塞的(与fork
类似),而被cancel掉的任务在完成善后逻辑后会立即返回
P.S.通过join
建立依赖关系(取task结果),例如:
function* rootSaga() {
// Returns immediately with a Task object
const task = yield spawn(serverHello, 'world'); // Perform an effect in the meantime
yield call(console.log, "waiting on server result..."); // Block on the result of serverHello
const result = yield join(task);
}
术语Saga指的是一系列操作的集合,是个运行时的抽象概念
redux-saga里的Saga形式上是generator,用来描述一组操作,而generator是个具体的静态概念
P.S.redux-saga里所说的Saga大多数情况下指的都是generator形式的一组操作,而不是指redux-saga自身。简单理解的话:在redux-saga里,Saga就是generator,Sagas就是多个generator
Sagas有2种顺序组合方式:
yield* saga()
call(saga)
同样,直接yield* iterator
运行时展开也面临不便测试的问题,所以通过call
包一层Effect。另外,yield*
只接受一个iterator
,组合起来不很方便,例如:
function* saga1() {
yield 1;
yield 2;
}
function* saga2() {
yield 3;
yield 4;
}
function* rootSaga() {
yield 0;
// 组合多个generator不方便
yield* (function*() {
yield* saga1();
yield* saga2();
})();
yield 5;
}// test
for (let val of rootSaga()) {
console.log(val); // 0 1 2 3 4 5
}
注意:实际上,call(saga)
返回的Effect与其它类型的Effect没什么本质差异,也可以通过all/race
进行组合
Saga Helper用来监听action,API形式是takeXXX,其语义相当于addActionListener:
takeEvery, takeLatest
是在take
之上的封装,take
才是底层API,灵活性最大,能手动满足各种场景
P.S.关于3者关系的更多信息,请查看Concurrency
从控制方式上讲,take
是pull的方式,takeEvery, takeLatest
是push的方式
pull与push是指:
yeild take()
会返回action)takeEvery/takeLatest
注册的Saga会被注入action参数)pull方式的优势在于:
takeEvery, takeLatest
只支持单action,如果是action序列的话要拆开,用take
能保留关联逻辑块的完整性,比如登录/注销P.S.关于pull/push的更多信息,请查看Pulling future actions
有几个印象比较深的场景,充分体现出了redux-saga的优雅
function* fetchProducts() {
try {
const products = yield call(Api.fetch, '/products')
yield put({ type: 'PRODUCTS_RECEIVED', products })
}
catch(error) {
yield put({ type: 'PRODUCTS_REQUEST_FAILED', error })
}
}
除了需要知道put
表示dispatch action外,几乎不需要什么注释,实际情况就是你想的那样
function* loginFlow() {
while (true) {
yield take('LOGIN')
// ... perform the login logic
yield take('LOGOUT')
// ... perform the logout logic
}
}
pull action能保持关联action的处理顺序,而不需要额外外部状态控制。这样保证了LOGOUT
总是在执行过LOGIN
之后的某个时刻发生的,代码看起来相当漂亮
// 在创建第3条todo的时候,给出提示消息
function* watchFirstThreeTodosCreation() {
for (let i = 0; i < 3; i++) {
const action = yield take('TODO_CREATED')
}
yield put({type: 'SHOW_CONGRATULATION'})
}// 接口访问异常重试
function* updateApi(data) {
for(let i = 0; i < 5; i++) {
try {
const apiResponse = yield call(apiRequest, { data });
return apiResponse;
} catch(err) {
if(i < 4) {
yield call(delay, 2000);
}
}
}
// attempts failed after 5 attempts
throw new Error('API request failed');
}
即takeN的示例,这样就把本应该存在于reducer中的副作用提到了外面,保证了reducer的纯度
优点:
async&await
差多少,很容易描述并行操作缺点:
P.S.redux-saga也可以接入其它环境(不与Redux绑定),详细见Connecting Sagas to external Input/Output