- 前端
【免费下载链接】easy-peasy
Vegetarian friendly state for React
导读
thunk是 easy-peasy 状态管理库中专门用于封装副作用(如 API 调用、异步流程)和复杂工作流(如基于 if/else 的分支逻辑)的核心 API。它不能直接修改 state,但可以派发action来更新状态,并支持同步与异步两种执行模式。读完本文,你将完整掌握thunk的声明语法、handler的全部参数(actions、payload、helpers)、异步 Promise 跟踪机制、三阶段调试(start/success/fail)机制、依赖注入(injections)以及对应的测试策略,并深入源码理解其底层实现原理。
什么是 thunk
在 easy-peasy 中,thunk用于在 model 上声明一个 thunk action。与action的核心区别在于:
action直接对 state 进行(immer 支持的)可变更新,是唯一合法的状态修改入口;thunk不能修改 state,但它可以派发action来间接完成状态更新,从而把"业务逻辑/副作用"与"状态更新"分离。
官方文档对 thunk 的定位是:typically used to encapsulate side effects or complex workflow (e.g.if/elsebased logic) around action dispatching,即围绕 action 派发封装副作用或复杂流程。它可以是异步的,也可以是同步的。
import { thunk } from 'easy-peasy'; const model = { loginSucceeded: action((state, user) => { state.user = user; }), login: thunk(async (actions, payload) => { const user = await loginService(payload); actions.loginSucceeded(user); }) };从源码看,thunk本质上是一个带标记的普通对象,helpers.js 中的定义非常直观:
export const thunk = (fn) => ({ [thunkSymbol]: true, fn, });其中thunkSymbol = '$_t'(见 constants.js),Easy Peasy 在解析 model 时正是通过这个符号识别出 thunk 并进行特殊处理。
返回值会传递给调用方
thunk的一个有趣特性是:任何从 thunk 返回的值都会被提供给调用方(即派发它的位置)。如果你在 thunk 中使用async/await或返回了Promise,调用方就可以通过返回的 Promise 链式操作,得知 thunk 何时执行完毕。
thunk(async (actions, payload) => { const user = await loginService(payload); actions.loginSucceeded(user); })异步完成后再触发监听器
当你使用async/await或返回Promise时,Easy Peasy 会等待异步工作完成,之后才会触发针对该 thunk 的监听器(listener)。这意味着thunkOn等监听 API 拿到的target.result一定是异步流程完成后的结果。
handler 的参数详解
thunk接收一个handler函数(必填)。该函数会收到以下三个参数:
1.actions(Object)
thunk 局部范围内的 actions,用于派发 action 来更新状态。它是基于当前 thunk 所挂载位置的局部 action 集合,例如上面的actions.loginSucceeded(user)。
2.payload(any)
派发 thunk 时传入的载荷数据。如果调用方未传 payload,则为undefined。
3.helpers(Object)
针对更高级 thunk 实现而提供的辅助对象,包含以下属性:
| 属性 | 类型 | 说明 |
|---|---|---|
dispatch | Function | 原始的 Redux dispatch 函数,可派发"标准" Redux action |
fail | Function | Fail API,用于显式标记 thunk 失败(见下文"调试 Thunks") |
getState | Function | 返回 thunk 局部范围内的 state |
getStoreActions | Function | 返回整个 store 上全部 actions 的集合 |
getStoreState | Function | 返回整个 store 的全部 state |
injections | Any | 通过createStore配置注入的依赖,默认undefined |
meta | Object | thunk 的元信息,包含parent(父路径数组)与path(完整路径数组) |
注意:虽然可以通过
getState/getStoreState访问 store 的状态,但不应该在 thunk 中修改这些状态,这会被视为反模式(anti-pattern)。所有状态更新必须收敛在action内部完成。这两个 API 的存在纯粹是为了方便——让你可以基于现有状态做逻辑判断。
关于getStoreActions,官方文档明确给出了建议:We don't recommend dispatching actions like this, and invite you to consider creating anactionOnorthunkOnlistener instead.即不推荐这种跨模块派发方式,建议改用 actionOn 或 thunkOn 监听器来解耦。
meta中的parent与path可从 thunks.js 的源码得到印证——handler 在创建时会绑定meta对象:
const helpers = { dispatch: _r.dispatch, fail, getState: () => get(def.meta.parent, _r.getState()), getStoreActions: () => _aC, getStoreState: _r.getState, injections, meta: { key: def.meta.actionName, parent: def.meta.parent, path: def.meta.path, }, }; return def.fn(get(def.meta.parent, _aC), payload, helpers);可以看到getState实际执行的是get(def.meta.parent, _r.getState())——即从根 state 中取出 thunk 父级路径对应的那一块状态,这就是"局部 state"的语义来源;而getStoreState则是直接返回根 state。
异步执行
要让 thunk 异步执行,只需使用async/await或返回Promise。这使你能够管理对 API 的异步调用:
const todosModel = { todos: [], addTodo: action((state, payload) => { state.todos.push(payload); }), // 异步 thunk 使用 async/await saveTodo: thunk(async (actions, payload) => { const saved = await todoService.save(payload); actions.addTodo(saved); }) };异步 thunk 的Promise会返回给派发它的代码,这对于在 thunk 完成后执行后续逻辑尤为重要。例如在 React 组件中,可以这样链式处理:
function MyComponent() { const saveTodo = useStoreActions(actions => actions.todos.saveTodo); const onSaveClick = useCallback( // 链式接住派发 thunk 返回的 Promise () => saveTodo('Learn easy peasy').then(() => { // 成功后重定向 history.push('/done'); }), [saveTodo] ); }注意:如果你的 thunk 内部使用
Promise(而非async/await),务必将 Promise 从 thunk 中返回,这样 Easy Peasy 才能内部跟踪它。例如thunk((actions, payload) => fetch('/api').then(res => res.json()))必须return这个 Promise。
源码层面,thunks.js 的createThunkActionsCreator会检测 handler 的返回值:如果isPromise(result),则返回result.then(...)链,在异步完成后统一派发 success/fail 事件;否则同步处理。这也解释了为什么异步 thunk 的调用方能够await它。
仓库中 examples/v7-concurrent/src/store/model.ts 提供了真实示例,比如用await new Promise(resolve => setTimeout(resolve, 600))模拟网络延迟后再派发 action:
loadItems: thunk(async (actions, count) => { await new Promise((resolve) => setTimeout(resolve, 600)); const items = Array.from({ length: count }, (_, i) => makeItem(i)); actions.setItems(items); }),同步执行
thunk 也可以是同步的,非常适合封装围绕 action 派发的分支逻辑:
thunk((actions, payload) => { if (payload.valid) { actions.doValid(); } else { actions.doInvalid(); } })同步 thunk 中if/else这样的条件逻辑,正是文档所说 "complex workflow around action dispatching" 的典型场景。测试 tests/thunks.test.js 验证了同步 thunk 的完整行为:执行时派发start事件、调用内部 action、返回结果,并同步派发success事件,且返回值会原样透传给调用方(expect(actualResult).toBe('did something'))。
调试 Thunks:start / success / fail 三阶段
thunk 本身是异步执行且不直接影响 state 的,但 Easy Peasy 会派发代表 thunk 各个状态的 action:started、completed、failed。这些 action 不会影响你的 state,但非常有用:
- 提升调试体验:让你更清晰地看到异步流程与常规 action 之间的关系;
- 支持监听器:允许将监听器(如
actionOn/thunkOn)挂到 thunk 的特定阶段上。
使用 Redux DevTools 扩展,你可以看到每个 thunk 在流转各阶段时的完整事件,以及派发时携带的 payload。测试 tests/thunks.test.js 中记录的 action 序列展示了这一机制:
{ type: '@thunk.foo.doSomething(start)', payload }, { type: '@action.foo.increment', payload: undefined }, { type: '@thunk.foo.doSomething(success)', payload, result: 'did something' },注意:
start和success事件会自动派发。要触发fail事件,需要在 thunk 内调用helpers.fail(error)。除了 thunk 的 payload 外,fail 事件还会带上你传给fail()的参数,可以是任意有助于调试的数据。
底层实现在 thunks.js:createThunkActionsCreator根据 model 解析阶段生成的元数据(extract-data-from-model.js)构造了三种事件类型:
def.meta = { startType: `${type}(start)`, successType: `${type}(success)`, failType: `${type}(fail)`, };其中type形如@thunk.todos.saveTodo。派发 thunk 时,先同步派发start;待 Promise resolve 后,若内部调用了fail(err)则派发fail(携带error字段),否则派发success(携带result字段)。此外,actionCreator上还绑定了type/successType/failType/startType四个静态属性,供监听器精确定位 thunk 的某个阶段——listeners 文档展示了如何利用它们监听 thunk 的特定阶段(例如只监听 success)。
在 thunk 内访问状态
你可以通过getState访问局部状态,或通过getStoreState访问整个 store 的状态:
import { createStore, thunk } from 'easy-peasy'; const store = createStore({ counter: { count: 1, debug: thunk((actions, payload, { getState, getStoreState }) => { console.log(getState()); // { count: 1 } console.log(getStoreState()); // 整个 store 的状态 }), } });请记住:如果你在 thunk 中执行了 action,那么需要在 action 之后再调用
getState或getStoreState,才能看到更新后的状态。
这一点在测试 tests/thunks.test.js 中有直接验证:loginthunk 先actions.loginSucceeded(user)更新状态,随后getState()返回的已经是{ user: { name: 'bob' } },证明 getState 拿到的是最新状态。
派发模型中其他部分的 action
在某些场景下,你可能需要在 thunk 中派发模型其他部分的 action,此时可使用getStoreActions:
import { action, createStore, thunk } from 'easy-peasy'; const store = createStore({ audit: { logs: [], add: action((state, payload) => { state.logs.push(payload); }) }, todos: { saveTodo: thunk((actions, payload, { getStoreActions }) => { getStoreActions().audit.add('Added a todo'); }) } });官方文档明确不推荐这种做法,而是建议你定义 actionOn 或 thunkOn 监听器,这能促进更好的关注点分离(separation of concerns)。
依赖注入(injections)
通过createStore的injections配置,可以将 API 服务等依赖注入到 thunk 中,从而让 thunk 与具体实现解耦(也便于测试时替换为 mock):
import { createStore, thunk } from 'easy-peasy'; const model = { saveTodo: thunk(async (actions, payload, { injections }) => { const { todosService } = injections; await todosService.save(payload); }) }; const store = createStore(model, { // injections 在 store 上配置 injections: { todosService, } });关于injections的完整说明见 StoreConfig:injections(Object,可选,默认undefined)——任何你想暴露给 thunk 和 effects 的依赖,它们通过helpers参数暴露给这两类 API。在 create-store.js 中,injections作为createStore的配置项读取,并在 extract-data-from-model.js 中被传入createThunkHandler(def, _r, injections, _aC),最终由 thunks 的 handler 包装进 helpers 供使用者读取。
测试 thunk:mockActions 与 getMockedActions
由于 thunk 可能触发网络请求等副作用、并派发其他 action/thunk,它的测试比 action 更复杂。官方 Testing 教程给出了两种策略:
- 策略 1:mock actions——设置
createStore的mockActions: true,此时派发的所有 action 不会真正执行状态更新,而是被记录下来,可通过 store 的getMockedActions()API 读取; - 策略 2:自然执行——不 mock action,让 thunk 真实执行,然后断言副作用调用与最终 state 变化。
官方推荐把副作用封装在模块中,通过injections注入 store,这样测试时就能注入 mock 版本的服务。
以策略 1 为例(完整用例见 tests/testing/thunks.test.js):
import { action, createStore, thunk } from 'easy-peasy'; const todosModel = { items: {}, fetchedTodo: action((state, payload) => { state.items[payload.id] = payload; }), fetchById: thunk(async (actions, payload, { injections }) => { const { todosService } = injections; const todo = await todosService.fetchById(payload); actions.fetchedTodo(todo); }), }; // 测试 const todo = { id: 1, text: 'Test my store' }; const mockTodosService = { fetchById: () => Promise.resolve(todo), }; const store = createStore(todosModel, { injections: { todosService: mockTodosService }, mockActions: true, }); await store.getActions().fetchById(todo.id); expect(store.getMockedActions()).toEqual([ { type: '@thunk.fetchById(start)', payload: todo.id }, { type: '@action.fetchedTodo', payload: todo }, { type: '@thunk.fetchById(success)', payload: todo.id }, ]);如果 thunk 调用了fail(err),则记录中会出现@thunk.throwing(fail)事件(携带error字段);如果通过helpers.dispatch派发原生 Redux action(如{ type: 'CUSTOM_ACTION' }),该 action 也会原样出现在记录中(见 tests/testing/thunks.test.js)。
mockActions的底层实现见 create-store.js:当mockActions为true时,Easy Peasy 会向中间件链追加一个mockActionsMiddleware,它把每个派发的 action 记录进mockedActions数组并返回undefined(阻断真实的状态更新),store 上则暴露了getMockedActions: () => [...mockedActions](create-store.js)。
TypeScript 支持
在 TypeScript 中,thunk的类型定义位于 index.d.ts:
export interface Thunk< Model extends object, Payload = undefined, Injections = any, StoreModel extends object = {}, Result = any, > { type: 'thunk'; payload: Payload; result: Result; }五个泛型参数含义如下:
Model——thunk 所绑定到的 model 类型;Payload——期望的 payload 类型,无则设为undefined;Injections——注入 store 的依赖类型;StoreModel——store 根 model 类型,使用getStoreState等 helper 时有用;Result——thunk 期望的返回值类型。
声明用法示例:
interface TodosModel { todos: Array<string>; addTodo: Thunk<TodosModel, string>; }仓库示例 examples/v7-concurrent/src/store/model.ts 展示了实际项目中的典型组合:用Thunk<CatalogModel, number>声明"带 payload 的异步加载",配合Action完成状态更新。
最佳实践小结
结合 thunk 文档、源码与示例,推荐遵循以下实践:
- 副作用放 thunk,状态更新放 action——thunk 内不直接改 state,一律通过
actions派发 action; - 异步务必 return Promise——使用
async/await或显式returnPromise,便于 Easy Peasy 跟踪并让调用方链式等待; - 用 injections 注入服务——把 API 客户端等依赖通过 StoreConfig 的
injections注入,测试时可无缝替换为 mock; - 跨模块通信优先用监听器——尽量避免
getStoreActions派发远端 action,改用 actionOn / thunkOn 解耦; - 利用三阶段事件调试——通过 Redux DevTools 观察
start/success/fail事件,用helpers.fail(error)显式标记失败,配合监听器响应 thunk 的特定阶段; - 测试时善用 mockActions——需要快速验证"thunk 是否派发了预期 action 序列"时,
mockActions: true+getMockedActions()是最直接的方案。
- 前端
【免费下载链接】easy-peasy
Vegetarian friendly state for React
相关推荐
easy-peasy effectOn 完整指南:声明式状态副作用监听与自动执行
easy peasy effectOn 完整指南:声明式状态副作用监听与自动执行 effectOn 是 easy peasy 提供的声明式副作用 API:它允许
前端Easy Peasy 中的 thunkOn 监听器:基于目标动作触发副作用与逻辑编排的完整指南
Easy Peasy 中的 thunkOn 监听器:基于目标动作触发副作用与逻辑编排的完整指南 thunkOn 是 Easy Peasy("Vegetarian
前端ECC 项目 FastAPI 规则实战指南:结构、异步、依赖注入与测试的完整开发规范
ECC 项目 FastAPI 规则实战指南:结构、异步、依赖注入与测试的完整开发规范 本指南以 ECC(The agent harness performanc
人工智能AI 技能AI 插件AI 评测Agent 评测MCP Clients开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考