专栏名称: 前端外刊评论
最新、最前沿的前端资讯,最有深入、最干前端相关的技术译文。
目录
相关文章推荐
前端早读课  ·  【第3453期】圈复杂度在转转前端质量体系中的应用 ·  23 小时前  
前端早读课  ·  【第3452期】React 开发中使用开闭原则 ·  昨天  
前端早读课  ·  【第3451期】前端 TypeError ... ·  2 天前  
51好读  ›  专栏  ›  前端外刊评论

构建你自己的 redux-saga(下)

前端外刊评论  · 公众号  · 前端  · 2018-05-29 06:30

正文

2.1 Task

proc 函数(redux-saga 的源码)用于运行一个迭代器,并返回一个 Task 对象。Task 对象描述了该迭代器的运行状态,我们首先来看看 Task 的接口(使用 TypeScript 来表示类型信息)。在 little-saga 中,我们将使用类似的 Task 接口。(注意是类似的接口,而不是相同的接口)

  1. type Callback = (result: any, isErr: boolean) => void

  2. type Joiner = { task: Task; cb: Callback }

  3. interface Task {

  4.  cancel(): void

  5.  toPromise(): Promise

  6.  result: any

  7.  error: Error

  8.  isRunning: boolean

  9.  isCancelled: boolean

  10.  isAborted: boolean

  11. }

Task 对象包含了 toPromise () 方法,该方法会返回 saga 实例对应的 promise。 cancel () 方法使得 saga 实例允许被取消; isXXX 等字段反映了 saga 实例的运行状态; result / error 字段可以记录了 saga 实例的运行结果。

async 函数被调用后,内部逻辑也许非常复杂,最后返回一个 Promise 对象来表示异步结果。而 saga 实例除了异步结果,还包含了额外的功能:「取消」以及「查询运行状态」。所以 Task 接口是要比 Promise 接口复杂的,内部实现也需要更多的数据结构和逻辑。

2.2 fork model

redux-saga 提供了 fork effect 来进行非阻塞调用, yield fork (...) 会返回一个 Task 对象,用于表示在后台执行的 saga 实例。在更普遍的情况下,一个 saga 实例在运行的时候会多次 yield fork effect,那么一个 parent-saga 实例就会有多个 child-saga。rootSaga 通过 sagaMiddleware . run () 开始运行,在 rootSaga 运行过程中,会 fork 得到若干个 child-saga,每一个 child-saga 又会 fork 得到若干个 grandchild-saga,如果我们将所有的 parent-child 关系绘制出来的话,我们可以得到类似于下图这样的一棵 saga 树。

redux-saga 的文档也对 fork model 进行了详细的说明,下面我做一点简单的翻译:

  • 完成:一个 saga 实例在满足以下条件之后进入完成状态:


  • 迭代器自身的语句执行完成


  • 所有的 child-saga 进入完成状态


当一个节点的所有子节点完成时,且自身迭代器代码执行完毕时,该节点才算完成。

  • 错误传播:一个 saga 实例在以下情况会中断并抛出错误:


  • 迭代器自身执行时抛出了异常


  • 其中一个 child-saga 抛出了错误


当一个节点发生错误时,错误会沿着树向根节点向上传播,直到某个节点捕获该错误。

  • 取消:取消一个 saga 实例也会导致以下事情的发生:


  • 取消 mainTask,也就是取消当前 saga 实例等待的 effect


  • 取消所有仍在执行的 child-saga


取消一个节点时,该节点对应的整个子树都将被取消。

2.3 类 ForkQueue

ForkQueue 是 fork model 的具体实现。redux-saga 使用了 函数 forkQueue 来实现,在 little-saga 中我们使用 class 语法定义了 ForkQueue 类。

每一个 saga 实例可以用一个 Task 对象进行描述,为了实现 fork model,每一个 saga 实例开始运行时,我们需要用一个数组来保存 child-tasks。我们来看看 forkQueue 的接口:

  1. interface ForkQueue {

  2.  constructor(mainTask: MainTask)

  3.  // cont: Callback   这是一个私有的字段

  4.  addTask(task: Task): void

  5.  cancelAll(): void

  6.  abort(err: Error): void

  7. }

