专栏名称: 前端外刊评论
最新、最前沿的前端资讯,最有深入、最干前端相关的技术译文。
目录
相关文章推荐
商务河北  ·  经开区“美•强•优”三重奏 ·  13 小时前  
前端早读课  ·  【第3453期】圈复杂度在转转前端质量体系中的应用 ·  22 小时前  
前端早读课  ·  【第3452期】React 开发中使用开闭原则 ·  昨天  
51好读  ›  专栏  ›  前端外刊评论

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

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

正文

知乎上已经有不少介绍 redux-saga 的好文章了,例如 redux-saga 实践总结、浅析 redux-saga 实现原理、Redux-Saga 漫谈。本文将介绍 redux-saga 的实现原理,并一步步地用代码构建 little-saga —— 一个 redux-saga 的简单版本。希望通过本文,更多人可以了解到 redux-saga 背后的运行原理。

本文是对 redux-saga 的原理解析,将不再介绍 redux-saga 的相关概念。所以在阅读文章之前,请确保对 redux-saga 有一定的了解。

文章目录

  • 0.1 文章结构

  • 0.2 名词解释

  • 0.3 关于 little-saga

  • 1.1 生成器函数

  • 1.2 使用 while-true 来消费迭代器

  • 1.3 使用递归函数来消费迭代器

  • 1.4 双向通信

  • 1.5 effect 的类型与含义

  • 1.6 result-first callback style

  • 1.7 cancellation

  • 1.8 effect 状态

  • 1.9 proc 初步实现

  • 2.1 Task

  • 2.2 fork model

  • 2.3 类 ForkQueue

  • 2.4 task context

  • 2.5 effect 类型拓展

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

  • 2.7 Task 状态变化举例

  • 2.8 类 Env

  • 2.9 第二部分小节

  • 3.1 commonEffects 拓展

  • 3.2 channelEffects 拓展

  • 3.3 compat 拓展

  • 3.4 scheduler

  • 3.5 其他细节问题

0.1 文章结构

本文很长,大致分为四部分。文中每一个章节有对应的 x.y 标记,方便相互引用。

  • 0.x 介绍文章的一些相关信息。

  • 1.x 讲解一些基础概念并实现一个简单版本的 proc 函数。

  • 2.x 介绍 redux-saga/little-saga 的一些核心概念,例如 Task、fork model、effect 类型拓展,并实现了 little-saga 核心部分。

  • 3.x 使用 little-saga 的拓展机制,实现了 race/all、channel、集成 redux 等功能,也讨论了一些其他相关问题。

0.2 名词解释

side effect 是来自函数式编程的概念,而 effect 这个单词直译过来是「作用」。「作用」是一个表意很模糊的词语,不太适合用在技术文章中,所以本文中将保持使用 effect 这个英文单词。不过,到底什么是 effect 呢?这又是一个很难解释清楚的问题。在本文中,我们放宽 effect 的概念,只要是被 yield 的值,都可以被称为 effect。例如 yield 1 中,数字 1 就是 effect,不过数字类型的 effect 缺少明确的含义;又例如 yield fetch ( 'some-url' ) 中, fetch ( 'some-url' ) 这个 Promise 对象便是 effect。

当我们使用 redux-saga 时,我们的业务代码往往充当 effect 的生产者:生成 effect,并使用 yield 语句将 effect 传给 saga-middleware。而 saga-middleware 充当 effect 的消费者:获取 effect,根据 effect 的类型解释该 effect,然后将结果返回给生产者。文中会使用 effect-producer 与 effect-runner 来表示生产者与消费者。注意 effect-runner 是需要将结果返回给 effect-producer 的,所以有的时候也需要将 redux-saga 看作一种「请求-响应」模型,我们业务代码生产 effect 以发起「请求」,而 saga-middleware 负责消费并将响应返回给业务代码。

