跳至主要内容

初学者教程

本教程的目标

本教程试图以一种(希望)易于理解的方式介绍 redux-saga。

对于我们的入门教程,我们将使用 Redux 仓库中简单的 Counter 演示。该应用程序非常基础,但非常适合说明 redux-saga 的基本概念,而不会陷入过多的细节。

初始设置

在开始之前,请克隆教程仓库.

本教程的最终代码位于 sagas 分支中。

然后在命令行中,运行

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

要启动应用程序,请运行

$ npm start

编译完成后,在浏览器中打开 http://localhost:9966

我们从最基本的使用案例开始:2 个按钮,用于增加减少计数器。稍后,我们将介绍异步调用。

如果一切顺利,您应该看到 2 个按钮增加减少,以及下面显示计数器:0的消息。

如果您在运行应用程序时遇到问题,请随时在 教程仓库 上创建一个问题。

你好,Sagas!

我们将创建我们的第一个 Saga。遵循传统,我们将编写 Saga 的“Hello, world”版本。

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

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

所以没什么可怕的,只是一个普通的函数(除了*)。它所做的只是将问候消息打印到控制台。

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

  • 创建一个 Saga 中间件,其中包含要运行的 Saga 列表(到目前为止,我们只有一个helloSaga
  • 将 Saga 中间件连接到 Redux 存储

我们将对main.js进行更改

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

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

const sagaMiddleware = createSagaMiddleware()
const store = createStore(
reducer,
applyMiddleware(sagaMiddleware)
)
sagaMiddleware.run(helloSaga)

const action = type => store.dispatch({type})

// rest unchanged

首先,我们从./sagas模块导入我们的 Saga。然后,我们使用redux-saga库导出的工厂函数createSagaMiddleware创建一个中间件。

在运行我们的helloSaga之前,我们必须使用applyMiddleware将我们的中间件连接到 Store。然后,我们可以使用sagaMiddleware.run(helloSaga)来启动我们的 Saga。

到目前为止,我们的 Saga 并没有做任何特别的事情。它只是记录一条消息,然后退出。

进行异步调用

现在让我们添加一些更接近原始计数器演示的内容。为了说明异步调用,我们将添加另一个按钮,在点击后 1 秒增加计数器。

首先,我们将为 UI 组件提供一个额外的按钮和一个回调onIncrementAsync

我们将对Counter.js进行更改

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 操作。

我们将修改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不同,我们的组件分发了普通对象操作。

现在我们将引入另一个Saga来执行异步调用。我们的用例如下

对于每个INCREMENT_ASYNC操作,我们希望启动一个执行以下操作的任务

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

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

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

const delay = (ms) => new Promise(res => setTimeout(res, ms))

// ...

// Our worker Saga: will perform the async increment task
export function* incrementAsync() {
yield delay(1000)
yield put({ type: 'INCREMENT' })
}

// Our watcher Saga: spawn a new incrementAsync task on each INCREMENT_ASYNC
export function* watchIncrementAsync() {
yield takeEvery('INCREMENT_ASYNC', incrementAsync)
}

现在解释一下。

我们创建了一个delay函数,它返回一个Promise,该函数将在指定毫秒数后解析。我们将使用此函数来阻塞生成器。

Saga 是用生成器函数实现的,它会将对象yield给 redux-saga 中间件。yield 的对象是中间件要解释的一种指令。当一个 Promise 被 yield 给中间件时,中间件会挂起 Saga,直到 Promise 完成。在上面的示例中,incrementAsync Saga 会被挂起,直到 delay 返回的 Promise 解析,这将在 1 秒后发生。

一旦 Promise 解析,中间件将恢复 Saga,执行代码直到下一个 yield。在这个例子中,下一条语句是另一个 yield 的对象:调用 put({type: 'INCREMENT'}) 的结果,它指示中间件分发一个 INCREMENT 操作。

put 是我们称之为Effect的一个例子。Effect 是包含要由中间件执行的指令的普通 JavaScript 对象。当中间件检索到 Saga yield 的 Effect 时,Saga 会暂停,直到 Effect 完成。

总之,incrementAsync Saga 通过调用 delay(1000) 休眠 1 秒,然后分发一个 INCREMENT 操作。

接下来,我们创建了另一个 Saga watchIncrementAsync。我们使用 takeEvery,这是一个由 redux-saga 提供的辅助函数,来监听分发的 INCREMENT_ASYNC 操作,并在每次操作时运行 incrementAsync

现在我们有两个 Saga,我们需要同时启动它们。为此,我们将添加一个 rootSaga,它负责启动我们的其他 Saga。在同一个文件 sagas.js 中,重构文件如下

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

export const delay = (ms) => new Promise(res => setTimeout(res, ms))

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

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

export 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 生成一个数组,包含了调用我们两个 Saga,helloSagawatchIncrementAsync 的结果。这意味着这两个生成的 Generator 将会并行启动。现在我们只需要在 main.js 中的根 Saga 上调用 sagaMiddleware.run 就可以了。

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

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

// ...

使我们的代码可测试

我们想要测试我们的 incrementAsync Saga,以确保它执行了预期的任务。

创建一个新的文件 sagas.spec.js

import test from 'tape'

import { incrementAsync } from './sagas'

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

// now what ?
})

