☰
Easy Peasy EffectOn 类型详解:为 effectOn 响应式副作用构建精准的 TypeScript 模型
2026/10/14 14:43:30 网站建设 项目流程
  • 前端

【免费下载链接】easy-peasy

Vegetarian friendly state for React

项目地址:https://gitcode.com/gh_mirrors/ea/easy-peasy
点击查看免费下载

本文以 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 文档):

  1. stateResolvers(Array<Function>)——用于隔离你要跟踪的具体状态片段。每个解析器接收:

    • state(Object):effectOn 属性所绑定的局部状态;
    • storeState(Object):整个 Store 的状态。
  2. 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—— 变化信息

一个描述"引发本次执行的变更"的对象,包含三个属性:

属性类型说明
prevArray<any>变化前由你的stateResolvers映射出的状态数组
currentArray<any>变化发生后由你的stateResolvers映射出的最新状态数组
actionObject引发该变化的 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):

属性类型说明
dispatchFunctionRedux dispatch 函数,允许派发"标准" Redux action
getStateFunction执行后返回局部(thunk 所绑定的)状态
getStoreActionsFunction执行后返回整个 Store 的 actions
getStoreStateFunction执行后返回整个 Store 的状态
injectionsAny(默认 undefined)createStore配置中注入的依赖
metaObject元信息,含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工厂函数在运行时是这样被"接入"状态管理的:

  1. 工厂函数标记:src/helpers.js 中effectOn(dependencyResolvers, fn)返回{ [effectOnSymbol]: true, dependencyResolvers, fn },其中effectOnSymbol在 src/constants.js 定义为'$_e'。

  2. 模型提取:src/extract-data-from-model.js 在遍历模型时识别带effectOnSymbol的值,为其生成元数据type(@effectOn.<path>)、startType、successType、failType,并创建effectHandler与actionCreator,最后把定义推入_e(effects 列表)。

  3. 中间件监听:Store 创建时挂载createEffectsMiddleware(见 src/create-store.js),该中间件在每次 dispatch 后对比前后状态并触发相应的 effect actionCreator(见 src/effects.js)。

  4. 事件派发: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

项目地址:https://gitcode.com/gh_mirrors/ea/easy-peasy
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询