ForkQueue 的构造函数接受一个参数 mainTask ,该参数代表当前迭代器自身代码的执行状态,forkQueue.cont 会在 ForkQueue 被构造之后进行设置。当所有的 child-task 以及 mainTask 都完成时,我们需要调用 forkQueue.cont 来通知其 parent-saga(对应于 2.2 fork model 中的「完成」)。

ForkQueue 对象包含三个方法。方法 addTask 用于添加新的 child-task;方法 cancelAll 用于取消所有的 child-task;而方法 abort 不仅会取消所有的 child-task,还会调用 forkQueue.cont 向 parent-task 通知错误。

little-saga 的 ForkQueue 实现如下:

  1. class ForkQueue {

  2.  tasks = []

  3.  result = undefined

  4.  // 使用 completed 变量来保证「完成」和「出错」的互斥

  5.  completed = false

  6.  // cont will be set after calling constructor()

  7.  cont = undefined

  8.  constructor(mainTask ) {

  9.    this.mainTask = mainTask

  10.    // mainTask 一开始就会被添加到数组中

  11.    this.addTask(this.mainTask)

  12.  }

  13.  // 取消所有的 child-task,并向上层通知错误

  14.  abort(err) {

  15.    this.cancelAll()

  16.    this.cont(err, true)

  17.  }

  18.  addTask(task) {

  19.    this.tasks.push(task)

  20.    // 指定 child-task 完成时的行为

  21.    task.cont = (res, isErr) => {

  22.      if (this.completed) {

  23.        return

  24.      }

  25.      // 移除 child-task

  26.      remove(this.tasks, task)

  27.      // 清空child-task完成时的行为

  28.      task.cont = noop

  29.      if (isErr) {

  30.        // 某一个 child-task 发生了错误,调用 abort 来进行「错误向上传播」

  31.        this.abort(res)

  32.      } else {

  33.        // 如果是 mainTask 完成的话,记录其结果

  34.        if (task === this.mainTask) {

  35.          this.result = res

  36.        }

  37.        if (this.tasks.length === 0) {

  38.          // 满足了 task 完成的两个条件

  39.          this.completed = true

  40.          this.cont(this.result)

  41.        }

  42.      }

  43.    }

  44.  }

  45.  cancelAll() {

  46.    if (this.completed) {

  47.      return

  48.     }

  49.    this.completed = true

  50.    // 依次调用 child-task 的 cancel 方法,进行「级联向下取消」,并清空 child-task 完成时的行为

  51.    this.tasks.forEach(t => {

  52.      t.cont = noop

  53.      t.cancel()

  54.    })

  55.    this.tasks = []

  56.  }

  57. }

2.4 task context

每一个 task 都有其对应的 context 对象,用于保存该 task 运行时的上下文信息。在 redux-saga 中我们可以使用 getContext/setContext 读写该对象。context 的一大特性是 child-task 会使用原型链的方式继承 parent-task context。当尝试访问 context 中的某个属性时,不仅会在当前 task context 对象中搜寻该属性,也会在 parent-task context 对象进行搜索,以及 parent-task 的 parent-task,依次层层往上搜索,直到找到该属性或是到达 rootSaga。该继承机制在 redux-saga 中的实现也非常简单,只有一行代码: const taskContext = Object . create ( parentContext )

context 是一个强大的机制,例如在 React 中,React context 用途非常广泛,react-redux / react-router 等相关类库都是基于该机制实现的。然而在 redux-saga 中,context 似乎很少被提起。

在 little-saga 中,我们将充分利用 context 机制,并使用该机制实现「effect 类型拓展」、「连接 redux store」等功能。这些机制的实现会在本文后面提到。

2.5 effect 类型拓展

在 1.9 proc 初步实现中,函数 runEffect 遇到未知 effect 类型便会抛出异常。这里我们对该处做一些修改,以实现 effect 的类型拓展。当遇到未知的 effect 类型时,我们将使用 ctx . translator getRunner 方法来获取该 effect 对应的 effectRunner,然后调用该 effectRunner。只要我们提前设置好 ctx . translator ,就能在后续的代码中使用拓展类型。为了方便设置 ctx . translator ,little-saga 中新增了 def 类型的 effect,用来关联拓展类型与其对应的 effectRunner。

