Formily 响应式核心 API 实战解析:reaction 的脏检查与依赖订阅机制
2026/9/23 15:58:06 网站建设 项目流程

Formily 响应式核心 API 实战解析:reaction 的脏检查与依赖订阅机制

【免费下载链接】formily📱🚀 🧩 Cross Device & High Performance Normal Form/Dynamic(JSON Schema) Form/Form Builder -- Support React/React Native/Vue 2/Vue 3项目地址: https://gitcode.com/gh_mirrors/fo/formily

导读

reaction是 Formily 响应式核心包@formily/reactive中与autorun并列的副作用响应 API。它接收一个 tracker(追踪函数)与一个 subscriber(回调响应函数),当 tracker 内部消费的 observable 数据发生变化时,tracker 会重复执行;但与autorun不同的是,只有当 tracker 的返回值发生变化时,subscriber 才会被触发。这一“先比较返回值、再决定是否回调”的语义,让reaction天然适合做“数值变化驱动的副作用”场景,例如监听表单值求和、筛选条件汇总、分页数据变化等。读完本文,你将掌握reaction的完整签名、三个可选参数的精确行为、与batch协作时的触发时机,以及它在源码中的执行链路与测试验证依据。

reaction 是什么:与 autorun 的定位差异

@formily/reactive中,autorunreaction同源但语义不同:

  • autorun:tracker 内部任何被消费的 observable 发生变化,tracker 就会重新执行(没有返回值比较环节),适合需要“每次都重跑”的渲染型副作用;
  • reaction:tracker 重新执行之后,还会对返回值做一次脏检查(dirty check),返回值不变则不触发 subscriber,适合“只在计算结果变化时才做副作用”的场景。

两者都返回一个dispose函数,用于解除订阅。reaction的 API 文档位于 packages/reactive/docs/api/reaction.zh-CN.md,其核心思想可用一句话概括:tracker 负责收集依赖并产出值,subscriber 只关心这个值是否真的变了。

签名与参数详解

reaction的完整 TypeScript 签名如下(出自 API 文档):