saga 又是一个让人困扰的单词 (・へ・),这里给一个简单的说明。文中的「saga 函数」其实就是指 JavaScript 生成器函数,不过特指那些「作为参数传入函数 proc 或 sagaMiddleware.run 然后开始运行」的生成器函数。「saga 实例」指的是调用 saga 函数得到的迭代器对象。task 指的是 saga 实例运行状态的描述对象。

0.3 关于 little-saga

little-saga 大量参考了 redux-saga 源码,参考的版本为 redux-saga v1.0.0-beta.1。redux-saga 对许多边界情况做了处理,代码比较晦涩,而 little-saga 则进行了大量简化,所以两者有许多实现细节差异。本文中出现的代码都是 little-saga 的,不过我偶尔也会附上相应的 redux-saga 源码链接,大家可以对照着看。

little-saga 已经跑通了 redux-saga 的绝大部分测试(跳过了 little-saga 没有实现的那一部分功能的测试),使用 little - saga / compat 已经可以替换 redux-saga,例如我上次写的坦克大战复刻版就已经使用 little-saga 替换掉了 redux-saga。

little-saga 的特点是没有绑定 redux,所以有的时候(例如写网络爬虫、写游戏逻辑时)如果并不想使用 redux,但仍想用 fork model 和 channel 来管理异步逻辑,可以尝试一下 little-saga。little-saga 的初衷还是通过简化 redux-saga,能让更多人理解 redux-saga 背后的原理。

1.1 生成器函数

让我们先从 redux-saga 中最常见的 yield 语法开始。生成器函数使用 function * 声明,而 yield 语法只能出现在生成器函数中。在生成器执行过程中,遇到 yield 表达式立即暂停,后续可恢复执行状态。使用 redux-saga 时,所有的 effect 都是通过 yield 语法传递给 effect-runner 的,effect-runner 处理该 effect 并决定什么时候恢复生成器。InfoQ 上面的深入浅出 ES6(三):生成器 Generators 是一篇非常不错的文章,对生成器不了解的话,非常推荐阅读该文。

调用生成器函数我们可以得到一个迭代器对象(关于迭代器的文章推荐深入浅出 ES6(二):迭代器和 for-of 循环)。在比较简单的情况下,我们使用 for-of 循环来「消费」该迭代器,下面的代码就是一个简单的例子。

  1. function* range(start, end) {

  2.  for (let i = start; i < end; i++) {

  3.    yield i

  4.  }

  5. }

  6. for (let x of range(1, 10)) {

  7.  console.log(x)

  8. }

  9. // 输出 1, 2, 3 ... 8, 9

for-of 虽然很方便,但是功能有限。迭代器对象包含了三个方法:next/throw/return,for-of 循环只会不断调用 next 方法;next 方法是可以带参数的,而 for-of 循环调用该方法都是不传参的。例如,for-of 循环就无法处理下面这样的生成器函数了。

  1. function* saga() {

  2.  const someValue = yield ['echo', 3]

  3.  // someValue 应该为 3,但使用 for-of循环的话,该值为 undefined

  4.  yield Promise.reject(someError)

  5.  // effectRunner 遇到 rejected Promise 应该使用迭代器的 throw 方法抛出 someError

  6.  // 但使用 for-of 循环的话,无法调用迭代器的 throw 方法

  7. }

1.2 使用 while-true 来消费迭代器

如果我们不用 for-of,而是使用 while-true 循环自己实现消费者,手动调用 next/throw/return 方法,那么我们可以实现更多的功能。下面的代码实现了一个「遇到数字 5 就抛出错误」的 effec-runner。

  1. const iterator = range(1, 10)

  2. while (true) {

  3.  const { done, value } = iterator.next(/* 我们可以决定这里的参数 */)

  4.  if (done) {

  5.     break

  6.  }

  7.  if (value === 5) {

  8.    iterator.throw(new Error('5 is bad input'))

  9.  }

  10.  console.log(value)

  11. }

  12. // 输出 1, 2, 3, 4,然后抛出异常 '5 is bad input'