根据 context 的特性,child-task 会继承 parent-saga 的 context,故在 parent-task 中定义的拓展类型也能用于 child-task。在 little-saga 中,race/all/take/put 等类型将使用该拓展机制进行实现。

  1. function runEffect(effect, currCb) {

  2.  const effectType = effect[0]

  3.  if (effectType === 'promise') {

  4.    resolvePromise(effect, ctx, currCb)

  5.  } else if (effectType === 'iterator') {

  6.    resolveIterator(iterator, ctx, currCb)

  7.  } else if (effectType === 'def') {

  8.    runDefEffect(effect, ctx, currCb)

  9.  /*   其他已知类型的 effect   */

  10.  /*   ....................   */

  11.  } else { // 未知类型的effect

  12.    const effectRunner = ctx.translator.getRunner(effect)

  13.    if (effectRunner == null) {

  14.      const error = new Error(`Cannot resolve effect-runner for type: ${effectType}`)

  15.      error.effect = effect

  16.      currCb(error, true)

  17.    } else {

  18.      effectRunner(effect, ctx, currCb, { digestEffect })

  19.    }

  20.  }

  21. }

  22. function runDefEffect([_, name, handler], ctx, cb) {

  23.  def(ctx, name, handler)

  24.  cb()

  25. }

  26. // def 定义在其他文件

  27. function def(ctx, type, handler) {

  28.  const old = ctx.translator

  29.  // 替换 ctx.translator

  30.  ctx.translator = {

  31.    getRunner (effect) {

  32.      return effect[0] === type ? handler : old.getRunner(effect)

  33.    }

  34.  },

  35. }

2.6 little-saga 核心部分的完整实现

little-saga 核心部分的实现代码位于 /src/core 文件夹内。完整实现的代码较多,相比于 1.9 proc 初步实现,完整实现添加了 context、task、fork model、effect 类型拓展、错误处理等功能,完善了 Task 生命周期(启动/完成/出错/取消)。

redux-saga 对应的实现代码全部都位于 proc.js 一个文件中,导致该文件很大;而 little-saga 则是将实现分成了若干个文件,下面我们一个一个来进行分析。

2.6.1 整体思路

在 2.6 后面的内容中,我们将使用以下的代码作为例子。该例子中,Parent 会 fork Child1 和 Child2,分别需要 100ms 和 300ms 来完成。Parent 迭代器本身的代码(mainTask)需要 200ms 完成。而整个 Parent Task 则需要 300ms 才能完成。

  1. function* Parent() {

  2.  const Child1 = yield fork(api.xxxx) // LINE-1 需要 100ms 才能完成

  3.  const Child2 = yield fork(api.yyyy) // LINE-2 需要 300ms 才能完成

  4.  yield delay(200) // LINE-3 需要 200 ms 才能完成

  5. }

下图展示了 Parent 运行时,各个 Task / mainTask / ForkQueue 之间的相互关系。图中的实线箭头表示两个对象之间的后继关系(cont):「A 指向 B」意味着「当 A 完成时,需要将结果传递给 B」。

在 1.7 cancellation 中我们知道 cancellation 的顺序和 cont 恰好是相反的,在具体代码实现时,我们不仅需要构建下图中的 cont 关系,还需要构建反向的 cancellation 关系。

本小节中的代码比较复杂,如果觉得理解起来比较困难的话,可以和 2.7 Task 状态变化举例 对照着看。

2.6.2 函数 proc

