- 前端
【免费下载链接】easy-peasy
Vegetarian friendly state for React
本文以 Easy Peasy 官方的 TypeScript API 文档为骨架,系统讲解EffectOn类型的三个泛型参数、effectOn工厂函数在模型中的声明方式,并结合仓库源码(src/effects.js、src/extract-data-from-model.js)与类型测试(tests/typescript/effect-on.ts、tests/typescript/state-resolvers.ts)剖析其底层运行机制。读完本文,你将能在自己的模型中声明类型安全的effectOn副作用,正确利用change.prev/change.current追踪状态变化、通过dispose函数取消异步请求,并避开"每次渲染都触发"的常见陷阱。
一、EffectOn 是什么
effectOn是 Easy Peasy 提供的一种响应式副作用声明方式:它允许你在模型内声明一个"效果"(effect),每当被跟踪的目标状态发生变化时自动执行。可以把它理解为一个在特定状态变化时自动触发的 thunk——你无需手动 dispatch,只需要声明"我要观察哪些状态"以及"状态变了之后做什么"。
对应的完整运行时 API 见 effectOn API 文档,而本文聚焦的是它的 TypeScript 形态——EffectOn类型,它在 index.d.ts 中定义,是整个声明式副作用在类型层面的"契约"。
在 TypeScript 项目中,你需要从easy-peasy中同时导入类型EffectOn与工厂函数effectOn:
import { EffectOn, effectOn } from 'easy-peasy';正如 TypeScript API 总览 所说,Easy Peasy 暴露的这些类型通常只在**定义模型接口(model interface)**时才需要显式导入使用;如果对 TypeScript 用法还不熟悉,建议先阅读 TypeScript 教程。
二、EffectOn 类型签名与三个泛型参数
EffectOn的完整签名如下(见 index.d.ts):
export interface EffectOn< Model extends object = {}, StoreModel extends object = {}, Injections = any, > { type: 'effectOn'; }它接受三个泛型参数,这也是本关联文档的核心内容:
1.Model—— 效应所绑定的局部模型
这是effectOn属性正在被定义的那个模型。你必须提供它,这样传入 effectOn 的stateResolvers所接收的state参数才能被正确推断类型——即你的局部状态的类型。
例如,在一个TodosModel内部声明副作用时,第一个泛型就写TodosModel:
interface TodosModel { todos: string[]; onTodosChanged: EffectOn<TodosModel>; }注意:这里传入的必须是局部模型类型,而不是根 Store 模型类型(这一点与Action类型的约定一致,可参考 typescript-api/action.md)。
2.StoreModel—— 需要访问整个 Store 时传入
如果你的stateResolvers或 effect 内部期望读取整个 Store 的状态(即stateResolvers的第二个参数storeState、或helpers.getStoreState()),那么就需要提供你的根 Store 模型接口,从而让storeState被正确类型化:
interface TodosModel { todos: string[]; // 需要读取根 Store 状态,因此传入 StoreModel onTodosChanged: EffectOn<TodosModel, StoreModel>; } interface StoreModel { todos: TodosModel; rootData: number; }该参数默认值为{};如果你只观察局部状态,可以省略它。
3.Injections—— 注入依赖的类型
如果你的 Store 在创建时配置了依赖注入(injections),并且你打算在 effect 中通过helpers.injections使用它们,则需要在这里提供注入对象的类型:
interface Injections { todosService: { save: (todos: string[]) => Promise<void>; }; } interface TodosModel { todos: string[]; onTodosChanged: EffectOn<TodosModel, StoreModel, Injections>; }关于如何把依赖注入到 Store,参见 StoreConfig 文档。该参数默认值为any,未使用注入时可以省略。
工厂函数 effectOn 的完整类型
对应的工厂函数签名同样带这三个泛型,并额外推导出Resolvers的返回类型(见 index.d.ts):
export function effectOn< Model extends object = {}, StoreModel extends object = {}, Injections = any, Resolvers extends StateResolvers<Model, StoreModel> = StateResolvers< Model, StoreModel >, >( dependencies: Resolvers, effect: ( actions: Actions<Model>, change: Change<Resolvers>, helpers: Helpers<Model, StoreModel, Injections>, ) => undefined | void | Dispose | Promise<Dispose>, ): EffectOn<Model, StoreModel, Injections>;这里StateResolvers被定义为StateResolver的数组(见 index.d.ts),而StateResolver形如(state: State<Model>, storeState: State<StoreModel>) => Result(见 index.d.ts)——这正好印证了文档中"局部 state + 整个 storeState"两个入参的描述。change的类型Change<Resolvers>则会把解析器数组的返回类型提取为元组(见 index.d.ts),使change.prev与change.current的每一项都有精确的类型。
三、实战示例:在模型中声明 EffectOn
官方最小示例(带注入)
关联文档给出的完整示例,结合了三个泛型参数的使用:
import { EffectOn, effectOn } from 'easy-peasy'; interface TodosModel { todos: string[]; onTodosChanged: EffectOn<TodosModel>; } const todosModel: TodosModel = { todos: [], onTodosChanged: effectOn( [(state) => state.todos], async (actions, change) => { const [todos] = change.current; await todosService.save(todos); }, ), };要点解析:
[ (state) => state.todos ]是stateResolvers(状态解析器数组),告诉 Easy Peasy 要跟踪todos这个局部状态;- 一旦
todos发生变化,handler就会被执行,change.current是解析器映射出的最新状态数组,通过解构const [todos] = change.current取到最新的todos; - handler 可以是异步的,这里用它把最新 todos 保存到后端。
更完整的类型测试示例
仓库的类型测试 tests/typescript/effect-on.ts 展示了同时使用三个泛型、并覆盖全部 helper API 的完整写法:
import { createStore, EffectOn, effectOn, Action, action } from 'easy-peasy'; interface Injections { doSomething: () => Promise<void>; } interface TodosModel { items: { id: number; text: string }[]; foo: string; setFoo: Action<TodosModel, string>; onStateChanged: EffectOn<TodosModel, StoreModel, Injections>; } interface StoreModel { todos: TodosModel; rootData: number; } const todosModel: TodosModel = { items: [], foo: 'bar', setFoo: action((state, payload) => { state.foo = payload; }), onStateChanged: effectOn( [ (state) => state.items, (state) => state.foo, (state, storeState) => storeState.rootData, // 观察整个 Store 的状态 ], (actions, change, helpers) => { actions.setFoo('bar'); // 在 effect 内 dispatch 局部 action const [prevItems, prevFoo, prevRootData] = change.prev; const [currentItems, currentFoo, currentRootData] = change.current; helpers.injections.doSomething(); // 使用注入依赖 helpers.dispatch.todos.setFoo('plop'); // 使用 Redux dispatch helpers.getState().items[0].id; // 读取局部状态 helpers.getStoreActions().todos.setFoo('plop'); // 读取整个 Store 的 actions helpers.getStoreState().rootData; // 读取整个 Store 的状态 return () => console.log('dispose'); // 返回 dispose 函数 }, ), };可以看到,stateResolvers的每个解析器都接收(state, storeState)两个参数,其中storeState的类型正是由第二个泛型StoreModel推导的;change.prev与change.current的元素类型则与解析器的返回类型一一对应(数组解构后prevItems是{ id: number; text: string }[]、prevRootData是number),全程类型安全。
为多个模型复用一个 EffectOn 类型
如果你的多个子模型都需要声明相同形态的 effect,可以像 tests/typescript/issue802.ts 那样提取一个别名类型,避免重复书写三个泛型参数:
type AppEffectOn<TModel extends object> = EffectOn< TModel, StoreModel, Injections >; interface HelloModel { name: string; sayHelloTo: Action<HelloModel, string>; onStateChanged: AppEffectOn<HelloModel>; // 局部模型各不相同 } interface TodosModel { items: { id: number; text: string }[]; onStateChanged: AppEffectOn<TodosModel>; }四、stateResolvers 与 handler:运行时如何工作
两个核心入参
effectOn接收两个参数(详见 effectOn API 文档):
stateResolvers(Array<Function>)——用于隔离你要跟踪的具体状态片段。每个解析器接收:state(Object):effectOn 属性所绑定的局部状态;storeState(Object):整个 Store 的状态。
handler(Function,必填)——执行实际副作用的函数,可以异步。它接收actions、change与helpers三个参数(详见下一节)。
状态变化如何被判定:严格相等
底层判定逻辑位于 src/effects.js 的createEffectsMiddleware:每当一个 action 派发后,中间件会比较prevState与nextState,把每个 effect 的dependencyResolvers分别在前后状态上求值,然后用**严格相等(prevDependencies[idx] !== nextDependencies[idx])**判断是否发生变化,一旦有任一依赖变化就触发对应的 effect:
const hasChanged = prevDependencies.some( (dependency, idx) => dependency !== nextDependencies[idx], ); if (hasChanged) { def.actionCreator(prevDependencies, nextDependencies, action); }因此,状态变化判定使用的是prevState === currentState式的严格相等比较。这意味着:
你必须从 store 中解析出真实的状态引用,绝不能在
stateResolvers里创建额外的包装对象/数组——否则每次比较都会因为生成了新引用而判定"已变化",导致你的 effect在 Store 的每一次变化时都执行。
反模式:返回新对象
下面的写法是官方文档明确标注的反模式:
const myModel = { one: 'one', two: 'two', effectOn( [ (state) => { // ❌ 反模式:每次都返回新的对象实例, // 会破坏严格相等比较,导致 effect 在每次状态变化时都执行 return { one: state.one, two: state.two, }; } ], (actions, change) => { console.log('I run for every change to the store!'); } ) };正确姿势:逐个解析状态片段
正确的做法是把每段状态分别解析出来:
const myModel = { one: 'one', two: 'two', effectOn( [ // ✅ 直接返回真实状态引用 (state) => state.one, (state) => state.two, ], (actions, change) => { console.log('I only run when "one" or "two" have changed'); } ) };这样只有当one或two的真实引用发生变化时 effect 才会触发。仓库中的类型测试 tests/typescript/state-resolvers.ts 还验证了单个 effect 可以挂载多达 15 个以上的 stateResolver(同时观察局部状态与整个 Store 的 computed 值),说明解析器数量与组合方式非常灵活。
五、handler 的三参数详解
handler被调用时依次收到actions、change、helpers,其类型由 index.d.ts 中Actions、Change、Helpers三个类型联合推导。
1.actions—— 局部 actions
即绑定 effect 的局部模型所对应的 actions集合。这使得你可以在副作用中 dispatch 局部 action 来更新状态。例如官方教程中的"保存中"开关:
import { effectOn } from 'easy-peasy'; const todosModel = { items: [], saving: false, setSaving: action((state, payload) => { state.saving = payload; }), effectOn( // 提供 stateResolvers 数组来解析目标状态: [state => state.items], // 提供 handler:每当目标状态变化时执行: async (actions, change) => { const [items] = change.current; actions.setSaving(true); await todosService.save(items); actions.setSaving(false); } ) };2.change—— 变化信息
一个描述"引发本次执行的变更"的对象,包含三个属性:
| 属性 | 类型 | 说明 |
|---|---|---|
prev | Array<any> | 变化前由你的stateResolvers映射出的状态数组 |
current | Array<any> | 变化发生后由你的stateResolvers映射出的最新状态数组 |
action | Object | 引发该变化的 action 的信息,含type(string,action 标识符)与payload(any,若存在) |
在 src/effects.js 的createEffectActionsCreator中,change对象正是由prevDependencies、nextDependencies与原始action组装而成:
const change = { prev: previousDependencies, current: nextDependencies, action, };测试 tests/effect-on.test.js 也验证了这一点:当setString('two')被派发后,effect 的 start 事件携带的change为{ prev: ['one'], current: ['two'], action: { type: '@action.nested.setString', payload: 'two' } }。
3.helpers—— 高级辅助对象
helpers提供更高级的能力(运行时实现在 src/effects.js):
| 属性 | 类型 | 说明 |
|---|---|---|
dispatch | Function | Redux dispatch 函数,允许派发"标准" Redux action |
getState | Function | 执行后返回局部(thunk 所绑定的)状态 |
getStoreActions | Function | 执行后返回整个 Store 的 actions |
getStoreState | Function | 执行后返回整个 Store 的状态 |
injections | Any(默认 undefined) | createStore配置中注入的依赖 |
meta | Object | 元信息,含parent(Array,模型内绑定位置的父路径)与path(Array,模型内完整路径) |
两点官方特别提醒:
- 虽然可以通过
getState/getStoreState读取状态,但effect 内不得对状态执行任何修改——这是反模式。所有状态更新必须收敛在 action 中,这些 API 存在的意义只是方便你基于现有状态做逻辑判断; - 官方不建议通过
getStoreActions在 effect 里到处派发 action,而是建议考虑改用 actionOn 或 thunkOn 这类监听型 API 来组织跨模型的联动。
六、利用 dispose 函数清理副作用
基本用法:取消进行中的 API 请求
effect 的 handler 允许返回一个dispose函数,它会在下一次执行开始之前被调用。如果你上一次的 effect 执行尚未完成,dispose就提供了一次"收尾"机会——典型的用途是取消 API 调用:
import { effectOn } from 'easy-peasy'; const todosModel = { items: [], effectOn( [state => state.items], (actions, change) => { const [items] = change.current; const request = todosService.save(items); // 返回 dispose 函数:在下一次 handler 执行前中止当前保存请求 // 👇 return () => request.abort(); } ) };dispose 必须同步返回
dispose函数必须从 effect 中同步返回——虽然 effect 内部可以执行异步代码。这个限制保证了:一旦有新的 effect 执行开始,Easy Peasy 能尽可能早地执行上一次的dispose。下面演示的是反模式:
effectOn( [(state) => state.items], // ❌ 使用了 async/await,dispose 会通过隐式返回的 Promise 被异步解析 async (actions, change) => { const [items] = change.current; const request = await todosService.save(items); return () => request.abort(); }, );注意上面这段代码里,由于await todosService.save(items)的存在,dispose 必然在保存调用解析之后才会被返回,起不到"提前取消"的作用。
源码中的执行顺序
在 src/effects.js 的createEffectHandler中可以看到完整逻辑:
- 每次触发时,如果存在上一次保存的
dispose,会先同步执行它(若返回的是 Promise,则仅捕获错误,见 src/effects.js); - 然后调用
def.fn(actions, change, helpers)得到effectResult; - 若
effectResult是 Promise,则等待其 resolve;若最终解析出的值是函数,说明用户异步地返回了 dispose——开发环境下会输出警告'[easy-peasy] Effect is asynchronously resolving a dispose fn.'(见 src/effects.js); - 只有同步返回函数时,才会把它保存为下一次执行前的
dispose(见 src/effects.js)。
测试验证
effect-on.test.js 验证了同步 effect + 同步 dispose 的行为:连续派发setFoo(1)、setFoo(2)、setFoo(3)后,执行序列严格为effect(1) → dispose(1) → effect(2) → dispose(2) → effect(3),即每次新执行前都会先调用上一次的 dispose。该文件同时验证了同步 effect + 异步 dispose 也被允许(见 tests/effect-on.test.js)。
七、执行行为与局限性
执行行为:并发且不等待
关于 effect 的执行时机,官方文档明确:
- 若目标状态连续多次变化,你的 effect 会为每一次状态变化各执行一次;
- 这意味着可能同时存在多个并发的 effect 执行——尤其是耗时较长的异步 effect;
- 每次执行不会等待先前执行完成。如果你需要类似"防抖"的效果,可以在 handler 内部自行实现 debounce,或利用
dispose在新执行开始时清理旧执行(参见上文第六节)。
局限性
- Effect 不能被直接派发:例如
useStoreActions钩子不会包含 effects(参见 use-store-actions 文档),你无法像 thunk 那样手动触发它; - 执行时机不保证:最好把 effect 当作"异步操作"来看待,不要试图把外部逻辑挂接在它的 resolve 之上。
错误处理
由于用户拿不到 effect 的句柄,effect 中抛出的错误无法被外部捕获,运行时会将错误通过console.error输出(见 src/effects.js 的logEffectError)。测试 tests/effect-on.test.js 验证了这一点:effect 内部 async thunk 抛出的错误会被记录到 console。
八、底层原理:从模型到中间件
理解EffectOn类型背后,effectOn工厂函数在运行时是这样被"接入"状态管理的:
工厂函数标记:src/helpers.js 中
effectOn(dependencyResolvers, fn)返回{ [effectOnSymbol]: true, dependencyResolvers, fn },其中effectOnSymbol在 src/constants.js 定义为'$_e'。模型提取:src/extract-data-from-model.js 在遍历模型时识别带
effectOnSymbol的值,为其生成元数据type(@effectOn.<path>)、startType、successType、failType,并创建effectHandler与actionCreator,最后把定义推入_e(effects 列表)。中间件监听:Store 创建时挂载
createEffectsMiddleware(见 src/create-store.js),该中间件在每次 dispatch 后对比前后状态并触发相应的 effect actionCreator(见 src/effects.js)。事件派发:
createEffectActionsCreator(见 src/effects.js)在 effect 执行前后分别派发startType与successType事件,因此你可以在 Redux DevTools 中观察到@effectOn.<path>(start)/(success)事件及其中携带的change对象——这与测试断言中的事件类型一致(见 tests/effect-on.test.js)。
这条调用链完整印证了文档描述:effectOn 不是被手动派发的 action,而是由状态管理中间件在状态变化时自动驱动。
九、相关资源
- 关联文档:TypeScript API — EffectOn
- 完整运行时文档:API — effectOn
- 类型定义:index.d.ts 中的 EffectOn 区域
- 源码实现:src/effects.js、src/helpers.js、src/extract-data-from-model.js
- 类型测试:tests/typescript/effect-on.ts、tests/typescript/state-resolvers.ts、tests/typescript/issue802.ts
- 行为测试:tests/effect-on.test.js
- 配套阅读:TypeScript 教程、actionOn、thunkOn
- 前端
【免费下载链接】easy-peasy
Vegetarian friendly state for React
相关推荐
easy-peasy effectOn 完整指南:声明式状态副作用监听与自动执行
easy peasy effectOn 完整指南:声明式状态副作用监听与自动执行 effectOn 是 easy peasy 提供的声明式副作用 API:它允许
前端Cycle.js与TypeScript高级类型:构建类型安全的响应式应用
Cycle.js与TypeScript高级类型:构建类型安全的响应式应用 你是否在开发响应式应用时遇到过类型不匹配导致的运行时错误?是否希望在编译阶段就能捕获数
前端Web框架Easy Peasy 监听器(listeners)完全指南:actionOn 与 thunkOn 的响应式状态与副作用编程
Easy Peasy 监听器(listeners)完全指南:actionOn 与 thunkOn 的响应式状态与副作用编程 本文基于 Easy Peasy 官方
前端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考