incrementAsync 是一个生成器函数。当运行时,它返回一个迭代器对象,而迭代器的 next 方法返回一个具有以下形状的对象

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

value 字段包含生成的表达式,即 yield 之后表达式的结果。done 字段表示生成器是否已终止,或者是否还有更多“yield”表达式。

incrementAsync 的情况下,生成器连续生成 2 个值

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

所以,如果我们连续三次调用生成器的 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 生成器没有返回任何东西(没有 return 语句),所以 value 字段被设置为 undefined

所以现在,为了测试 incrementAsync 中的逻辑,我们必须遍历返回的生成器,并检查生成器生成的的值。

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 返回一个正常的值,测试会更容易。

好吧,redux-saga 提供了一种方法来使上述语句成为可能。我们不会直接在 incrementAsync 中调用 delay(1000),而是间接调用它,并将其导出,以便进行后续的深度比较。

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

export const delay = (ms) => new Promise(res => setTimeout(res, ms))

// ...

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

我们不再执行 yield delay(1000),而是执行 yield call(delay, 1000)。有什么区别呢?

在第一种情况下,yield 表达式 delay(1000) 在传递给 next 的调用者之前被评估(调用者可能是运行我们代码时的中间件。它也可能是我们的测试代码,它运行生成器函数并遍历返回的生成器)。所以调用者得到的是一个 Promise,就像上面的测试代码一样。

在第二种情况下,yield 表达式 `call(delay, 1000)` 是传递给 `next` 调用者的内容。`call` 与 `put` 一样,返回一个 Effect,指示中间件使用给定的参数调用给定的函数。实际上,`put` 和 `call` 本身都不会执行任何调度或异步调用,它们只返回普通的 JavaScript 对象。

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

发生的事情是,中间件会检查每个 yield 的 Effect 的类型,然后决定如何满足该 Effect。如果 Effect 类型是 `PUT`,则它会将一个 action 分派到 Store。如果 Effect 是 `CALL`,则它会调用给定的函数。

这种 Effect 创建和 Effect 执行分离的方式,使得以一种非常容易的方式测试我们的 Generator 成为可能。

import test from 'tape'

import { put, call } from 'redux-saga/effects'
import { incrementAsync, delay } 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()
})

由于 `put` 和 `call` 返回的是普通对象,因此我们可以在测试代码中重用相同的函数。为了测试 `incrementAsync` 的逻辑,我们遍历生成器并对其值进行 `deepEqual` 测试。

为了运行上面的测试,运行

$ npm test

这应该在控制台上报告结果。