- 前端
【免费下载链接】redux-saga
An alternative side effect model for Redux apps
导读
本文是 redux-saga 的入门实战教程,基于官方 Beginner Tutorial 展开,围绕一个最经典的 Counter 计数器应用,逐步演示如何在 Redux 应用中引入 Saga:从创建第一个 Saga、把 Saga 中间件挂载到 Store,到用 Generator 编排"等待 1 秒后再派发 INCREMENT 动作"的异步副作用,最后通过"Effect 创建与执行分离"的机制写出无需 mock 任何异步操作的单元测试。读完本文,你将掌握 redux-saga 的核心心智模型(Effect / 中间件解释执行 / 生成器驱动),并能独立为现有 Redux 应用接入第一个 Saga 及配套测试。文中所涉及的所有机制均可在本仓库 packages/core/src 的源码中找到对应实现,仓库中亦有与本教程同源的 counter 示例 可对照运行。
本教程的目标与初始环境搭建
本教程从 Redux 官方仓库的 Counter 演示应用出发。该应用虽然简单,却足以在不淹没于繁琐细节的前提下,讲清楚 redux-saga 的基础概念:Saga 是什么、中间件如何接入、Generator 如何驱动、Effect 如何被解释执行。
获取教程代码
开始之前,请先克隆教程配套仓库(官方教程的最终代码位于其sagas分支),随后在命令行中执行:
$ cd redux-saga-beginner-tutorial $ npm install启动应用
$ npm start编译完成后,在浏览器中打开http://localhost:9966。初始状态下界面提供两个按钮Increment和Decrement,用于增减计数器,下方消息区显示Counter: 0。
若运行应用时遇到问题,可在教程仓库的 issue 区反馈。
如果希望在本仓库内直接体验同类应用,可以查看 examples/counter 目录:其 package.json 同样提供npm start(webpack-dev-server)与npm test(cross-env NODE_ENV=test babel-node test/sagas.js | tap-spec)脚本,src/main.js 展示了用createStore(reducer, applyMiddleware(sagaMiddleware))+sagaMiddleware.run(rootSaga)接入 Saga 的经典写法。
Hello Sagas!创建并运行第一个 Saga
遵循传统,我们先写一个 Sagas 版的 "Hello, world"。
创建sagas.js文件,加入以下片段:
export function* helloSaga() { console.log('Hello Sagas!') }除了那个*(星号)之外,这看起来就是一个普通函数,其作用只是在控制台打印一句问候。关键在于:function*声明的是一个 Generator 函数,调用它会返回一个迭代器对象,redux-saga 中间件正是通过驱动这个迭代器来运行 Saga 逻辑的。
运行 Saga 的两步准备
要让helloSaga跑起来,需要完成两件事:
- 创建一个 Saga 中间件,并传入要运行的 Saga 列表(目前只有
helloSaga); - 把 Saga 中间件连接到 Redux Store 上。
修改main.js:
// ... import { configureStore } from '@reduxjs/toolkit' import createSagaMiddleware from 'redux-saga' // ... import { helloSaga } from './sagas' const sagaMiddleware = createSagaMiddleware() const store = configureStore({ reducer, middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(sagaMiddleware), }) sagaMiddleware.run(helloSaga) const action = type => store.dispatch({ type }) // rest unchanged流程拆解:
- 从
./sagas模块导入我们的 Saga; - 通过
redux-saga库导出的工厂函数createSagaMiddleware()创建中间件; - 在运行
helloSaga之前,必须先把中间件加入 Store(上述代码通过configureStore的middleware选项与默认中间件拼接); - 最后调用
sagaMiddleware.run(helloSaga)启动 Saga。
从源码看,createSagaMiddleware即 middleware.js 中的sagaMiddlewareFactory,它接收{ context, channel, sagaMonitor, ...options }并返回一个 Redux 中间件;真正启动 Saga 的sagaMiddleware.run会在内部调用 runSaga.js 中的runSaga——调用saga(...args)取得迭代器后,经proc驱动执行。注意 middleware.js 中的开发期校验:若尚未将中间件挂载到 Store 就调用run,会抛出 "Before running a Saga, you must mount the Saga middleware on the Store using applyMiddleware" 的错误。
到目前为止,我们的 Saga 还没有任何特殊之处:它打印一条消息后即告结束。
让 Saga 做异步调用:1 秒后自增
现在给 Counter 演示加上一点异步逻辑:新增一个按钮,点击后1 秒后计数器加一。
第一步:给 UI 组件加按钮和回调
修改Counter.js,提供额外按钮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>第二步:把回调连接到 Store action
修改main.js,将onIncrementAsync映射为派发INCREMENT_ASYNC动作:
import { createRoot } from 'react-dom/client' const root = createRoot(document.getElementById('root')) function render() { root.render( <Counter value={store.getState()} onIncrement={() => action('INCREMENT')} onDecrement={() => action('DECREMENT')} onIncrementAsync={() => action('INCREMENT_ASYNC')} /> ) }注意:与 redux-thunk 不同,这里的组件派发的是一个纯对象 action,没有任何 thunk 函数包装。异步逻辑完全由 Saga 在中间件层负责。
第三步:编写处理异步的 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) }这里出现了 Saga 世界中的经典分工:
- worker Saga(工人)
incrementAsync:真正执行异步任务的逻辑; - watcher Saga(观察者)
watchIncrementAsync:监听动作、派生任务。
关键机制:yield 与中间件的解释执行
逐句解释上面的代码:
delay是一个返回 Promise 的函数,它会在指定毫秒数后 resolve。我们用它来阻塞Generator。- Saga 是以Generator 函数实现的,它们向 redux-saga 中间件yield各种对象。被 yield 出去的对象是一种"待中间件解读的指令"。当 yield 出一个 Promise 时,中间件会挂起该 Saga,直到 Promise 完成。上面例子中,
incrementAsync会一直被挂起,直到delay返回的 Promise 在 1 秒后 resolve。 - Promise 一旦 resolve,中间件就恢复该 Saga,继续执行到下一个 yield。本例中的下一条语句是
put({type: 'INCREMENT'})的调用结果——它指示中间件去派发一个INCREMENT动作。 put就是我们所说的Effect的一种。Effect 是包含"待中间件执行指令"的纯 JavaScript 对象。当中间件拿到 Saga yield 出来的 Effect 时,Saga 会暂停,直到该 Effect 被满足(fulfilled)。
从源码层面印证:在 effectTypes.js 中列出了全部 Effect 类型(TAKE、PUT、ALL、RACE、CALL、FORK等),而 proc.js 中的runEffect会根据 yield 出的值分类处理:是 Promise 就交给resolvePromise;是迭代器就递归启动新的proc;是带IO标记的 Effect 对象则查找 effectRunnerMap.js 中对应的 runner(如PUT→runPutEffect,其内部调用env.dispatch(action)派发动作)。proc.js 中的next函数就是那个"生成器驱动器":它以递归的 continuation 方式推进迭代器,直到 Generator 终止或抛出异常。
所以整体总结:incrementAsync先通过delay(1000)睡 1 秒,随后派发INCREMENT动作。
takeEvery:监听动作并派生任务
接下来我们创建了第二个 SagawatchIncrementAsync,使用takeEvery——redux-saga 提供的辅助函数——来监听被派发的INCREMENT_ASYNC动作,并在每次收到该动作时运行incrementAsync。
takeEvery的底层实现值得一看:sagaHelpers/takeEvery.js 用有限状态机(fsmIterator)在"take 动作"与"fork worker"两个状态间循环;而其对外接口 io-helpers.js 实际上返回一个fork(takeEveryHelper, patternOrChannel, worker, ...args)的 FORK Effect。换句话说,takeEvery('INCREMENT_ASYNC', incrementAsync)本身也只是一个被中间件解释的 Effect 描述,而非立即执行的副作用。
第四步:用 rootSaga 同时启动多个 Saga
现在我们有 2 个 Saga,需要同时启动它们。为此在sagas.js中重构,新增一个负责启动其他 Saga 的rootSaga:
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 yield 出一个数组——即调用helloSaga和watchIncrementAsync两个函数得到的结果。这意味着两个 Generator 将并行启动。接下来只需要在main.js中对根 Saga 调用sagaMiddleware.run:
// ... import { configureStore } from '@reduxjs/toolkit' import createSagaMiddleware from 'redux-saga' import rootSaga from './sagas' const sagaMiddleware = createSagaMiddleware() const store = configureStore({ reducer, middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(sagaMiddleware), }) sagaMiddleware.run(rootSaga) // ...all对应的底层执行在 effectRunnerMap.js 的runAllEffect中:它会遍历 effects 对象的所有 key,为每个子 effect 创建独立的完成回调,并让它们并行消化(digest),全部完成后再汇总到父回调。
让代码可测试:Effect 与执行分离的威力
现在我们要测试incrementAsync,确认它执行了预期的任务。
朴素测试的困境
创建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 出的表达式结果,即yield后面那个表达式的求值结果;done字段表示生成器是否已终止、是否还有更多yield表达式。
就incrementAsync而言,生成器会连续 yield 出 2 个值:
yield delay(1000)yield put({type: 'INCREMENT'})
所以连续调用 3 次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内部的逻辑,我们需要遍历返回的 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返回的是普通值,测试就会容易得多。
用 call Effect 间接调用
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——就像上面的测试代码那样,难以断言。 - 第二种写法:传给
next调用者的是call(delay, 1000)这个表达式的结果。call和put一样,返回一个指示"中间件用给定参数调用给定函数"的Effect。事实上,put和call本身都不会执行任何派发或异步调用,它们只返回纯 JavaScript 对象:
put({type: 'INCREMENT'}) // => { PUT: {type: 'INCREMENT'} } call(delay, 1000) // => { CALL: {fn: delay, args: [1000]}}中间件拿到每个 yield 出的 Effect 后,检查其类型再决定如何满足它:类型是PUT就向 Store 派发动作;类型是CALL就调用给定函数。这个"Effect 创建与 Effect 执行分离"的设计,正是 redux-saga 可测试性的根基。
源码印证:io.js 中的makeEffect(type, payload)统一构造带IO标记、type和payload字段的 Effect 对象;io.js 的put返回{ type: 'PUT', payload: { channel, action } },io.js 的call返回{ type: 'CALL', payload: { context, fn, args } }。开发模式下两者还会执行参数校验(例如call会提示你:若delay本身是来自redux-saga/effects的 effect,应直接yield delay(...)而非yield call(delay, ...))。runCallEffect(effectRunnerMap.js)才是真正执行fn.apply(context, args)的地方,并把返回的 Promise 交给resolvePromise处理。
编写真正的测试
由于put和call返回纯对象,我们可以在测试代码里复用同样的函数来构造期望值,然后遍历生成器,对其值做deepEqual断言:
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() })运行测试:
$ npm test结果会输出到控制台。
仓库中的同款测试
上述测试模式并非孤例:本仓库 examples/counter/test/sagas.js 中就有几乎完全一致的实现——它从redux-saga/effects导入put, delay,对 examples/counter/src/sagas/index.js 的incrementAsync依次断言delay(1000)、put({ type: 'INCREMENT' })与{ done: true, value: undefined }。可见"迭代生成器 + 对 Effect 描述做深比较"是 redux-saga 官方推崇的测试范式。由于测试完全不依赖真实计时器或网络,它运行得飞快且完全确定。
进阶要点速览
本文只是起点,教程中埋下的几个核心概念在后续深入学习中会反复出现:
- Effect 家族:除
put、call、all、takeEvery外,还有take、race、fork、cancel、select、apply、cps等,完整列表见 effectTypes.js,各自对应的执行逻辑见 effectRunnerMap.js; - 组合器 Effect:
all与race是"组合器"(io.js 会给它们打上combinator: true标记),用于并行与竞争编排; - 测试工具包:仓库还提供
@redux-saga/testing-utils(如cloneableGenerator、createMockTask,见 packages/testing-utils/src/index.js),用于更复杂的生成器克隆与任务模拟场景; - 运行环境差异:本仓库的包实现区分开发/生产构建(见 packages/core/src/resolved-conditions),大量参数校验仅在开发模式下执行,因此生产环境可获得更小的体积与更少的开销。
小结
通过 Counter 这个最小可运行的应用,我们完整走通了 redux-saga 的接入闭环:
- 创建 Saga:用
function*编写生成器,yield 出 Effect 或 Promise; - 接入 Store:
createSagaMiddleware()创建中间件 → 挂载到 Store →sagaMiddleware.run(rootSaga)启动,入口源码见 middleware.js 与 runSaga.js; - 编排异步:用
takeEvery+ worker Saga 监听INCREMENT_ASYNC,worker 内部call阻塞等待、put派发结果; - 并行启动:用
all组合多个根级 Saga; - 可测试:把副作用收敛为 Effect 描述对象,测试中只做纯对象的深比较,无需 mock 任何异步资源。
理解了"Generator yield 描述 → 中间件解释执行"这条主线,后续学习take/race/fork/cancel、Channel、取消与并发模型(参见 docs/advanced 系列文档)都会水到渠成。
- 前端
【免费下载链接】redux-saga
An alternative side effect model for Redux apps
相关推荐
redux-saga 中文实战指南:用 Generator 与 Effect 构建可测试的 Redux 副作用层
redux saga 中文实战指南:用 Generator 与 Effect 构建可测试的 Redux 副作用层 redux saga 是 Redux 应用的另
前端Redux-Saga实战指南:从入门到精通
Redux Saga实战指南:从入门到精通 本文全面介绍了Redux Saga的环境搭建、基础配置和实战应用。从计数器简单示例到购物车复杂场景的实现,再到真实世
前端awesome-prompts 提示词库上手指南:3 个变量一改就生效
awesome prompts 提示词库上手指南:3 个变量一改就生效 想让模型按专业角色干活,写一句"你是一位资深程序员"往往换来一股脑的通用回答——问题多半
提示工程文档人工智能
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考