阅读 432
踏入进阶!redux-saga中的八个高阶知识点

踏入进阶!redux-saga中的八个高阶知识点

前言

最近在阅读redux-saga官网,发现其中的 Advanced Concepts 的内容十分精妙,学习里面的高级API的用法可以应对很多复杂的场景。因此自己在阅读的同时,把自己学到的总结到这篇文章中。下面直接逐一介绍其中的高级特性。

阅读下面的内容之前,需要知晓redux-saga的基本用法和了解redux-saga的内部运行原理。如果不知道,可以先提前阅读我之前写过的文章redux-saga:运用 ~ 原理分析 ~ 理解设计目的

通过阅读该文章,你会学会redux-saga官网中推荐的很多高级玩法。

1. 通道(Channels)

1.1 channel

下面先展示一个经典的使用forktake的例子:

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-sagabuffer参数,如:

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提供了一个更好用的APIactionChannel。我们来看一下如果上面的需求用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')
  }
}
复制代码

在上述sagasagaMiddleware.run(saga)后,页面控制台会出现以下效果:

eventchannel-test.gif

同样的,eventChannel也支持使用buffer控制传入信息的缓存模式。在eventChannel的第二形参可以传入buffer参数。

这么一看,eventChannel就是把事件源放在Redux Saga之外。其实这样子对我们平时写Redux Saga的逻辑有很大的扩展性。可以看一下我这篇文章在Redux中实现Lazy-Load,能让你少写很多dispatch语句,通过eventChannel实现Redux Stata中数据的延迟加载。

官网中提供了一个基于eventChannelsocket通信处理saga的示例,有兴趣的可以去看看。

想了解actionChannel的相关源码分析可看redux-saga中关于channel的那片学问 actionChannel 源码分析

2. 组合saga(Composing Sagas)

一般来说,我们通过yield语句去构造编写saga的逻辑。但在写saga时我们要注意以下两点:

  1. 根据单一职责原则,把部分逻辑抽离成一个saga。避免把过多的逻辑,过多的yield都写在一个saga上,以致测试时要写一堆重复的代码执行直到saga运行到要测试的那一部分代码区。
  2. 可以使用一些组合API来执行多个项任务,从而减少sagayield次数。

其实上述第一点很好理解,例如:

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的复杂度,提高测试的易行性。

第二点则需要我们去灵活使用allrace的使用方式,接下来依次学习一下两者是如何使用的。

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必须用takecall这类阻塞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执行后输出结果如下所示:

    allSaga-fork-test.gif

当调用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

raceall的传参方式一样,但race中的Effect并行处理的。且racePromise.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]responsefetchUsers返回的结果,而cancelundefined。如果 take(CANCEL_FETCH)对应的Effect先完成处理时,即对应的actiondispatch时,[response, cancel]cancel是那个被派发的actionresponseundefined

    all一样,race中传入的Effect必须用takecall这类阻塞API生成,如果传入fork生成的Effect,会导致saga执行完毕后,Effect还在执行,如下例子所示:

    function* raceSaga() {
     yield race([fork(timeout, 2), fork(timeout, 3)]);
     console.log("raceSaga finishs");
    }
    复制代码

    raceSaga执行后输出结果如下所示:

    raceSaga-fork-test.gif

  • 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}responsefetchUsers返回的结果,而cancelundefined。如果 take(CANCEL_FETCH)对应的Effect先完成处理时,{response, cancel}cancel是那个被派发的actionresponseundefined

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}timeouttrue(如果想自定义delay的返回值,可以在delay的第二形参上定义,例如delay(60000,'timeout'),则超时后返回'timeout'scoreundefined。再通过下面的if语句判断是否超时去处理。

3. 并发性(Concurrency)

takeEverytakeLatest这两个API经常用于捕获action。其两者的区别是对Effect并发性的处理。takeEvery允许多个处理同一个actionsaga执行。而takeLatest值允许一个actionsaga执行,如果同一个action被多次触发,则只会保留最新的saga在执行,上一个saga会被取消执行。

下面可以展示一下用takefork这类基础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中,我们可以通过forkspawn调度子任务在后台执行(子任务可以是生成器函数)。但上述两个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 forksdetached forks的区别,后者的执行周期不受任何外界因素影响。 接下来我们分几种情况来说一下attached forksparent saga是如何相互影响的。

4.1.1 正常执行

正常执行指的是saga在执行过程中不存在cancel和内部抛出错误一个saga。在正常执行过程中会随着下面两种行为而终止:

  1. saga自身语句执行完毕

  2. 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中,会依次发起两个延时delayTimeoutattached fork。然后打印输出"fetchAllWithDelay finishs"。此时因为两个attached fork还没执行完,故会fetchAllWithDelay一直等待直至两个attached fork已执行完。因此,当rootSaga终止时,控制台输出如下:

fetchAllWithDelay finishs
2s pass
3s pass
root finish
复制代码

4.1.2 错误传出

错误传出指在执行过程中,attached forkparant 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会做两件事:

  1. 取消附属于自身的attached forks的执行(如果已经执行完则不受影响)

  2. 终止自身的执行并抛出来自attach fork或自身的错误

按照上面的规律,我们可以推理出以下流程: attach fork:delayTimeout(2, true)抛出错误后,fetchAllWithDelay终止另外一个attached fork:delayTimeout(3, true),然后终止自身的执行,包括delay(4000)的处理。

最后在控制台中输出如下:

image.png

关于错误传出的情况,有两点值得注意一下:

  1. 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);
      });
    }
    复制代码

    更改上面的函数后,最后控制台输出如下所示:

    image.png

  2. 关于错误处理

    注意我们捕捉错误,即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的执行相互不影响:

  1. parent saga不会等待detached fork执行完成后才终止

  2. detached fork中抛出的错误不会冒泡传播到parent saga

  3. 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);
  }
}
复制代码

