redux-saga 入门教程:从 Hello Saga 到异步副作用与可测试性实战

redux-saga 入门教程:从 Hello Saga 到异步副作用与可测试性实战 前端【免费下载链接】redux-sagaAn alternative side effect model for Redux apps项目地址https://gitcode.com/gh_mirrors/re/redux-saga点击查看免费下载导读本文是 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 startwebpack-dev-server与npm testcross-env NODE_ENVtest 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提供额外按钮onIncrementAsyncconst 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.jsimport { 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 的rootSagaimport { 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.jsimport 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 表达式的结果第三次调用时已无更多 yielddone变为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, ...)。runCallEffecteffectRunnerMap.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组合器 Effectall与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接入 StorecreateSagaMiddleware()创建中间件 → 挂载到 Store →sagaMiddleware.run(rootSaga)启动入口源码见 middleware.js 与 runSaga.js编排异步用takeEvery worker Saga 监听INCREMENT_ASYNCworker 内部call阻塞等待、put派发结果并行启动用all组合多个根级 Saga可测试把副作用收敛为 Effect 描述对象测试中只做纯对象的深比较无需 mock 任何异步资源。理解了Generator yield 描述 → 中间件解释执行这条主线后续学习take/race/fork/cancel、Channel、取消与并发模型参见 docs/advanced 系列文档都会水到渠成。赞分享前端【免费下载链接】redux-sagaAn alternative side effect model for Redux apps项目地址https://gitcode.com/gh_mirrors/re/redux-saga点击查看免费下载相关推荐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 个变量一改就生效 想让模型按专业角色干活写一句你是一位资深程序员往往换来一股脑的通用回答——问题多半提示工程文档人工智能上一篇【亲测免费】 Git Credential Manager 项目推荐下一篇开源项目推荐Celeste创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考