初级教程
本教程的目标
本教程尝试用一种易于接受的方式(希望如此)来介绍 redux-saga。
我们将使用 Redux 仓库那个很小的计数器例子作为我们的入门教程。
这个应用比较简单,但是非常适合用来演示说明 redux-saga 的基本概念,不至于迷失在过多的细节里。
初始步骤
在我们开始前,需要先 clone 这个仓库:
https://github.com/yelouafi/redux-saga-beginner-tutorial
此教程最终的代码位于 sagas 分支
然后在命令行输入:
cd redux-saga-beginner-tutorial
npm install
接着启动应用:
npm start
我们先从最简单的用例开始:2 个按钮 增加(Increment)
和 减少(Decrement)
计数。之后我们将介绍异步调用。
不出意外的话,你应该能看到 2 个按钮 Increment
和 Decrement
,以及按钮下方 Counter : 0
的文字。
如果你在运行这个应用的时候遇到问题,可随时在这个教程的仓库上创建 issue
https://github.com/yelouafi/redux-saga-beginner-tutorial/issues
你好,Sagas!
接下来将创建我们的第一个 Saga。按照传统,我们将编写一个 Sagas 版本的 ‘Hello, world’。
创建一个 sagas.js
的文件,然后添加以下代码片段:
export function* helloSaga() {
console.log('Hello Sagas!');
}
所以并没有什么吓人的东西,只是一个很普通的功能(好吧,除了 *
)。这段代码的作用是打印一句问候消息到控制台。
为了运行我们的 Saga,我们需要:
- 以 Sagas 列表创建一个 Saga middleware(目前我们只有一个
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。createSagaMiddleware
接受 Sagas 列表,这些 Sagas 将会通过创建的 middleware 被立即执行。
到目前为止,我们的 Saga 并没做什么特别的事情。它只是打印了一条消息,然后退出。
发起异步调用
现在我们来添加一些更接近原始计数器例子的东西。为了演示异步调用,我们将添加另外一个按钮,用于点击后 1 秒增加计数。
首先,我们需要提供一个额外的回调 onIncrementAsync
。
const Counter = ({ value, onIncrement, onDecrement, onIncrementAsync }) =>
<div>
...
{' '}
<button onClick={onIncrementAsync}>Increment after 1 second</button>
<hr />
<div>Clicked: {value} times</div>
</div>
接下来我们需要使 onIncrementAsync
与 Store action 联系起来。
修改 main.js
模块:
function render() {
ReactDOM.render(
<Counter
...
onIncrementAsync={() => action('INCREMENT_ASYNC')}
/>,
document.getElementById('root')
)
}
注意,与 redux-thunk 不同,上面组件发起的是一个普通对象格式的 action。
现在我们将介绍另一种执行异步调用的 Saga。我们的用例如下:
在每个
INCREMENT_ASYNC
action 发起后,我们需要启动一个做以下事情的任务:
- 等待 1 秒,然后增加计数
添加以下代码到 sagas.js
模块:
import { takeEvery } from 'redux-saga'
import { put } from 'redux-saga/effects'
// 一个工具函数:返回一个 Promise,这个 Promise 将在 1 秒后 resolve
export const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
// Our worker Saga: 将异步执行 increment 任务
export function* incrementAsync() {
yield delay(1000)
yield put({ type: 'INCREMENT' })
}
// Our watcher Saga: 在每个 INCREMENT_ASYNC action 调用后,派生一个新的 incrementAsync 任务
export function* watchIncrementAsync() {
yield* takeEvery('INCREMENT_ASYNC', incrementAsync)
}
好吧,该解释一下了。首先我们创建一个工具函数 delay
,用于返回一个延迟 1 秒再 resolve 的 Promise。
我们将使用这个函数去 阻塞 Generator。
Sagas 被实现为 Generator 函数,它 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'})
的结果。
意思是 Saga 指示 middleware 发起一个 INCREMENT
的 action。
put
就是我们所说的一个调用 Effect 的例子。Effect 是一些简单 Javascript 对象,对象包含了要被 middleware 执行的指令。
当 middleware 拿到一个被 Saga yield 后的 Effect,它会暂停 Saga,直到 Effect 执行完成,然后 Saga 会再次被恢复。
总结一下,incrementAsync
Saga 通过 delay(1000)
延迟了 1 秒钟,然后发起了一个 INCREMENT
的 action。
接下来,我们创建了另一个 Saga watchIncrementAsync
。这个 Saga 将监听所有发起的 INCREMENT_ASYNC
action,并在每次 action 被匹配时派生一个新的 incrementAsync
任务。
为了实现这个目的,我们使用一个辅助函数 takeEvery
来执行以上的处理过程。
在我们开始这个应用之前,我们需要将 watchIncrementAsync
这个 Saga 连接至 Store:
//...
import { helloSaga, watchIncrementAsync } from './sagas'
const store = createStore(
reducer,
applyMiddleware(createSagaMiddleware(helloSaga, watchIncrementAsync))
)
//...
注意我们不需要连接 incrementAsync
这个 Saga,因为它会在每次 INCREMENT_ASYNC
action 发起时被 watchIncrementAsync
动态启动。
让我们的代码可测试
我们希望测试 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 函数,当我们在 middleware 之外运行它,每次调用 generator 的 next
,你将得到一个以下结构的对象:
gen.next() // => { done: boolean, value: any }
value
字段包含 yield 后的表达式,即 yield
后面那个表达式的结果。done
字段指示 generator 是结束了,还是有更多的 yield
表达式。
在 incrementAsync
的例子中,generator 连续 yield 了两个值:
yield delay(1000)
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 '../src/sagas'
test('incrementAsync Saga test', (assert) => {
const gen = incrementAsync()
assert.deepEqual(
gen.next().value,
{ done: false, value: ??? },
'incrementAsync should return a Promise that will resolve after 1 second'
)
});
问题是我们如何测试 delay
的返回值?我们不能在 Promise 之间做简单的相等测试。如果 delay
返回的是一个 普通(normal) 的值,
事情将会变得很简单。
好吧,redux-saga
提供了一种方式,让上面的语句变得可能。与在 incrementAsync
中直接调用 delay(1000)
不同,我们叫它 间接(indirectly
//...
import { put, call } from 'redux-saga/effects'
export const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
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
,
返回一个指示 middleware 以给定参数调用给定的函数的 Effect。
put({type: 'INCREMENT'}) // => { PUT: {type: 'INCREMENT'} }
call(delay, 1000) // => { CALL: {fn: delay, args: [1000]}}
这里发生的情况是:middleware 检查每个 yield Effect 的类型,然后决定如何实现那个 Effect。如果 Effect 类型是 PUT
那 middleware 会发起一个 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
的逻辑,
我们可以简单地遍历 generator 并对它的值做 deepEqual
测试。
为了运行上面的测试代码,我们需要输入:
npm test
测试结果会显示在控制面板上。