然而,while-true 仍有一个缺陷:while-true 是同步的。这意味着生成器无法暂停执行直到某个异步任务(例如网络请求)完成,也就意味着无法使用 while-true 实现 redux-saga 了。

1.3 使用递归函数来消费迭代器

effect-runner 的最终答案是:递归函数。递归函数满足了作为一个 effect-runner 的所有要求,不仅能够调用迭代器的 next/throw/return 方法,能在调用这些方法时使用指定参数,还能同步或异步地调用它们。

上一个例子中我们所有的代码都是同步的;而在下面的例子中,如果我们发现 value 是偶数的话,我们不马上调用 next,而是使用 setTimeout 延迟调用 next。

  1. const iterator = range(1, 10)

  2. function next() {

  3.  const { done, value } = iterator.next()

  4.  if ( done) {

  5.    return

  6.  }

  7.  console.log(value)

  8.  if (value % 2 === 0) {

  9.    setTimeout(next, value * 300)

  10.  } else {

  11.    next()

  12.  }

  13. }

  14. next()

  15. // 陆续输出 1, 2, 3 ... 8, 9

  16. // 偶数数字输出之后,需要等待一会儿才会输出下一个奇数

  17. // 奇数数字输出之后,立刻输出下一个偶数

这个例子比较简单,只是对 value 进行奇偶判断。不过我们不难设想以下的使用方法:effect-producer 产生 promise,而 effect-runner 对 promise 的处理方式如下:当 promise resolve 的时候调用迭代器 的 next 方法,当 promise reject 的时候调用迭代器的 throw 方法。我们就可以用生成器的语法实现 async/await,这也是生成器比 async/await 更加强大的原因。而 redux-saga/little-saga 不仅实现了对 promise 的处理,还实现了功能更为强大的 fork model。

在本文后面,我们称该递归函数为「驱动函数」。注意不要将驱动函数 next 和迭代器的 next 方法搞混,迭代器的 next 方法被调用的形式是 iterator . next ( someValue ) ,而驱动函数被调用的形式不同。在 redux-saga/little-saga,函数名字为 next 的情况只有驱动函数和迭代器 next 方法这两种,所以如果发现一个叫做 next 的函数,且该函数不是迭代器方法,那么该函数就是驱动函数。

1.4 双向通信

前面的例子中,我们只用到了单项通信:effect-runner 调用 iterator . next () 获取 effect,但 effect-runner 并没有将数据传递给 effect-producer。在 redux-saga 中,我们往往需要使用 yield 语句的返回值,返回值的含义取决于 effect 的类型。例如下面这个例子:

  1. function* someSaga() {

  2.  // yield 一个 promise 应该返回 promise resolve 的值

  3.  const response = yield fetch('https://example.com/')

  4.  // yield 一个 take effect 应该返回一个 Action

  5.  const action = yield take('SOME_ACTION')

  6.  // yield 一个 all effect 应该返回一个数组,该数组记录了 effect1 或 effect2 的执行结果

  7.  const allResult = yield all([effect1, effect2])

  8. }

