一.目标定位

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.

实现上,关键点是:

以generator形式组织逻辑序列(function* + yield),把一系列的串行/并行操作通过yield拆分开

利用iterator的可“暂停/恢复”特性(iter.next())分步执行

通过iterator影响内部状态(iter.next(result)),注入异步操作结果

利用iterator的错误捕获特性(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);}// testlet iter = gen();// 尾触发顺序执行iter.nextlet 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 1002AB at 2012BC1 at 3015C2 at 3015["C1", "C2"]D1-1 at 4019D2 at 4020D1-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部分:

业务操作 -> Effect

以Effect creator API形式提供,提供各种语义的用来生成Effect的工具函数,例如把dispatch action包装成put、把方法调用包装成call/apply

Effect -> 业务操作

在执行时内部进行转换,例如把[Effect1, Effect2]转换为并行调用

类似于装箱(把业务操作用Effect包起来)拆箱(执行Effect里的业务操作),此外,完整的redux-saga还要实现:

作为middleware接入到Redux

提供读/写Redux state的接口(select/put)

提供监听action的接口(take/takeEvery/takeLatest)

Sagas组合、通信

task顺序控制、取消

action并发控制

差不多是一个大而全的异步流程控制库了,从实现上看,相当于一个增强版的co

四.术语概念
Effect
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)包起来)

有额外的学习成本(理解各个creator的语义,适应先包一层的玩法)

例如:

// 直接const userInfo = yield API.fetch('user/info', userId);// 包一层creatorconst userInfo = yield call(API.fetch, 'user/info', userId);// 并指定context,默认是nullconst 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 instructionassert.deepEqual(  iterator.next().value,  call(Api.fetch, '/products'),  "fetchProducts should yield an Effect call(Api.fetch, './products')")// 预期接口返回数据const products = {}// expects a dispatch instructionassert.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描述操作指令(要做什么)

Effect creator
redux-saga/effects提供了很多用来生成Effect的工具方法。常用的Effect creator如下:

阻塞型方法调用:call/apply 详见Declarative Effects

非阻塞型方法调用:fork/spawn 详见redux-saga’s fork model

并行执行task:all/race 详见Running Tasks In Parallel,Starting a race between multiple Effects

读写state:select/put 详见Pulling future actions

task控制:join/cancel/cancelled 详见Task cancellation

大多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
术语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;}// testfor (let val of rootSaga()) {    console.log(val);   // 0 1 2 3 4 5}

注意:实际上,call(saga)返回的Effect与其它类型的Effect没什么本质差异,也可以通过all/race进行组合

Saga Helpers
Saga Helper用来监听action,API形式是takeXXX,其语义相当于addActionListener:

take:语义相当于once

takeEvery:语义相当于on,允许并发action(上一个没完成也立即开始下一个)

takeLatest:限制版的on,不允许并发action(pending时又来一个就cancel掉pending的,只做最新的)

takeEvery, takeLatest是在take之上的封装,take才是底层API,灵活性最大,能手动满足各种场景

P.S.关于3者关系的更多信息,请查看Concurrency

pull action与push action
从控制方式上讲,take是pull的方式,takeEvery, takeLatest是push的方式

pull与push是指:

pull action:要求业务方主动去取action(yeild take()会返回action)

push action:由框架从外部注入action(takeEvery/takeLatest注册的Saga会被注入action参数)

pull方式的优势在于:

允许更精细的控制

比如可以手动实现takeN的效果(只关注某几次action,用完就释放掉)

以同步形式描述控制流

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的纯度

六.优缺点
优点:

易测试,提供了各种case的测试方案,包括mock task,分支覆盖等等

大而全的异步控制库,从异步流程控制到并发控制应有尽有

完备的错误捕获机制,阻塞型错误可try-catch,非阻塞型会通知所属Saga

优雅的流程控制,可读性/精炼程度不比async&await差多少,很容易描述并行操作

缺点:

体积略大,1700行,min版24KB,实际上并发控制等功能很难用到

依赖ES6 generator特性,可能需要polyfill

P.S.redux-saga也可以接入其它环境(不与Redux绑定),详细见Connecting Sagas to external Input/Output

参考资料
JavaScript Power Tools Part II: Composition Patterns In Redux-Saga

API Reference

Reference 6: A Saga on Sagas

更多相关文章

  1. 2.20 从本质上理解虚拟机快照以及操作演示
  2. 网络变更服务器不断网方案测试
  3. 快速测试 API 接口的新技能
  4. 作为一个Java程序员必须要知道的单元测试框架Junit详解
  5. Java中使用Jedis操作Redis
  6. 常用 Linux 发行版操作系统大盘点!
  7. Selenium3自动化测试【12】元素定位认知
  8. JavaScript 测试教程–part 3:测试 props,挂载函数和快照测试[每日
  9. JavaScript测试教程–part 4:模拟 API 调用和模拟 React 组件交互

随机推荐

  1. Android文件操作的模式
  2. Android 画图常用类
  3. android控件属性(中文)
  4. android相对布局中控件的常用属性
  5. 补间动画
  6. Android Studio下载地址[国内可下载]
  7. Android中Cursor类的概念和用法
  8. Android:获取设备ID、型号等
  9. Android应用开发中半透明效果实现方案
  10. 为Android编写实时游戏