知乎上已经有不少介绍 redux-saga 的好文章了,例如 redux-saga 实践总结、浅析 redux-saga 实现原理、Redux-Saga 漫谈。本文将介绍 redux-saga 的实现原理,并一步步地用代码构建 little-saga —— 一个 redux-saga 的简单版本。希望通过本文,更多人可以了解到 redux-saga 背后的运行原理。
本文是对 redux-saga 的原理解析,将不再介绍 redux-saga 的相关概念。所以在阅读文章之前,请确保对 redux-saga 有一定的了解。
文章目录
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 循环来「消费」该迭代器,下面的代码就是一个简单的例子。
function* range(start, end) {
for (let i = start; i < end; i++) {
yield i
}
}
for (let x of range(1, 10)) {
console.log(x)
}
// 输出 1, 2, 3 ... 8, 9
for-of 虽然很方便,但是功能有限。迭代器对象包含了三个方法:next/throw/return,for-of 循环只会不断调用 next 方法;next 方法是可以带参数的,而 for-of 循环调用该方法都是不传参的。例如,for-of 循环就无法处理下面这样的生成器函数了。
function* saga() {
const
someValue = yield ['echo', 3]
// someValue 应该为 3,但使用 for-of循环的话,该值为 undefined
yield Promise.reject(someError)
// effectRunner 遇到 rejected Promise 应该使用迭代器的 throw 方法抛出 someError
// 但使用 for-of 循环的话,无法调用迭代器的 throw 方法
}
1.2 使用 while-true 来消费迭代器
如果我们不用 for-of,而是使用 while-true 循环自己实现消费者,手动调用 next/throw/return 方法,那么我们可以实现更多的功能。下面的代码实现了一个「遇到数字
5
就抛出错误」的 effec-runner。
const iterator = range(1, 10)
while (true) {
const { done, value } = iterator.next(/* 我们可以决定这里的参数 */)
if (done) {
break
}
if (value === 5) {
iterator.throw(new Error('5 is bad input'))
}
console.log(value)
}
// 输出 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。
const iterator = range(1, 10)
function next() {
const { done, value } = iterator.next()
if (
done) {
return
}
console.log(value)
if (value % 2 === 0) {
setTimeout(next, value * 300)
} else {
next()
}
}
next()
// 陆续输出 1, 2, 3 ... 8, 9
// 偶数数字输出之后,需要等待一会儿才会输出下一个奇数
// 奇数数字输出之后,立刻输出下一个偶数
这个例子比较简单,只是对 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 的类型。例如下面这个例子:
function* someSaga() {
// yield 一个 promise 应该返回 promise resolve 的值
const response = yield fetch('https://example.com/')
// yield 一个 take effect 应该返回一个 Action
const action = yield take('SOME_ACTION')
// yield 一个 all effect 应该返回一个数组,该数组记录了 effect1 或 effect2 的执行结果
const allResult = yield all([effect1, effect2])
}
为了实现双向通信,effect-runner 要提供合适的参数来调用
iterator
.
next
(
arg
)
。当
iterator
.
next
(
arg
)
被调用时,参数 arg 将会作为
yield
xxx
语句的返回值,且暂停的迭代器会继续执行(直到遇到下一个 yield 语句)。为此我们修改前面的代码如下:
function* range2(start, end) {
for (let i = start; i < end; i++) {
const response = yield i
console.log(`response of ${i} is ${response}`)
}
}
const iterator = range2(1, 10)
function next(arg, isErr) {
// 注意驱动函数多了参数 arg 和 isErr
let result
if (isErr) {
result = iterator.throw(arg)
} else {
// 这里我们将 arg 作为参数传递给 iterator.next,作为 effect-producer 中 yield 语句的返回值
result = iterator.next(arg)
}
const { done, value } = result
if (done) {
return
}
console.log('getting:', value)
if (value ===
5) {
// 将 isErr 置为 true,就能用递归的方式调用 iterator.throw 方法
next(new Error('5 is bad input'), true)
} else {
// 延迟调用驱动函数;「响应」是「请求」的两倍
setTimeout(() => next(value * 2), value * 1000)
}
}
next()
// 输出
// getting: 1
// response of 1 is 2
// getting: 2
// response of 2 is 4
// getting: 3
// response of 3 is 6
// getting: 4
// response of 4 is 8
// getting: 5
// Uncaught Error: 5 is bad input
// 输出 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 很像呢?
function* gen() {
console.log('enter ...')
const a = yield ['promise', fetch('/')]
console.assert(a instanceof Response)
const b = yield ['delay', 500]
console.assert(b === '500ms elapsed')
const c = yield ['ping']
console
.assert(c === 'pong')
console.log('exit ... ')
}
const iterator = gen()
function next(arg, isErr) {
let result
if (isErr) {
result = iterator.throw(arg)
} else {
result = iterator.next(arg)
}
const { done, value }
= result
if (done) {
return
}
// 不打印 value,而是根据 value 的含义执行相应的处理逻辑
if (value[0] === 'promise') {
const promise = value[1]
promise.then(resolvedValue => next(resolvedValue), error => next(error, true))
} else if (value[0] === 'delay') {
const timeout = value[1]
setTimeout
(() => next(`${timeout}ms elapsed`), timeout)
} else if (value[0] === 'ping') {
next('pong')
} else {
iterator.throw(new Error('无法识别的 effect'))
}
}
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 表示如下:
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
两个函数的代码如下:
const noop = () => null
const is = {
func: /* 判断参数是否函数 */
string: /* 判断参数是否字符串 */
/* ...... */
}
function digestEffect(rawEffect, cb) {
let effectSettled = false
function currCb(res, isErr) {
if (effectSettled) {
return
}
effectSettled = true
cb.cancel = noop
cb(res, isErr)
}
currCb.cancel = noop
cb.cancel = () => {
if (effectSettled) {
return
}
effectSettled = true
try {
currCb.cancel()
} catch (err) {
console.error(err)
}
currCb.cancel = noop
}
runEffect(normalizeEffect(rawEffect), currCb)
}
// normalizeEffect 定义在其他文件
function normalizeEffect(effect, currCb) {
if (is.string(effect)) {
return [effect]
} else if (is.promise(effect)) {
return ['promise', effect]
} else if (is.iterator(effect)) {
return ['iterator', effect]
} else if