新手教程

本教程的目标

本教程尝试用一种容易上手的方式(希望如此)来介绍 redux-saga。

我们将使用 Redux 仓库那个很小的计数器例子作为我们的入门教程。 这个应用程序比较简单,但是非常适合用来演示说明 redux-saga 的基本概念,不至于迷失在过多的细节里。

初始步骤

在我们开始前,clone 这个 教程仓库

教程的最终代码放在 sagas 分支上

然后在命令行输入:

$ cd redux-saga-beginner-tutorial
$ npm install

接着启动应用:

$ npm start

我们先从最简单的用例开始:2 个按钮 增加(Increment)减少(Decrement) 计数。之后我们将介绍异步调用。

不出意外的话,你应该能看到 2 个按钮 IncrementDecrement,以及按钮下方 Counter : 0 的文字。

如果你在运行这个应用程序的时候遇到问题,可随时在这个教程仓库上创建 issue。

Hello,Sagas!

接下来将创建我们的第一个 Saga。按照传统,我们将编写一个 Sagas 版本的 'Hello, world'。

创建一个 sagas.js 的文件,然后添加以下代码片段:

export function* helloSaga() {
  console.log('Hello Sagas!');
}

所以并没有什么吓人的东西,只是一个很普通的功能(好吧,除了 *)。这段代码的唯一的作用就是打印一句问候的消息到控制台。