为了实现双向通信,effect-runner 要提供合适的参数来调用 iterator . next ( arg ) 。当 iterator . next ( arg ) 被调用时,参数 arg 将会作为 yield xxx 语句的返回值,且暂停的迭代器会继续执行(直到遇到下一个 yield 语句)。为此我们修改前面的代码如下:

  1. function* range2(start, end) {

  2.  for (let i = start; i < end; i++) {

  3.    const response = yield i

  4.    console.log(`response of ${i} is ${response}`)

  5.  }

  6. }

  7. const iterator = range2(1, 10)

  8. function next(arg, isErr) {

  9.   // 注意驱动函数多了参数 arg 和 isErr

  10.  let result

  11.  if (isErr) {

  12.    result = iterator.throw(arg)

  13.  } else {

  14.    // 这里我们将 arg 作为参数传递给 iterator.next,作为 effect-producer 中 yield 语句的返回值

  15.    result = iterator.next(arg)

  16.  }

  17.  const { done, value } = result

  18.  if (done) {

  19.    return

  20.  }

  21.  console.log('getting:', value)

  22.  if (value === 5) {

  23.    // 将 isErr 置为 true,就能用递归的方式调用 iterator.throw 方法

  24.    next(new Error('5 is bad input'), true)

  25.  } else {

  26.    // 延迟调用驱动函数;「响应」是「请求」的两倍

  27.    setTimeout(() => next(value * 2), value * 1000)

  28.  }

  29. }

  30. next()

  31. // 输出

  32. // getting: 1

  33. // response of 1 is 2

  34. // getting: 2

  35. // response of 2 is 4

  36. // getting: 3

  37. // response of 3 is 6

  38. // getting: 4

  39. // response of 4 is 8

  40. // getting: 5

  41. // Uncaught Error: 5 is bad input

  42. // 输出 getting: x 之后,输出会暂停一段时间

1.5 effect 的类型与含义

前面的例子中我们的 effect-producer 都是简单的 range,effect(即被 yield 的值)为数字。因为数字没有什么确切的含义,effect-runner 只是简单地打印这些数字,然后再在合适地时刻调用驱动函数。

如果 effect 有明确的含义,effect-runner 就可以根据其含义来决定具体的执行逻辑。redux-saga 可以处理 promise、iterator、take、put 等类型的 effect,合理地组合不同类型的 effect 可以表达非常复杂的异步逻辑。下面我们给 little-saga 加上一些简单的 effect 的处理能力,是不是觉得这个和 co 很像呢?

  1. function* gen() {

  2.  console.log('enter ...')

  3.  const a = yield ['promise', fetch('/')]

  4.  console.assert(a instanceof Response)

  5.  const b = yield ['delay', 500]

  6.  console.assert(b === '500ms elapsed')

  7.  const c = yield ['ping']

  8.  console .assert(c === 'pong')

  9.  console.log('exit ... ')

  10. }

  11. const iterator = gen()

  12. function next(arg, isErr) {

  13.  let result

  14.  if (isErr) {

  15.    result = iterator.throw(arg)

  16.  } else {

  17.    result = iterator.next(arg)

  18.  }

  19.  const { done, value } = result

  20.  if (done) {

  21.    return

  22.  }

  23.  // 不打印 value,而是根据 value 的含义执行相应的处理逻辑

  24.  if (value[0] === 'promise') {

  25.    const promise = value[1]

  26.    promise.then(resolvedValue => next(resolvedValue), error => next(error, true))

  27.  } else if (value[0] === 'delay') {

  28.    const timeout = value[1]

  29.    setTimeout (() => next(`${timeout}ms elapsed`), timeout)

  30.  } else if (value[0] === 'ping') {

  31.    next('pong')

  32.  } else {

  33.    iterator.throw(new Error('无法识别的 effect'))

  34.  }

  35. }

  36. next()

在 redux-saga 中,effect 是一个由函数 effect 生成、 [ IO ] 字段为 true 的对象。little-saga 使用数组来表示 effect:数组的第一个元素为字符串,用于表示 effect 的类型,数组剩余元素为 effect 的参数。

前面几个小节介绍了 ES2015 生成器的特性,讲解了如何使用递归函数来实现 effect-runner。我们发现,约定一些常见的 effect 类型,并恰当使用这些类型的话,我们可以用生成器语法写出富有表达力的代码。

1.6 result-first callback style

在 Node.js 中,异步回调函数往往使用 error-first 的模式:第一个参数为 err,如果一个异步操作发生了错误,那么错误会通过 err 参数传递回来;第二个参数用于传递正确的操作结果,如果异步操作没有发生错误,那么操作结果会通过该参数进行传递。error-first 模式大量用于 node 核心模块(例如 fs 模块)和第三方库(例如 async 模块),可以阅读该文章了解更多信息。