利用sagagenerator的特性,我们可以很巧妙地实现以上需求。想象一下,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开启子任务更好。下面依次说明和比较几种常用的调度写法:

  1. 模式一:非阻塞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),我们可以通过canceljoin之类的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传出的错误进行处理**。

  2. 模式二:让Root Saga保持正常运行

    export default function* rootSaga() {
      yield spawn(saga1)
      yield spawn(saga2)
      yield spawn(saga3)
    }
    复制代码

    模式一中的隐患在这种模式下可以得到解决,因为spawn分离调度,其调度的sagaRoot Saga脱钩。因此其中一个saga传出的错误不会冒泡到Root Saga从而导致其终止。

  3. 模式三:让一切保持正常运行

    模式二中的写法基本解决模式一中的基本问题,但还是存在两个小缺点:

    • 其中一个被调度的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 指的是由callfork调度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)
}
复制代码

mainyield cancel(task)被执行后,会取消subtask及其正被处理的call Effect,即subtask2会被取消。其取消行为的执行过程形成一个链式反应:从subtasksubtask2someApi。我们可以看到这个反应过程是往下传播的(对应的,例如错误抛出冒泡事件都是向上传播的)。

为了更方便地解释,假设存在两个角色,分别是callercalleecaller是异步操作的调用方,callee是被调用方,以上面的代码作为例子,则有:

  • subtasksubtask2callercallee的关系
  • subtask2someApicallercallee的关系

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,但这个库里面只有两个方法:cloneableGeneratorcreateMockTask,借助这两个方法,我们可以基本完成所有简单或复杂的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中,存在着条件判断(ifswitch)。在单元测试中,如果不借助外部方法,则在该条件判断语句块中,有多少个分支,我们就要初始化相应数量的迭代器进行测试,这会增加代码的复杂度。此时,我们可以借助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的模式:

  1. 逐步测试生成器函数:这个就和前面写的所有测试用例是一种思路,都是对saga产出的Effect逐个对比。

  2. 运行整个中间件且对其边界效应进行断言:这种模式是调用一个mock sagaMiddleware运行saga,然后通过mock sagaMiddleware派发对应的action来捕捉内部的反应。

针对上面的两种模式都有第三方库,第一种模式推荐使用redux-saga-testing,第二种模式推荐使用redux-saga-tester。大家可以自行阅读了解。

8. 适合你胃口的API(Recipes)

下面简洁介绍三个在redux-saga中内置的常用的APIthrottledebounce以及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次。

后记

这篇文章写了蛮久的,觉得有用的话,请点个赞喔,有什么疑问可以随时在下留言。

文章分类
前端
文章标签