函数 proc 是运行 saga 实例的入口函数。结合上图,函数 proc 的作用是创建图中各个 Task/mainTask 对象,并建立对象之间的后继关系(cont)和取消关系(cancellation)。代码如下:

  1. // /src/core/proc.js

  2. function proc(iterator, parentContext, cont) {

  3.  // 初始化当前 task 的 context

  4.  const ctx = Object.create(parentContext)

  5.  // mainTask 用来跟踪当前迭代器的语句执行状态

  6.  const mainTask = {

  7.    // cont: **will be set when passed to ForkQueue**

  8.    isRunning: true,

  9.    isCancelled: false,

  10.    cancel() {

  11.      if (mainTask.isRunning && !mainTask.isCancelled) {

  12.        mainTask.isCancelled = true

  13.        next(TASK_CANCEL)

  14.      }

  15.    },

  16.  }

  17.  // 创建 ForkQueue 对象和 Task 对象,这两个类的代码在后面会写出来

  18.  const taskQueue = new ForkQueue(mainTask)

  19.  const task = new Task(taskQueue)

  20.  // 设置后继关系

  21.  taskQueue.cont = task.end

  22.  task.cont = cont

  23.  // 设置取消关系

  24.  cont.cancel = task.cancel

  25.   next()

  26.  return task

  27.  // 以下代码均为函数定义

  28.  // 在图中驱动函数只和 mainTask 有联系

  29.  // 然后我们也可以发现下面 next 函数的代码中,也只调用了 mainTask 的接口

  30.  // 即 next 函数中的代码不会引用 task 和 taskQueue 对象

  31.  function next(arg, isErr) {

  32.    console.assert(mainTask.isRunning, 'Trying to resume an already finished generator')

  33.    try {

  34.      let result

  35.      if (isErr) {

  36.        result = iterator.throw(arg)

  37.      } else if (arg === TASK_CANCEL) {

  38.        mainTask.isCancelled = true

  39.        next.cancel() // 取消当前执行的 effect

  40.        // 跳转到迭代器的 finally block,执行清理逻辑

  41.        result = iterator.return(TASK_CANCEL)

  42.      } else {

  43.        result = iterator.next(arg)

  44.      }

  45.      if (!result.done) {

  46.        digestEffect(result.value, next)

  47.      } else {

  48.        mainTask.isRunning = false

  49.        mainTask.cont(result.value)

  50.      }

  51.    } catch (error) {

  52.      if (!mainTask.isRunning) {

  53.        throw error

  54.      }

  55.      if (mainTask.isCancelled) {

  56.        // 在执行 cancel 逻辑时发生错误,在 3.4 其他问题与细节 中说明

  57.        console.error(error)

  58.      }

  59.      mainTask.isRunning = false

  60.      mainTask.cont(error, true)

  61.    }

  62.   }

  63.  // function digestEffect(rawEffect, cb) { /* ...... */ }

  64.  // function runEffect(effect, currCb) { /* ...... */ }

  65.  // function resolvePromise([effectType, promise], ctx, cb) { /* ... */ }

  66.  // function resolveIterator([effectType, iterator], ctx, cb) { /* ... */ }

  67.  // ...... 各种内置类型的 effect-runner

  68.  // fork-model 中新增了 fork/spawn/join/cancel/cancelled

  69.  // 这五种类型的 effec-runner 代码见下方

  70. }

2.6.3 fork-model 相关的 effect-runner

函数 runForkEffect 用来执行 fork 类型的 effect,并向调用者返回一个 subTask 对象。该函数需要注意的是,有的时候调用者会使用 fork 来执行一些同步的任务,所以调用 proc ( iterator , ctx , noop ) 可能会返回一个已经完成或是已经发生错误的 subTask,此时我们不需要将 subTask 放入 fork-queue 中,而是需要执行其他操作。

  1. // /src/core/proc.js

  2. function runForkEffect([effectType, fn, ...args], ctx, cb) {

  3.  const iterator = createTaskIterator(fn , args)

  4.  try {

  5.    suspend() // 见 3.4 scheduler

  6.    const subTask = proc(iterator, ctx, noop)

  7.    if (subTask.isRunning) {

  8.      task.taskQueue.addTask(subTask)

  9.      cb(subTask)

  10.    } else if (subTask.error) {

  11.      task.taskQueue.abort(subTask.error)

  12.    } else {

  13.      cb(subTask)

  14.    }

  15.   } finally {

  16.    flush() // 见 3.4 scheduler

  17.  }

  18. }

剩下四个类型(spawn / join / cancel / cancelled)的 effect-runner 比较简单,这里就不再进行介绍。

2.6.4 类 Task

