初学者教程
本教程的目标
本教程试图以一种(希望)易于理解的方式介绍 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,helloSaga
和 watchIncrementAsync
的结果。这意味着这两个生成的 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 个值
yield delay(1000)
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
这应该在控制台上报告结果。