而在 redux-saga/little-saga 中,我们使用 result-first 的模式。异步回调函数的第一个参数是操作结果,第二个参数是一个布尔值,表示是否发生了错误。我们称该风格为 result-first callback style,其类型信息用 TypeScript 表示如下:

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

redux-saga 源码中几乎所有回调函数都是该风格的,相应的变量名也有好几个:

  • cont continuation 缩写,一般用于表示 Task / MainTask / ForkQueue 的后继

  • cb callback 缩写 或是 currCb 应该是 currentCallback 的缩写。一般用于 effect 的后继/回调函数

  • next 就是前边的递归函数,它也是符合 result-first callback style 的

redux-saga 中这些变量名频繁出现,仅在一个 proc.js 文件中就出现了几十次。在后面 little-saga 的代码中,我们都将使用该风格的回调函数。

1.7 cancellation

redux-saga 的一大特点就是 effects 是可取消的,并且支持使用 try-catch-finally 的语法将清理逻辑放在 finally 语句块中。官方文档中也对任务取消做了说明。

在 redux-saga 具体实现中,调用者(caller)会将回调函数 cb 传递给被调用者(callee),当 callee 完成异步任务时,调用 cb 来把结果告诉给 caller。而 cancellation 机制是这么实现的:如果一个操作是可取消的话,callee 需要将「取消时的逻辑」放在 cb.cancel 上,这样一来当 caller 想要取消该异步操作时,直接调用 cb.cancel() 即可。函数调用是嵌套的,cb.cancel 的设置需要跟着函数调用一层层进行设置。在后面许多代码都会有类似 cb . cancel = xxx 的操作,这些操作都是在实现 cancellation。

文章如何取消你的 Promise?中也提到了多种取消 Promise 的方法,其中生成器是最具扩展性的方式,有兴趣的同学可以进行阅读。

1.8 effect 状态

effect 状态分为运行中、已完成(正常结束或是抛出错误结束都算完成)、被取消。

promise 一旦 resolve/reject 之后,就不能再改变状态了。effect 也是类似,一旦完成或是被取消,就不能再改变状态,「完成时的回调函数」和「被取消时的回调函数」合起来只能最多被调用一次。也就是说,effect 的「完成」和「被取消」是互斥的。

每一个 effect 在运行之前都会通过函数 digestEffect 的处理。该函数用变量 effectSettled 记录了一个 effect 是否已经 settled,保证了上述互斥性。

digestEffect 也调用了 normalizeEffect 来规范化 effect,这样一来,对于 promise/iterator,我们可以在 effect-producer 直接 yield 这些对象,而不需要将它们包裹在数组中。

digestEffect normalizeEffect 两个函数的代码如下:

  1. const noop = () => null

  2. const is = {

  3.  func: /* 判断参数是否函数 */

  4.  string: /* 判断参数是否字符串 */

  5.  /* ...... */

  6. }

  7. function digestEffect(rawEffect, cb) {

  8.  let effectSettled = false

  9.  function currCb(res, isErr) {

  10.    if (effectSettled) {

  11.      return

  12.    }

  13.    effectSettled = true

  14.    cb.cancel = noop

  15.    cb(res, isErr)

  16.  }

  17.  currCb.cancel = noop

  18.  cb.cancel = () => {

  19.     if (effectSettled) {

  20.      return

  21.    }

  22.    effectSettled = true

  23.    try {

  24.      currCb.cancel()

  25.    } catch (err) {

  26.      console.error(err)

  27.    }

  28.    currCb.cancel = noop

  29.  }

  30.  runEffect(normalizeEffect(rawEffect), currCb)

  31. }

  32. // normalizeEffect 定义在其他文件

  33. function normalizeEffect(effect, currCb) {

  34.  if (is.string(effect)) {

  35.    return [effect]

  36.  } else if (is.promise(effect)) {

  37.    return ['promise', effect]

  38.  } else if (is.iterator(effect)) {

  39.    return ['iterator', effect]

  40.  } else if







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