前言
最近在阅读redux-saga的官网,发现其中的 Advanced Concepts 的内容十分精妙,学习里面的高级API的用法可以应对很多复杂的场景。因此自己在阅读的同时,把自己学到的总结到这篇文章中。下面直接逐一介绍其中的高级特性。
阅读下面的内容之前,需要知晓redux-saga的基本用法和了解redux-saga的内部运行原理。如果不知道,可以先提前阅读我之前写过的文章redux-saga:运用 ~ 原理分析 ~ 理解设计目的。
通过阅读该文章,你会学会redux-saga官网中推荐的很多高级玩法。
1. 通道(Channels)
1.1 channel
下面先展示一个经典的使用fork和take的例子:
import { take, fork } from 'redux-saga/effects'
function* watchRequests() {
while (true) {
const {payload} = yield take('REQUEST')
yield fork(handleRequest, payload)
}
}
function* handleRequest(payload) { ... }
上述的watchRequests存在一个隐患:fork是一个非阻塞的API。因此,在如果在短时间内有大量对应的action被捕获,则handleRequest会被不断地调用,如果handleRequest中带有网络请求的逻辑,则同一时间内会有大量的网络请求在执行。
假设我们针对上述缺点的解决方案是:同一时间内最多有三个handleRequest在执行,如果又有对应的action被派发,则等到三个正在执行的handleRequest中其中一个已结束后才能fork新的handleRequest。
可是上述的解决方案要怎么做呢?redux-saga提供了channel给我们去很方便地完成这种逻辑,直接看以下代码:
import { channel } from 'redux-saga'
import { take, fork, call } from 'redux-saga/effects'
function* watchRequests() {
// create a channel to queue incoming requests
// 创建channel去存储传入信息
const chan = yield call(channel)
// create 3 worker 'threads'
// 创建3个'工作线程',其实这里不算是线程,只是因为`fork`是非阻塞API,用于非阻塞地调用fn。
for (var i = 0; i < 3; i++) {
yield fork(handleRequest, chan)
}
while (true) {
const {payload} = yield take('REQUEST')
// 当有对应的action被派发,把action.payload存入到chan里
yield put(chan, payload)
}
}
function* handleRequest(chan) {
while (true) {
// 查看chan中的是否有信息存入,有则取出,然后运行下面的逻辑
const payload = yield take(chan)
// process the request
}
}
非常简短,而且对比于自己写限制函数。使用channel能让我们更好地测试。
channel这个API还有一个很好的特点,默认channel生成的通道会不限制数量地存储任何输入信息,但如果你想限制数量,可以在调用channel是传入redux-saga中buffer参数,如:
import { buffers,channel } from 'redux-saga'
function* watchRequests() {
// 此处设定只接受最多5个传入信息。
// 使用了buffers.sliding代表如果有新的action被派发,则舍弃最早传入的action。
// 其实就是存新弃旧。
const chan = yield call(channel, buffers.sliding(5))
...
}
其实,buffers除了sliding外有几种模式:
buffers.none(): 任何被存入的输入信息直接丢弃,不会缓存。buffers.fixed(limit): 输入信息会被缓存直到数量超过上限后,会抛出错误.此处limit的缺省值为10。buffers.expanding(initialSize): 输入信息会被缓存直到数量超过上限后会动态扩容。buffers.dropping(limit):action会被缓存直到数量超过上限后,即不会抛出错误,也不会缓存新的输入信息。buffers.sliding(limit): 输入信息会被缓存直到数量超过上限后,会移除最先缓存的输入信息,然后缓存最新的输入信息。
用法介绍的差不多了,如果想了解channel的相关源码,可看redux-saga中关于channel的那片学问 channel 源码分析。
1.2 actionChannel
我们再次看一下开头的那个watchRequests:
import { take, fork } from 'redux-saga/effects'
function* watchRequests() {
while (true) {
const {payload} = yield take('REQUEST')
yield fork(handleRequest, payload)
}
}
function* handleRequest(payload) { ... }
在1.1 channel中我们说了watchRequests中的一个存在的隐患:如果匹配的action在短时间内以极高的频率被派发,则同时会存在许多handleRequest任务在执行。 而在1.1 channel章节中,使用的解决方案是限制同一时刻中,handleRequest的执行数量。
如果我们想换一种解决方案:即串行执行handleRequest。其实就是限制同一时刻中只允许存在一个handleRequest在运行。那其实我们拿1.1 channel中运用channel代码例子来改一下就好了,把创建的“工作线程”的数量改成1就好了,如下所示:
import { channel } from 'redux-saga'
import { take, fork, call } from 'redux-saga/effects'
function* watchRequests() {
// create a channel to queue incoming requests
// 创建channel去存储传入信息
const chan = yield call(channel)
// 创建1个'工作线程
yield fork(handleRequest, chan)
while (true) {
const {payload} = yield take('REQUEST')
// 当有对应的action被派发,把action.payload存入到chan里
yield put(chan, payload)
}
}
function* handleRequest(chan) {
while (true) {
// 查看chan中的是否有信息存入,有则取出,然后运行下面的逻辑
const payload = yield take(chan)
// process the request
}
}
但其实,针对串行处理。redux-saga提供了一个更好用的API:actionChannel。我们来看一下如果上面的需求用actionChannel来实现是怎样子的:
import { take, actionChannel, call } from 'redux-saga/effects'
function* watchRequests() {
// 1- 发出ActionChannel Effect以创建管道用于存储来不及处理的action
const requestChan = yield actionChannel('REQUEST')
while (true) {
// 2- 把action从管道中取出
const {payload} = yield take(requestChan)
// 3- 调用handleRequest处理action
// 注意这里调用的是call阻塞API。如果调用fork非阻塞API,就达不到串行处理的效果
yield call(handleRequest, payload)
}
}
function* handleRequest(payload) { ... }
怎样,对比两种实现方式的代码,使用actionChannel的简洁多了是不是。还有一点,跟channel一样的,我们可以通过传入buffer来限制生成的通道存储action的模式,在actionChannel的第二参数中传入buffer参数既可,如下所示:
import { buffers } from 'redux-saga'
import { actionChannel } from 'redux-saga/effects'
function* watchRequests() {
const requestChan = yield actionChannel('REQUEST', buffers.sliding(5))
...
}
想了解actionChannel的相关源码分析可看redux-saga中关于channel的那片学问 actionChannel 源码分析。
1.3 eventChannel
eventChannel是一个工厂函数(与actionChannel不一样,eventChannel不是一个EffectCreator),用于创建一个通道,但该通道的事件源是脱离Redux store的。用一个例子来简单展示以下:
import { eventChannel, END } from 'redux-saga'
import { take, put, call } from 'redux-saga/effects'
function countdown(secs) {
/**
* eventChannel的形参为订阅函数,其范式为emitter=>(()=>{}||void)
* 每次调用emitter都会触发捕获该通道(即take(chan))的saga继续执行
*/
return eventChannel(emitter => {
const iv = setInterval(() => {
secs -= 1
if (secs > 0) {
/**
* emitter中传入的数据可以通过yield take(chan)获取到
* 官方推荐传入的数据的数据结构是纯函数,即:
* 比起emitter(number),emitter({number})更好
*/
emitter(secs)
} else {
/**
* 这种操作会让通道关闭, END是redux-saga定义的一个用于关闭通道的action
* 关闭通道后,不会在有信息传入该通道
*/
emitter(END)
}
}, 1000);
/**
* 如果返回结果为一个的函数,则该函数会用于用于注销订阅,
* 用于在关闭通道时,redux-saga内部会调用
*/
return () => {
clearInterval(iv)
}
}
)
}
export function* saga() {
const chan = yield call(countdown, 5)
try {
while (true) {
// 通道关闭后会导致saga直接跳到finally语句块
let seconds = yield take(chan)
console.log(`countdown: ${seconds}`)
}
} finally {
console.log('countdown terminated')
}
}
在上述saga被sagaMiddleware.run(saga)后,页面控制台会出现以下效果:
同样的,eventChannel也支持使用buffer控制传入信息的缓存模式。在eventChannel的第二形参可以传入buffer参数。
这么一看,eventChannel就是把事件源放在Redux Saga之外。其实这样子对我们平时写Redux Saga的逻辑有很大的扩展性。可以看一下我这篇文章在Redux中实现Lazy-Load,能让你少写很多dispatch语句,通过eventChannel实现Redux Stata中数据的延迟加载。
官网中提供了一个基于eventChannel的socket通信处理saga的示例,有兴趣的可以去看看。
想了解actionChannel的相关源码分析可看redux-saga中关于channel的那片学问 actionChannel 源码分析。
2. 组合saga(Composing Sagas)
一般来说,我们通过yield语句去构造编写saga的逻辑。但在写saga时我们要注意以下两点:
- 根据单一职责原则,把部分逻辑抽离成一个
saga。避免把过多的逻辑,过多的yield都写在一个saga上,以致测试时要写一堆重复的代码执行直到saga运行到要测试的那一部分代码区。 - 可以使用一些组合
API来执行多个项任务,从而减少saga的yield次数。
其实上述第一点很好理解,例如:
function* fetchPosts() {
yield put(actions.requestPosts())
const products = yield call(fetchApi, '/products')
yield put(actions.receivePosts(products))
}
function* watchFetch() {
while (yield take('FETCH_POSTS')) {
yield call(fetchPosts) // waits for the fetchPosts task to terminate
}
}
上面例子中把watchFetch中的while里面的逻辑抽离成一个saga,即fetchPosts,取而代之在while里用call调用fetchPosts。从而减少watchFetch的复杂度,提高测试的易行性。
第二点则需要我们去灵活使用all、race的使用方式,接下来依次学习一下两者是如何使用的。
2.1 all
all有两种传参方式,接下来依次介绍一下:
-
all([...effects])这种方式创建出来的
Effect会指示sagaMiddleware去串行依次处理其形参里传入的Effect,然后等待所有Effect执行结束后把结果按照顺序存进数组里返回给saga。传参方式和返回结果都与跟Promise.all一样。例子如下:import { fetchCustomers, fetchProducts } from './path/to/api' import { all, call } from `redux-saga/effects` function* mySaga() { const [customers, products] = yield all([ call(fetchCustomers), call(fetchProducts) ]) }当
call(fetchCustomers)的Effect处理完后,其结果会放在数组第一个元素上,即例子中的customers。其他的同理。 -
all(effects)此处的形参
effects是一个对象,例子如下:import { fetchCustomers, fetchProducts } from './path/to/api' import { all, call } from `redux-saga/effects` function* mySaga() { const { customers, products } = yield all({ customers: call(fetchCustomers), products: call(fetchProducts) }) }注意
effects中的Effect也是串行执行的。当call(fetchCustomers)的Effect处理完后,其结果会放在纯对象的customers属性上。其他的同理。注意
all中传入的Effect必须用take或call这类阻塞API生成,如果传入fork生成的Effect,会导致saga执行完毕后,Effect还在执行,如下例子所示:function timeout(sec) { return new Promise((resolve) => { setTimeout(() => { console.log(`${sec}s pass`); resolve(); }, sec * 1000); }); } function* allSaga() { yield all([fork(timeout, 2), fork(timeout, 3)]); console.log("allSaga finishs"); }allSaga执行后输出结果如下所示:
当调用all时,saga会一直处于阻塞状态直至形参中所有的Effect处理完毕。但如果有一个Effect在处理过程中抛出错误,则saga会退出阻塞状态且停止往下执行。为了让saga继续往下执行,可以用try-catch语句包裹其语句。如下所示:
import { fetchCustomers, fetchProducts } from './path/to/api'
import { all, call } from `redux-saga/effects`
function* mySaga() {
try{
const { customers, products } = yield all({
customers: call(fetchCustomers),
products: call(fetchProducts)
})
}catch(error){
// 处理错误
}
}
2.2 race
race与all的传参方式一样,但race中的Effect是并行处理的。且race和Promise.race的效果一样,即形参中一旦有一个Effect处理完成或者抛出错误,则直接结束阻塞状态:
-
race([...effects])和
all一样传入数组且会返回一个数组,直接举例子来解释:import { take, call, race } from `redux-saga/effects` import fetchUsers from './path/to/fetchUsers' function* fetchUsersSaga() { const [response, cancel] = yield race([ call(fetchUsers), take(CANCEL_FETCH) ]) }上述的例子中,
call(fetchUsers)和take(CANCEL_FETCH)生成的两个Effect处于竞速状态。其中call(fetchUsers)用于请求后端数据。take(CANCEL_FETCH)用于当外部dispatch了对应的action时,中断call(fetchUsers)的数据请求。当
call(fetchUsers)对应的Effect先完成处理时,race返回的数组[response, cancel]中response是fetchUsers返回的结果,而cancel是undefined。如果take(CANCEL_FETCH)对应的Effect先完成处理时,即对应的action被dispatch时,[response, cancel]中cancel是那个被派发的action而response是undefined。和
all一样,race中传入的Effect必须用take或call这类阻塞API生成,如果传入fork生成的Effect,会导致saga执行完毕后,Effect还在执行,如下例子所示:function* raceSaga() { yield race([fork(timeout, 2), fork(timeout, 3)]); console.log("raceSaga finishs"); }raceSaga执行后输出结果如下所示: -
race(effects)和
all一样传入对象且会返回一个对象,直接举例子来解释:import { take, call, race } from `redux-saga/effects` import fetchUsers from './path/to/fetchUsers' function* fetchUsersSaga() { const { response, cancel } = yield race({ response: call(fetchUsers), cancel: take(CANCEL_FETCH) }) }当
call(fetchUsers)对应的Effect先完成处理时,race返回的对象{response, cancel}中response是fetchUsers返回的结果,而cancel是undefined。如果take(CANCEL_FETCH)对应的Effect先完成处理时,{response, cancel}中cancel是那个被派发的action而response是undefined。
race适用的场景很多,在上面的fetchUsersSaga例子中,展示了一个可以手动终止的异步请求。下面再展示一个用race的例子:
function* game(getState) {
let finished
while (!finished) {
// has to finish in 60 seconds
const {score, timeout} = yield race({
score: call(play, getState),
timeout: delay(60000)
})
if (!timeout) {
finished = true
yield put(showScore(score))
}
}
}
上面的例子game中展示了一个需要在限制时间内完成的操作:call(play, getState)和delay(60000)一起竞速。如果play在60秒之内还没完成,则60秒后delay(60000)处理完毕返回处理结果,此时{score, timeout}中timeout为true(如果想自定义delay的返回值,可以在delay的第二形参上定义,例如delay(60000,'timeout'),则超时后返回'timeout'),score为undefined。再通过下面的if语句判断是否超时去处理。
3. 并发性(Concurrency)
takeEvery和takeLatest这两个API经常用于捕获action。其两者的区别是对Effect的并发性的处理。takeEvery允许多个处理同一个action的saga执行。而takeLatest值允许一个action的saga执行,如果同一个action被多次触发,则只会保留最新的saga在执行,上一个saga会被取消执行。
下面可以展示一下用take、fork这类基础API去实现上面两个API:
takeEvery
import {fork, take} from "redux-saga/effects"
const takeEvery = (pattern, saga, ...args) => fork(function*() {
while (true) {
const action = yield take(pattern)
yield fork(saga, ...args.concat(action))
}
})
takeLatest
import {cancel, fork, take} from "redux-saga/effects"
const takeLatest = (pattern, saga, ...args) => fork(function*() {
let lastTask
while (true) {
const action = yield take(pattern)
if (lastTask) {
// yield fork的返回值是一个Task,如果存在上一个Task,则调用cancel取消这个子任务
// 如果子任务已完成或已终止,则cancel会是一个空函数
yield cancel(lastTask)
}
lastTask = yield fork(saga, ...args.concat(action))
}
})
4. Fork模式(Fork Model)
在saga中,我们可以通过fork和spawn调度子任务在后台执行(子任务可以是生成器和函数)。但上述两个API有一点不一样:
fork用于生成附属调度(attached forks)spawn用于生成独立调度(detached forks)
下面我们一律把附属调度称为attached forks,独立调度称为detached forks。那么这两者有什么区别?下面逐一开始解释:
4.1 Attached forks(通过fork创建)
attached forks,根据字面的意思就知道其附属于某一方。在这里,attached forks附属于其父级,即发起调度的那个saga(下面我们称之为parent saga)。attached forks的执行周期与parent saga的执行周期相互影响,这正是attached forks与detached forks的区别,后者的执行周期不受任何外界因素影响。 接下来我们分几种情况来说一下attached forks与parent saga是如何相互影响的。
4.1.1 正常执行
正常执行指的是saga在执行过程中不存在cancel和内部抛出错误一个saga。在正常执行过程中会随着下面两种行为而终止:
-
saga自身语句执行完毕 -
saga发起的attached forks已执行完毕
举个例子:
function* delayTimeout(sec, err = false) {
yield delay(sec * 1000);
console.log(`${sec}s pass`);
if (err) throw new Error();
}
function* fetchAllWithDelay() {
yield fork(delayTimeout, 2);
yield fork(delayTimeout, 3);
console.log("fetchAllWithDelay finishs");
}
function* rootSaga() {
/** 注意此处用call而不是fork,因为前者是阻塞API,
* 在fetchAllWithDelay结束后才会执行下一步打印,从而知道fetchAllWithDelay
* 啥时候终止。
*/
yield call(fetchAllWithDelay);
console.log('root finish');
}
在sagaMiddleware.run(rootSaga)后,rootSaga通过call执行fetchAllWithDelay。在fetchAllWithDelay中,会依次发起两个延时delayTimeout的attached fork。然后打印输出"fetchAllWithDelay finishs"。此时因为两个attached fork还没执行完,故会fetchAllWithDelay一直等待直至两个attached fork已执行完。因此,当rootSaga终止时,控制台输出如下:
fetchAllWithDelay finishs
2s pass
3s pass
root finish
4.1.2 错误传出
错误传出指在执行过程中,attached fork和parant saga两者之一的内部抛出错误或手动执行Promise.reject的情况。
直接举一个例子说明:
function* delayTimeout(sec, err = false) {
yield delay(sec * 1000);
console.log(`${sec}s pass`);
if (err) throw new Error();
}
function* fetchAllWithDelay() {
yield fork(delayTimeout, 2, true);
yield fork(delayTimeout, 3);
yield delay(4000)
console.log("fetchAllWithDelay finishs");
}
function* rootSaga() {
try {
yield call(fetchAllWithDelay);
} catch (error) {
console.log('error from root',error);
}
console.log('root finish');
}
已知delayTimeout在第二个形参为true时,会抛出错误。那可知fetchAllWithDelay作为parent saga,其中一个attach fork:delayTimeout(2, true)会抛出错误。
只要parent saga出现错误,parent saga会做两件事:
-
取消附属于自身的
attached forks的执行(如果已经执行完则不受影响) -
终止自身的执行并抛出来自
attach fork或自身的错误
按照上面的规律,我们可以推理出以下流程: attach fork:delayTimeout(2, true)抛出错误后,fetchAllWithDelay终止另外一个attached fork:delayTimeout(3, true),然后终止自身的执行,包括delay(4000)的处理。
最后在控制台中输出如下:
关于错误传出的情况,有两点值得注意一下:
-
parent saga会取消附属于自身的attached fork的执行,但其实她是终止对attached fork中产出的Effect的处理和执行权的交还,相当于在attached fork内部调用了cancel。但如果attached fork内部没有yield语句,则attached fork还是会继续执行,如下所示:function* delayTimeout(sec, err = false) { // yield delay(sec * 1000); // console.log(`${sec}s pass`); // if (err) throw new Error(); // 如果我们把逻辑改成下面的样子,则基于该生成器生成的attached fork无法终止 return new Promise((resolve, reject) => { setTimeout(() => { console.log(`${sec}s pass`); if (err) { return reject(new Error()); } resolve(); }, sec * 1000); }); }更改上面的函数后,最后控制台输出如下所示:
-
关于错误处理
注意我们捕捉错误,即
try-catch语句块是包裹在yield call(fetchAllWithDelay)上的,而不是包裹在fetchAllWithDelay内部的语句里。这是一个经验法则:不能从fork语句上捕捉attached fork的错误,因为来自attached fork的错误会让parent saga终止自身的执行。
4.1.3 取消行为
取消行为指的是在saga内部调用cancel()取消自身的执行和在parent saga中调用cancel(task)取消attached fork的执行。
当parent saga被外部调用cancel或者在自身内部中调用cancel时,会导致parent saga的终止以及其正在执行的attached fork的终止。
举个例子:
function* fetchAllWithCancel() {
yield fork(delayTimeout, 2);
yield fork(delayTimeout, 3);
yield delay(4 * 1000);
console.log("fetchAllWithCancel finish");
}
function* cancelSaga() {
const task = yield fork(fetchAllWithCancel);
yield cancel(task);
}
function* rootSaga() {
yield call(cancelSaga);
console.log('root finish');
}
最后控制台输出如下:
root finish
4.2 Detached forks(通过spawn创建)
detached fork的执行和parent saga的执行相互不影响:
-
parent saga不会等待detached fork执行完成后才终止 -
在
detached fork中抛出的错误不会冒泡传播到parent saga上 -
对
parent saga执行cancel时,其detached fork无论是正在执行还是已执行完,都不会被终止
简而言之,detached fork表现起来和直接被middleware.run执行的saga的效果一样。
5. 控制流(Control Flow)
到现在很多人在saga中用takeEvery多于take,因为如果对应的场景只是在匹配的action上调度子任务,那么用takeEvery要比用take少写while语句块。但用take会更灵活地掌握sage中的触发流程,在官网中称之为Control Flow。下面展示一下需要我们设计Control Flow的例子:
假设现在要实现一个需求:在一个温度监控系统中,如果温度在10秒内四次超过阈值,则触发报警。
我们用redux-saga可以很巧妙地实现上面的需求,首先要分析怎么做,每次温度超过阈值都会派发(dispatch)一个对应的action:({type:'EXCEED'})。我们可以在saga中通过take捕获这个action,问题是怎么统计每连续四次超出记录都在10秒内呢?我们可以创建一个数组,放置最近3次的超出记录的时间戳,之后当有一个新的记录,我们就用当前时间戳去和数组头部的时间戳对比,如果两者小于10s,则触发报警,且把头部的时间戳移除,把当前的时间戳塞进尾部。 相关的saga代码如下所示:
function* watchSaga() {
const timeRecord = [];
let i = 0;
while (i < 3) {
yield take("EXCEED");
timeRecord.push(new Date().getTime());
i++;
}
while (true) {
yield take("EXCEED");
const currentTime = new Date().getTime();
const previousTime = timeRecord.shift();
if (currentTime - previousTime > 10 * 1000) {
// 派发{ type: "SHOW_ALERT" }显示报警
yield put({ type: "SHOW_ALERT" });
}
timeRecord.push(currentTime);
}
}
利用saga是generator的特性,我们可以很巧妙地实现以上需求。想象一下,take就像一个debugger断点,控制着程序执行到哪一步就停留等待,直到我们想让他再次执行才继续往下走,这就是Control Flow的魅力所在。
redux-saga官网中的NonBlockingCalls章节中也提供了个典型的基于Control Flow的例子:
function* loginFlow() {
while (true) {
yield take('LOGIN')
// ... perform the login logic
yield take('LOGOUT')
// ... perform the logout logic
}
}
通常一个网站是先登录后退出,那可以把整个流程都塞在一个saga上,然后编写对应的控制流逻辑。官网针对这个例子补充了很多细节,更多详细的可以阅读上面的链接。
5. RootSaga的模式(Root Saga Patterns)
首先要说明什么是RootSaga。在调用sagaMiddleware.run时,作为形参传入的saga,就是RootSaga。在RootSaga内部会通过fork之类的API去调度其他saga。本章节就是探讨在RootSaga怎么调度saga开启子任务更好。下面依次说明和比较几种常用的调度写法:
-
模式一:非阻塞
fork Effect调度export default function* rootSaga() { yield fork(saga1) yield fork(saga2) yield fork(saga3) // code after fork-effect }这是一种比较常用的模式,因为
fork是非阻塞API,因为上面例子中调度的三个saga都并行执行。且用fork调度会返回一个任务描述符(task descriptor),我们可以通过cancel和join之类的API对该任务描述符进行操作。此外以上逻辑还有一个更简化的写法:
const [task1, task2, task3] = yield all([ fork(saga1), fork(saga2), fork(saga3) ])但以上的写法有个隐患:
rootSaga调度的所有saga中,一旦有一个saga内部有错误抛出,则整个rootSaga以及其调度的其余所有正在执行的saga都会终止(在4. Fork Model(Fork模式)章节有详细说过**)。而且在rootSaga不能通过try-catch捕获这些被调度的saga传出的错误进行处理**。 -
模式二:让
Root Saga保持正常运行export default function* rootSaga() { yield spawn(saga1) yield spawn(saga2) yield spawn(saga3) }模式一中的隐患在这种模式下可以得到解决,因为
spawn是分离调度,其调度的saga与Root Saga脱钩。因此其中一个saga传出的错误不会冒泡到Root Saga从而导致其终止。 -
模式三:让一切保持正常运行
模式二中的写法基本解决模式一中的基本问题,但还是存在两个小缺点:
-
其中一个被调度的
saga一旦抛出错误后了就会停止运行 -
被调度的
saga抛出错误后没有对其错误进行处理
下面展示一个能弥补上面缺点的写法:
function* rootSaga () { const sagas = [saga1, saga2, saga3]; yield all(sagas.map(saga => spawn(function* () { while (true) { try { // saga出错会退出call阻塞,然后while循环再次调用call重新阻塞执行saga yield call(saga) break } catch (e) { // 这里的错误处理可以自行定义,例如上报运行错误等 console.log(e) } } })) ); } -
6. 任务的取消(Task cancellation)
这里的任务(Task) 指的是由call、fork调度saga而创建的任务,下面统一用Task称呼。一旦Task被创建,我们可以通过两种方法取消Task:
-
外部取消:在父级
saga中通过yield cancel(task)取消。 -
内部取消:在其内部调用
yield cancel()取消。
本章节主要说当调用cancel后的种种细节。
为了方便理解,我们先假定存在一个场景:前端页面中有个开关组件,组件开启时前端需要周期性地从后端同步一些数据,组件关闭后停止同步过程。针对这个场景,我们可以设定在开启和关闭时都派发对应的action,这里假设开启时派发action:({type:'START_SYNC'}),关闭时派发action:({type:'STOP_SYNC'})。那么可以通过以下saga实现:
import { take, put, call, fork, cancel, cancelled, delay } from 'redux-saga/effects'
import { someApi, actions } from 'somewhere'
function* syncSaga() {
try {
while (true) {
// 页面显示同步执行中
yield put({type: 'SHOW_SYNC_PENDING'})
// 调用异步方法获取后端数据后存入store中
const result = yield call(someApi)
yield put({type: 'SAVE_SYNC_DATA', payload: {data: result}})
// 页面显示同步执行完成
yield put({type: 'SHOW_SYNC_SUCCESS'})
// 间隔5秒后再次同步数据
yield delay(5000)
}
/**
* 当syncTask被cancel时,会导致Generator.prototype.return的执行
* 从而使syncTask内部的运行直接跳到finally语句块中
*/
} finally {
/**
* 通过cancelled检查Task自身是否已被取消,如果还没被取消,
* 则会一直阻塞直到取消才会退出阻塞。
*/
if (yield cancelled())
// 页面显示同步已停止
yield put({type: 'SHOW_SYNC_STOP'})
}
}
function* main() {
// 当开关组件被开启,action:({type:'START_SYNC'})被派发,`main`开始进入while语句块执行
while ( yield take('START_SYNC') ) {
// 调度syncSaga生成syncTask
const syncTask = yield fork(syncSaga)
// 等待action:({type:'STOP_SYNC'})被派发
yield take('STOP_SYNC')
// 当开关组件被关闭后,取消syncTask的执行
yield cancel(syncTask)
}
}
取消正在执行的Task不但会让其跳到finally语句块(注意:有些saga不一定写了finally语句块),还会取消那些由Task生成的正被sagaMiddleware处理的Effect。举个例子:
function* main() {
const task = yield fork(subtask)
...
yield cancel(task)
}
function* subtask() {
yield call(subtask2)
}
function* subtask2() {
yield call(someApi)
}
当main中yield cancel(task)被执行后,会取消subtask及其正被处理的call Effect,即subtask2会被取消。其取消行为的执行过程形成一个链式反应:从subtask到subtask2到someApi。我们可以看到这个反应过程是往下传播的(对应的,例如错误抛出和冒泡事件都是向上传播的)。
为了更方便地解释,假设存在两个角色,分别是caller和callee,caller是异步操作的调用方,callee是被调用方,以上面的代码作为例子,则有:
subtask和subtask2是caller和callee的关系subtask2和someApi是caller和callee的关系
当caller要取消正在执行中的callee时,会触发一系列的向下传播的反应。callee被取消的时候,如果该callee也是一个caller(就像上面的subtask2),也会对作为自己所对应的callee执行取消操作。
除了上面说的向下传播,取消行为还有另一种传播方向,在4.1.3 取消行为章节中说到,cancel会取消saga以及其attacked fork。因此不仅callee会被执行取消,attacked fork也会被执行取消,而attacked fork作为caller其下的callee同样也会被执行取消。
7. 测试(Testing)
阅读本章节要求你有对saga进行单元测试的代码经验,如果你还没了解这方面的操作,可以看一下我之前写过的文章的章节使用jest对saga进行测试。
redux-saga官方提供了一个专门用于测试的库@redux-saga/testing-utils,但这个库里面只有两个方法:cloneableGenerator和createMockTask,借助这两个方法,我们可以基本完成所有简单或复杂的saga的单元测试,接下来依次说一下这两个方法有什么作用。
**注意:@redux-saga/testing-utils要通过npm i @redux-saga/testing-utils -D**独立安装。
7.1 cloneableGenerator
假设下面是我们要测试的saga:
export function* setColorWhenModeChange() {
const action = yield take("CHANGE_MODE");
switch (action.payload.mode) {
case 0:
yield put({ type: "SET_COLOR", payload: { color: "white" } });
break;
case 1:
yield put({ type: "SET_COLOR", payload: { color: "black" } });
break;
default:
break;
}
}
上面的saga实现的功能是:当UI交互中设置mode时,派发action:({type:"CHANGE_MODE"}),如果mode值被设置为0,则把颜色设置为white。如果mode值被设置为1,则把颜色设置为black。
对于上面的saga中,存在着条件判断(if或switch)。在单元测试中,如果不借助外部方法,则在该条件判断语句块中,有多少个分支,我们就要初始化相应数量的迭代器进行测试,这会增加代码的复杂度。此时,我们可以借助cloneableGenerator来解决这个问题, 如下面的代码所示:
import { cloneableGenerator } from "@redux-saga/testing-utils";
import { setColorWhenModeChange } from "./saga";
describe("setColorWhenModeChange testing", () => {
// 调用cloneableGenerator生成gen
const gen = cloneableGenerator(setColorWhenModeChange)();
// 第一条语句是yield take('CHANGE_MODE')",因此,直接一波基本操作
test("test: yield take('CHANGE_MODE')", () => {
expect(gen.next().value).toEqual(take("CHANGE_MODE"));
});
// 下面就要测试switch语句块里的Effect了
// 这里测试mode为0时,触发的yield put({type:'SET_COLOR',payload:{color:'white'}})
test("test: yield put({type:'SET_COLOR',payload:{color:'white'}})", () => {
/**
* 用cloneableGenerator生成的gen可以调用clone生成一个副本,
* 该副本的执行位置停留在调用clone时,gen内部的执行位置,
* 通过这种生成副本的方法,我们无需每次测试分支都要初始化一个新的iterator且走完前面共有的流程
*/
const clone = gen.clone();
expect(
clone.next({ type: "CHANGE_MODE", payload: { mode: 0 } }).value
).toEqual(put({ type: "SET_COLOR", payload: { color: "white" } }));
});
// 这里测试mode为1时,触发的yield put({type:'SET_COLOR',payload:{color:'black'}})
test("test: yield put({type:'SET_COLOR',payload:{color:'white'}})", () => {
const clone = gen.clone();
expect(
clone.next({ type: "CHANGE_MODE", payload: { mode: 1 } }).value
).toEqual(put({ type: "SET_COLOR", payload: { color: "black" } }));
});
});
7.2 createMockTask
我们以6. 任务的取消(Task cancellation) 中那个同步的例子中的main方法做单元测试,先看回之前那个例子的代码:
// 在main的单元测试中,我们不需要关心syncSaga里面的逻辑,至于原因可以留到下面看
export function* syncSaga() {}
export function* main() {
// 当开关组件被开启,action:({type:'START_SYNC'})被派发,`main`开始进入while语句块执行
while (yield take("START_SYNC")) {
// 调度syncSaga生成syncTask
const syncTask = yield fork(syncSaga);
// 等待action:({type:'STOP_SYNC'})被派发
yield take("STOP_SYNC");
// 当开关组件被关闭后,取消syncTask的执行
yield cancel(syncTask);
}
}
在上面的main中,存在fork生成的任务Task。至于在单元测试中,要怎么模拟这个Task?@redux-saga/testing-utils中提供的createMockTask方法为我们解决了这个难题,接下来看看对于main的单元测试代码:
import { createMockTask } from "@redux-saga/testing-utils";
import { main, syncSaga } from "./saga";
describe("main testing", () => {
// 生成迭代器
const gen = main();
// 测试while (yield take("START_SYNC")),基本操作不解释
test('test: yield take("START_SYNC")', () => {
expect(gen.next().value).toEqual(take("START_SYNC"));
});
// 测试yield fork(syncSaga)
test("test: yield fork(syncSaga)", () => {
/**
* 注意这里要生成action:{ type: "START_SYNC" }放入gen.next中,
* 因为从while (yield take("START_SYNC"))可知,
* 如果yield take("START_SYNC")不返回一个真值,
* 则while括号里的值为假,继而无法走进while语句块
*/
const mockAction = { type: "START_SYNC" };
expect(gen.next(mockAction).value).toEqual(fork(syncSaga));
});
test('test: yield take("STOP_SYNC") and yield cancel(syncTask)', () => {
/**
* 在上一条语句中调用fork生成task,在测试中,我们无需关注task的内部逻辑,
* 只需要知道其运行状态即可,因为task的内部逻辑不受外界影响,但其运行状态可能会被父级saga改变
* 因此,我们可以调用createMockTask生成一个mockTask,然后放入gen.next中,
* 方便之后测试cancel逻辑
*/
const mockTask = createMockTask();
// 此处测试yield take("STOP_SYNC"),基本操作,不过要注意把生成的mockTask放入gen.next中
expect(gen.next(mockTask).value).toEqual(take("STOP_SYNC"));
/**
* 此处测试yield cancel(syncTask)
* 这里的syncTask其实就是刚刚放入的mockTask,
* 因此直接调用cancel生成Effect对比即可
*/
expect(gen.next().value).toEqual(cancel(mockTask));
});
});
7.3 两种测试模式
官方中提出了两种测试saga的模式:
-
逐步测试生成器函数:这个就和前面写的所有测试用例是一种思路,都是对
saga产出的Effect逐个对比。 -
运行整个中间件且对其边界效应进行断言:这种模式是调用一个
mock sagaMiddleware运行saga,然后通过mock sagaMiddleware派发对应的action来捕捉内部的反应。
针对上面的两种模式都有第三方库,第一种模式推荐使用redux-saga-testing,第二种模式推荐使用redux-saga-tester。大家可以自行阅读了解。
8. 适合你胃口的API(Recipes)
下面简洁介绍三个在redux-saga中内置的常用的API:throttle、debounce以及retry。
8.1 throttle~节流函数
我们都知道,节流指的是在规定时间内,无论调用多少次方法,该方法只会被执行一次。带着这个知识点,我们直接看一下使用redux-saga中内置的throttle的例子:
import { throttle } from 'redux-saga/effects'
function* handleInput(input) {
// ...
}
function* watchInput() {
yield throttle(500, 'INPUT_CHANGED', handleInput)
}
当watchInput执行后,在500ms内的action:({type:'INPUT_CHANGED'})即使被多次派发,handleInput也只会执行一次。
8.2 debounce~防抖函数
已知,防抖指的是在在每调用一次方法,该方法都会延迟指定时间后再执行,如果在延迟时间内本方法再次被调用,则上一次调用取消,在最新一次调用的基础上延迟指定时间后执行。带着这个知识点,我们直接看一下使用redux-saga中内置的debounce的例子:
import { debounce } from `redux-saga/effects`
function* handleInput(action) {
//...
}
function* watchInput() {
yield debounce(500, 'INPUT_CHANGED', fetchAutocomplete)
}
当debounceAutocomplete执行后,action:({type:'FETCH_AUTOCOMPLETE'})被派发后过1000ms的延迟时间才执行fetchAutocomplete。我们可以用一些基础的API实现上面的逻辑,如下所示:
import { call, cancel, fork, take, delay } from 'redux-saga/effects'
function* handleInput(input) {
// debounce by 500ms
yield delay(500)
...
}
function* watchInput() {
let task
while (true) {
const { input } = yield take('INPUT_CHANGED')
if (task) {
yield cancel(task)
}
task = yield fork(handleInput, input)
}
}
8.3 retry~重试
这个API类似于call调用异步方法,不过比后者多出了重试机制,可以指定重新调用的次数以及每次调用的执行时间。要用try~catch语句包裹着。例子如下:
import { put, retry } from 'redux-saga/effects'
import { request } from 'some-api';
function* retrySaga(data) {
try {
const response = yield retry(3, 10 * 1000, request, data)
yield put({ type: 'REQUEST_SUCCESS', payload: response })
} catch(error) {
yield put({ type: 'REQUEST_FAIL', payload: { error } })
}
}
在上面的retrySaga中,request这个异步方法可以调用3次,每次调用执行时超过10秒就会被认定失败。如果首次调用request因为超出10秒或者网络错误的原因导致失败,那么会再次调用request直到调用成功或者调用次数超过3次。
后记
这篇文章写了蛮久的,觉得有用的话,请点个赞喔,有什么疑问可以随时在下留言。