interface IReactionOptions<T> { name?: string equals?: (oldValue: T, newValue: T) => boolean //脏检查 fireImmediately?: boolean //是否第一次默认触发,绕过脏检查 } interface reaction<T> { ( tracker: () => T, subscriber?: (newValue: T, oldValue: T) => void, options?: IReactionOptions<T> ): void }

各参数含义:

参数类型说明
tracker() => T追踪函数。执行期间读取的 observable 属性会被自动收集为依赖;其返回值作为“观察值”参与脏检查
subscriber(newValue, oldValue) => void回调响应函数。仅在 tracker 返回值发生变化时执行,入参分别为新值、旧值
options.namestring反应名称,默认值为'Reaction',主要用于调试定位
options.equals(oldValue, newValue) => boolean自定义脏检查函数,返回true表示“相等”(不触发),返回false表示“不等”(触发)。默认使用!==严格不等比较
options.fireImmediatelyboolean是否在创建时立即触发一次 subscriber,绕过脏检查

接口定义同样存在于源码 packages/reactive/src/types.ts,其中name的默认值'Reaction'可以在 autorun.ts 的实现中看到:

const realOptions = { name: 'Reaction', ...options, }

从官方用例看运行机制

文档给出的完整用例(这里补充了逐步执行说明):

import { observable, reaction, batch } from '@formily/reactive' const obs = observable({ aa: 1, bb: 2, }) const dispose = reaction(() => { return obs.aa + obs.bb }, console.log) batch(() => { //不会触发,因为 obs.aa + obs.bb 的值没变 obs.aa = 2 obs.bb = 1 }) obs.aa = 4 dispose()

这段代码的执行时序如下:

  1. 创建阶段reaction创建时会立即执行一次 tracker,此时obs.aa + obs.bb === 3,同时完成对obs.aaobs.bb两个属性的依赖收集;由于未传fireImmediately,subscriber 不会立刻执行。
  2. batch 阶段batch内的两次赋值obs.aa = 2obs.bb = 1会被合并处理。虽然两次赋值都触发了依赖通知,但由于处于批处理中,reaction只会被压入待执行队列;批处理结束时统一执行一次 tracker,此时返回值依然是2 + 1 === 3,与旧值3相等,脏检查不通过,subscriber 不触发
  3. 单独赋值阶段obs.aa = 4在 batch 之外执行,tracker 重新计算得到4 + 1 === 5,与旧值3不等,subscriber 被调用:console.log(5, 3)(新值、旧值)。
  4. 销毁阶段dispose()解除订阅,此后任何对obs.aaobs.bb的修改都不会再触发 tracker 与 subscriber。

源码实现:reaction 的完整执行链路

reaction的实现位于 packages/reactive/src/autorun.ts,与autorun同文件,共享同一套反应调度基础设施(见 packages/reactive/src/reaction.ts)。其内部由三个关键部分构成。

1. tracker 的封装与依赖收集

const reaction: Reaction = () => { if (ReactionStack.indexOf(reaction) === -1) { releaseBindingReactions(reaction) // 先释放旧的依赖绑定 try { ReactionStack.push(reaction) // 压入反应栈,进入收集态 value.currentValue = tracker() // 执行 tracker 并记录返回值 } finally { ReactionStack.pop() // 退出收集态 } } }

执行 tracker 期间,任何对 observable 属性的读取都会触发 bindTargetKeyWithCurrentReaction,把“当前反应 + 目标属性键”写入RawReactionsMap依赖表;而每次重新执行前调用releaseBindingReactions清空旧依赖,从而保证依赖集合总是反映最近一次 tracker 的实际读取路径

2. 调度器与脏检查

reaction._scheduler = (looping) => { looping() // 重新执行 tracker,更新 currentValue if (dirtyCheck()) fireAction() // 脏检查通过才触发 subscriber value.oldValue = value.currentValue // 更新旧值快照 }

其中脏检查逻辑为:

const dirtyCheck = () => { if (isFn(realOptions.equals)) return !realOptions.equals(value.oldValue, value.currentValue) // 自定义比较 return value.oldValue !== value.currentValue // 默认 !== 比较 }

也就是说,默认使用!==做“引用/原始值”比较;对于对象、数组这类引用类型,即使内容完全一致,只要引用变了也会触发——这正是官方用例与测试中“浅比较”的行为来源。

3. subscriber 的触发与销毁

const fireAction = () => { try { batchStart() // 包裹在批处理中执行,避免重复触发 if (isFn(subscriber)) subscriber(value.currentValue, value.oldValue) } finally { batchEnd() } }

reaction最终返回销毁函数:

return () => { disposeBindingReactions(reaction) // 从 RawReactionsMap 中移除所有依赖绑定 }

销毁函数调用 disposeBindingReactions,将反应标记为_disposed并从全局依赖表中彻底解除,之后对相关属性的任何修改都不会再唤醒该反应。

关键语义深入

脏检查(Dirty Check)的边界

reaction的触发条件是“tracker 返回值变化”,而非“依赖数据变化”。二者的区别体现在两个典型场景:

  • 依赖数据变了但返回值没变 → 不触发(官方用例中的 batch 场景);
  • 依赖数据没变但返回值变了 → 不可能发生(依赖未变则 tracker 不会重新执行)。

测试 packages/reactive/src/tests/autorun.spec.ts 中的reaction dirty check用例对此有精确验证:aa被标记为observable.ref后,在batch中连续两次赋相同的值123,handler 调用次数保持为 0。

自定义 equals 实现深度比较

默认!==是浅比较,如果 tracker 返回的是对象,需要按内容比较时,可自定义equals

reaction( () => { return obs.aa // aa 是 observable.ref 引用类型 }, handler, { equals: (a, b) => JSON.stringify(a) === JSON.stringify(b), // 深度比较 } ) obs.aa = { bb: 123 } // 内容相同,equals 返回 true,不触发

对应测试reaction with deep equals:赋值一个内容相同的新对象引用后 handler 调用次数为 0;而reaction with shallow equals用例中,不传equals时同样赋值新引用,handler 调用次数为 1——直观展示了浅/深比较的差异。

fireImmediately:绕过脏检查立即触发

reaction( () => obs.aa.bb, handler, { fireImmediately: true } ) // 创建后 handler 立即被调用 1 次,即使旧值等于新值

测试reaction fireImmediately验证:创建即调用 1 次;随后赋相同值不触发;赋新值再触发 1 次。源码中的实现顺序为:先执行一次 tracker 并同步oldValue = currentValue,然后调用fireAction()——因此即使新旧值相等,也会被强制执行一次 subscriber。

subscriber 内部读取 observable 不建立新依赖

subscriber执行时ReactionStack已清空(tracker 的执行与回调调用是分离的),因此在 subscriber 内读取 observable 不会产生新的依赖绑定。测试reaction untrack handler证明了这一点:handler 内部读取obs.aa.cc,随后修改obs.aa.cc并不会导致 handler 被再次调用。

依赖的动态重收集

tracker 每次执行前都会释放旧依赖,因此依赖集合可以随条件分支动态变化。测试reaction recollect dependencies展示了典型场景:

reaction(() => { if (obs.aa === 'aaa') { return obs.bb // 当前只依赖 obs.aa、obs.bb } return obs.cc // 分支切换后改为依赖 obs.aa、obs.cc }, trigger)

obs.aa被改为'111'后,tracker 走第二个分支,依赖自动切换为obs.cc,后续修改obs.bb不再触发,修改obs.cc才会触发。

与 autorun 的对比速查

维度autorunreaction
tracker 返回值无返回值参与判断返回值参与脏检查
触发条件依赖变化即重跑依赖变化且返回值变化才回调
subscriber 入参无(tracker 即副作用)(newValue, oldValue)
典型场景渲染、日志、同步写缓存按计算结果驱动的副作用(求和、过滤、分页)
返回销毁函数

autorun的文档见 packages/reactive/docs/api/autorun.zh-CN.md,它与reaction共享ReactionStackRawReactionsMap、批处理队列等基础设施。

与 batch 协作的实践要点

  • reaction内部对 subscriber 的调用本身被batchStart/batchEnd包裹(见fireAction),因此 subscriber 内部若再次修改 observable,不会引发同步的连锁反应,而是进入批处理队列统一消化;
  • batch外部连续修改多个依赖,每次修改都会触发一次 tracker 执行与脏检查(多次回调);若希望合并为一次回调,请像官方用例那样把修改包进batch,批处理结束时反应只会执行一次(详见 packages/reactive/docs/api/batch.zh-CN.md);
  • 在 Formily 表单体系中,reaction常被用于监听若干字段值的变化并汇总计算结果(如总价、依赖联动值),通过equals可避免对复杂计算结果的重复回调。

小结

reaction@formily/reactive中最具“计算语义”的响应式 API:tracker 承担依赖收集与取值职责,脏检查决定了 subscriber 的触发边界,fireImmediatelyequals则提供了对触发时机的精细控制。理解其与batch的协作时序和ReactionStack依赖收集机制,是正确使用 Formily 响应式体系、避免“多触发”或“漏触发”的关键。以上行为均有对应源码(autorun.ts、reaction.ts)与单元测试(autorun.spec.ts)作为依据,读者可结合实际场景进一步验证。

【免费下载链接】formily📱🚀 🧩 Cross Device & High Performance Normal Form/Dynamic(JSON Schema) Form/Form Builder -- Support React/React Native/Vue 2/Vue 3项目地址: https://gitcode.com/gh_mirrors/fo/formily

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

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

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

立即咨询