为了运行我们的 Saga,我们需要:

  • 创建一个 Saga middleware 和要运行的 Sagas(目前我们只有一个 helloSaga
  • 将这个 Saga middleware 连接至 Redux store.

我们修改一下 main.js

// ...
import { createStore, applyMiddleware } from 'redux'
import createSagaMiddleware from 'redux-saga'

//...
import { helloSaga } from './sagas'

const store = createStore(
  reducer,
  applyMiddleware(createSagaMiddleware(helloSaga))
)

// rest unchanged

首先我们引入 ./sagas 模块中的 Saga。然后使用 redux-saga 模块的 createSagaMiddleware 工厂函数来创建一个 Saga middleware。

运行 helloSaga 之前,我们必须使用 applyMiddleware 将 middleware 连接至 Store。然后使用 sagaMiddleware.run(helloSaga) 运行 Saga。

到目前为止,我们的 Saga 并没做什么特别的事情。它只是打印了一条消息,然后退出。

发起异步调用

现在我们来添加一些更接近原始计数器例子的东西。为了演示异步调用,我们将添加另外一个按钮,用于在点击 1 秒后增加计数。

首先,我们需要在 UI 组件上添加一个额外的按钮和一个回调 onIncrementAsync

const Counter = ({ value, onIncrement, onDecrement, onIncrementAsync }) =>
  <div>
    <button onClick={onIncrementAsync}>
      Increment after 1 second
    </button>
    {' '}
    <button onClick={onIncrement}>
      Increment
    </button>
    {' '}
    <button onClick={onDecrement}>
      Decrement
    </button>
    <hr />
    <div>
      Clicked: {value} times
    </div>
  </div>

接下来我们需要将组件的 onIncrementAsync 与 Store action 连接起来。

修改 main.js 模块:

function render() {
  ReactDOM.render(
    <Counter
      value={store.getState()}
      onIncrement={() => action('INCREMENT')}
      onDecrement={() => action('DECREMENT')}
      onIncrementAsync={() => action('INCREMENT_ASYNC')} />,
    document.getElementById('root')
  )
}

注意,与 redux-thunk 不同,上面组件 dispatch 的是一个 plain Object 的 action。

现在我们将介绍另一种执行异步调用的 Saga。我们的用例如下:

我们需要在每个 INCREMENT_ASYNC action 启动一个做以下事情的任务:

  • 等待 1 秒,然后增加计数

添加以下代码到 sagas.js 模块:

import { delay } from 'redux-saga'
import { put, takeEvery } from 'redux-saga/effects'

// ...

// Our worker Saga: 将执行异步的 increment 任务
export function* incrementAsync() {
  yield delay(1000)
  yield put({ type: 'INCREMENT' })
}

// Our watcher Saga: 在每个 INCREMENT_ASYNC action spawn 一个新的 incrementAsync 任务
export function* watchIncrementAsync() {
  yield takeEvery('INCREMENT_ASYNC', incrementAsync)
}

好吧,该解释一下了。

我们引入了一个工具函数 delay,这个函数返回一个延迟 1 秒再 resolve 的 Promise 我们将使用这个函数去 block(阻塞) Generator。

Sagas 被实现为 Generator functions,它会 yield 对象到 redux-saga middleware。 被 yield 的对象都是一类指令,指令可被 middleware 解释执行。当 middleware 取得一个 yield 后的 Promise,middleware 会暂停 Saga,直到 Promise 完成。 在上面的例子中,incrementAsync 这个 Saga 会暂停直到 delay 返回的 Promise 被 resolve,这个 Promise 将在 1 秒后 resolve。

一旦 Promise 被 resolve,middleware 会恢复 Saga 接着执行,直到遇到下一个 yield。 在这个例子中,下一个语句是另一个被 yield 的对象:调用 put({type: 'INCREMENT'}) 的结果,意思是告诉 middleware 发起一个 INCREMENT 的 action。

put 就是我们称作 Effect 的一个例子。Effects 是一些简单 Javascript 对象,包含了要被 middleware 执行的指令。 当 middleware 拿到一个被 Saga yield 的 Effect,它会暂停 Saga,直到 Effect 执行完成,然后 Saga 会再次被恢复。

总结一下,incrementAsync Saga 通过 delay(1000) 延迟了 1 秒钟,然后 dispatch 一个叫 INCREMENT 的 action。

接下来,我们创建了另一个 Saga watchIncrementAsync。我们用了一个 redux-saga 提供的辅助函数 takeEvery,用于监听所有的 INCREMENT_ASYNC action,并在 action 被匹配时执行 incrementAsync 任务。

现在我们有了 2 个 Sagas,我们需要同时启动它们。为了做到这一点,我们将添加一个 rootSaga,负责启动其他的 Sagas。在同样的 sagas.js 文件中,重构文件如下:

import { delay } from 'redux-saga'
import { put, takeEvery, all } from 'redux-saga/effects'


function* incrementAsync() {
  yield delay(1000)
  yield put({ type: 'INCREMENT' })
}


function* watchIncrementAsync() {
  yield takeEvery('INCREMENT_ASYNC', incrementAsync)
}


// notice how we now only export the rootSaga
// single entry point to start all Sagas at once
export default function* rootSaga() {
  yield all([
    helloSaga(),
    watchIncrementAsync()
  ])
}

这个 Saga yield 了一个数组,值是调用 helloSagawatchIncrementAsync 两个 Saga 的结果。意思是说这两个 Generators 将会同时启动。 现在我们只有在 main.js 的 root Saga 中调用 sagaMiddleware.run

// ...
import rootSaga from './sagas'

const sagaMiddleware = createSagaMiddleware()
const store = ...
sagaMiddleware.run(rootSaga)

// ...

让我们的代码可测试

我们希望测试 incrementAsync Saga,以确保它执行期望的任务。

创建另一个文件 saga.spec.js

import test from 'tape';

import { incrementAsync } from './sagas'

test('incrementAsync Saga test', (assert) => {
  const gen = incrementAsync()

  // now what ?
});

incrementAsync 是一个 Generator 函数。执行的时候返回一个 iterator object,这个 iterator 的 next 方法返回一个如下格式的对象:

gen.next() // => { done: boolean, value: any }

value 字段包含被 yield 后的表达式,也就是 yield 后面那个表达式的结果。done 字段指示 generator 是结束了,还是有更多的 yield 表达式。

incrementAsync 的例子中,generator 连续 yield 了两个值:

  1. yield delay(1000)
  2. yield put({type: 'INCREMENT'})

所以,如果我们连续 3 次调用 generator 的 next 方法,我们会得到以下结果:

gen.next() // => { done: false, value: <result of calling delay(1000)> }
gen.next() // => { done: false, value: <result of calling put({type: 'INCREMENT'})> }
gen.next() // => { done: true, value: undefined }

前两次调用返回了 yield 表达式的结果。第三次调用由于没有更多的 yield 了,所以 done 字段被设置为 true。 并且由于 incrementAsync Generator 未返回任何东西(没有 return 语句),所以 value 字段被设置为 undefined

所以现在,为了测试 incrementAsync 里面的逻辑,我们需要对返回的 Generator 进行简单地迭代并检查 Generator yield 后的值。

import test from 'tape';

import { incrementAsync } from './sagas'

test('incrementAsync Saga test', (assert) => {
  const gen = incrementAsync()

  assert.deepEqual(
    gen.next(),
    { done: false, value: ??? },
    'incrementAsync should return a Promise that will resolve after 1 second'
  )
});

上面的问题是我们如何测试 delay 的返回值?我们不能在 Promise 之间做简单的相等测试。如果 delay 返回的是一个 普通(normal) 的值, 事情将会变得很简单。

好吧,redux-saga 提供了一种方式,让上面的语句变得可能。与在 incrementAsync 中直接(directly)调用 delay(1000) 不同,我们叫它 indirectly

import { delay } from 'redux-saga'
import { put, takeEvery, all, call } from 'redux-saga/effects'

// ...

export function* incrementAsync() {
  // use the call Effect
  yield call(delay, 1000)
  yield put({ type: 'INCREMENT' })
}

我们现在做的是 yield call(delay, 1000) 而不是 yield delay(1000),所以有何不同?

yield delay(1000) 的情况下,yield 后的表达式 delay(1000) 在被传递给 next 的调用者之前就被执行了(当运行我们的代码时,调用者可能是 middleware。 也有可能是运行 Generator 函数并对返回的 Generator 进行迭代的测试代码)。所以调用者得到的是一个 Promise,像在以上的测试代码里一样。

而在 yield call(delay, 1000) 的情况下,yield 后的表达式 call(delay, 1000) 被传递给 next 的调用者。call 就像 put, 返回一个 Effect,告诉 middleware 使用给定的参数调用给定的函数。实际上,无论是 put 还是 call 都不执行任何 dispatch 或异步调用,它们只是简单地返回 plain Javascript 对象。

put({type: 'INCREMENT'}) // => { PUT: {type: 'INCREMENT'} }
call(delay, 1000)        // => { CALL: {fn: delay, args: [1000]}}

这里发生的事情是:middleware 检查每个被 yield 的 Effect 的类型,然后决定如何实现哪个 Effect。如果 Effect 类型是 PUT 那 middleware 会 dispatch 一个 action 到 Store。 如果 Effect 类型是 CALL 那么它会调用给定的函数。

这种把 Effect 创建和 Effect 执行之间分开的做法,使得我们能够以一种非常简单的方法去测试 Generator。

import test from 'tape';

import { put, call } from 'redux-saga/effects'
import { delay } from 'redux-saga'
import { incrementAsync } from './sagas'

test('incrementAsync Saga test', (assert) => {
  const gen = incrementAsync()

  assert.deepEqual(
    gen.next().value,
    call(delay, 1000),
    'incrementAsync Saga must call delay(1000)'
  )

  assert.deepEqual(
    gen.next().value,
    put({type: 'INCREMENT'}),
    'incrementAsync Saga must dispatch an INCREMENT action'
  )

  assert.deepEqual(
    gen.next(),
    { done: true, value: undefined },
    'incrementAsync Saga must be done'
  )

  assert.end()
});

由于 putcall 返回 plain Object,所以我们可以在测试代码中重复使用同样的函数。为了测试 incrementAsync 的逻辑, 我们可以简单地遍历 generator 并对它的值做 deepEqual 测试。

为了运行上面的测试代码,我们需要运行:

$ npm test

测试结果会显示在控制面板上。

results matching ""

    No results matching ""