Task 是 2.1 Task 的具体实现。

  1. // /src/core/Task.js

  2. class Task {

  3.  isRunning = true

  4.  isCancelled = false

  5.  isAborted = false

  6.  result = undefined

  7.  error = undefined

  8.  joiners = []

  9.  // cont will be set after calling constructor()

  10.  cont = undefined

  11.  constructor (taskQueue) {

  12.    this.taskQueue = taskQueue

  13.  }

  14.  // 调用 cancel 函数来取消该 Task,这将取消所有当前正在执行的 child-task 和 mainTask

  15.  // cancellation 会向下传播,意味着该 Task 对应的 saga-tree 子树都将会被取消

  16.  // 同时 cancellation 也会传递给该 Task 的所有 joiners

  17.  cancel = () => {

  18.    // 如果该 Task 已经完成或是已经被取消,则跳过

  19.    if (this.isRunning && !this.isCancelled) {

  20.      this.isCancelled = true

  21.      this.taskQueue.cancelAll()

  22.      // 将 TASK_CANCEL 传递给所有 joiners

  23.      this.end(TASK_CANCEL)

  24.    }

  25.   }

  26.  // 结束当前 Task

  27.  // 设置 Task 的 result/error,然后调用 task.cont,最后将结果传递给 joiners

  28.  // 当该 Task 的 child-task 和 mainTask 都完成时(即 fork-queue 完成时),该函数将被调用

  29.  end = (result, isErr) => {

  30.    this.isRunning = false

  31.    if (!isErr) {

  32.      this.result = result

  33.    } else {

  34.      this.error = result

  35.      this.isAborted = true

  36.    }

  37.    this.cont(result , isErr)

  38.    this.joiners.forEach(j => j.cb(result, isErr))

  39.    this.joiners = null

  40.  }

  41.  toPromise() {

  42.    // 获取 task 对应的 promise 对象,这里省略了代码

  43.  }

  44. }

2.6.5 小节

这一节中代码较多,而且代码的逻辑密度很高。想要完全理解 little-saga 的实现思路,还是需要仔细阅读源代码才行。

2.7 Task 状态变化举例

下面的代码是 2.6 中的例子。

  1. function* Parent() {

  2.  const Child1 = yield fork(api.xxxx) // LINE-1 需要 100ms 才能完成

  3.  const Child2 = yield fork(api.yyyy ) // LINE-2 需要 300ms 才能完成

  4.  yield delay(200) // LINE-3 需要 200 ms 才能完成

  5. }

下表展示了这个例子在一些关键时间点的执行情况与相应的状态变化。注意在下表中,如果没有指定 task/forkQueue/mainTask 是属于 Parent 还是 Child1/Child2,默认都是属于 Parent 的。下表只展示了这个例子正常完成的过程,我们也可以思考一下在 t=50 / t=150 / t=250 / t=350 等不同时间点,如果 Parent 被取消了,代码又会怎么执行。

2.8 类 Env

Env 的作用是在运行 rootSaga 之前,对 root Task 的运行环境进行配置。Env 采用了链式调用风格的 API,方便将多个配置串联起来。

我们可以利用 Env 来预先添加一些常见的 effect 类型,例如 all/race/take/put 等,这样后续所有的 saga 函数都可以直接使用这些 effect 类型。例如下面的代码在运行 rootSaga 之前定义了 delay 和 echo 两种 effect。

  1. new Env()

  2.  .def('delay', ([_, timeout], _ctx, cb) => setTimeout(cb, timeout))

  3.  .def('echo', ([_, arg], _ctx, cb) => cb(arg))

  4.  .run(rootSaga)

  5. function* rootSaga() {

  6.  yield ['delay', 500] // 500ms之后 yield 才会返回

  7.  yield ['echo', 'hello'] // yield 返回字符串 'hello'

  8. }

2.9 第二部分小节

至此,little-saga 的核心部分实现完毕。核心部分实现了 fork model,实现了 fork/join/cancel/promise/iterator 等内置类型的 effect-runner,并预留了拓展接口。第三部分中,我们将使用该拓展接口来实现 redux-saga 中剩下的那些 effect 类型(all/race/put/take 等)。

3.1 commonEffects 拓展

all-effect 的行为与 Promise#all 非常类似:all-effect 在构造时接受一些 effects 作为 sub-effects,当所有 sub-effects 完成时,all-effect 才算完成;当其中之一 sub-effect 抛出错误时,all-effect 会立即抛出错误。

有了 def effect,拓展 effect 就简单多了。redux-saga 中 runAllEffect 用于运行 all 类型的 effect,我们拷贝该代码,并简单修改,使其符合 effectRunner 接口,即可在 little-saga 中实现 all effect。little-sage 中实现 all effect 的代码如下:

  1. // 这里该函数是一个简化版,省略了 all-effect 被取消的处理代码

  2. // 这里假设 effects 是一个对象,实际版本中还需要考虑 effects为数组的情况

  3. function all([_, effects], ctx, cb, { digestEffect }) {

  4.  const keys = Object.keys(effects)







请到「今天看啥」查看全文