MobX 反应性分析(Analyzing Reactivity)指南:自省 API 与 Spy 事件体系完全解析
【免费下载链接】mobxSimple, scalable state management.项目地址: https://gitcode.com/gh_mirrors/mo/mobx
MobX 是一个简单、可扩展的状态管理库,其核心是"可观察状态 + 派生 + 反应"的细粒度响应式模型。当调试复杂应用、排查"为什么某个 autorun 没有触发"、或者想要构建基于 MobX 的开发者工具时,仅仅依赖console.log往往不够——你需要直接观察 MobX 内部运行时的依赖关系与事件流。本文以官方文档 docs/analyzing-reactivity.md 为主体,结合本仓库packages/mobx的源码与测试,系统讲解两组内省工具:用于静态剖析依赖图的getDebugName/getDependencyTree/getObserverTree/getAtom自省 API,以及用于实时监听全部运行时事件的spy全局监听器。读完本文,你将能精确回答"某个 observable 被谁观察、某个反应依赖了什么、一次 mutation 触发了哪些内部事件"这类问题,并能在自己的调试工具或 DevTools 插件中落地同样的技术。
相关前置阅读:本文频繁引用可观察对象与反应的概念,可先参阅 docs/observable-state.md、docs/reactions.md;文中事件对象的完整字段约定来自 docs/intercept-and-observe.md。
一、内省 API(Introspection APIs):透视依赖图与命名
MobX 在运行时维护着一张"可观察节点(Atom / ObservableValue / ComputedValue)→ 派生节点(Reaction / ComputedValue)"的双向依赖图。自省 API 的目标就是把这张不可见的图以可读形式暴露出来,适用于调试期排查和构建上层工具(例如官方 MobX DevTools 就使用了这些 API)。此外,isObservable*系列断言(见 docs/api.md)也常与之配合,先判断对象类型再做内省。
这些 API 的实现集中在 packages/mobx/src/api/extras.ts 与 packages/mobx/src/types/type-utils.ts,并从 packages/mobx/src/mobx.ts 对外导出。测试用例见 packages/mobx/tests/base/extras.js。
1.1getDebugName(thing, property?)
- 签名:
getDebugName(thing, property?) - 作用:返回可观察对象、属性、反应等的(自动生成的)可读调试名称。MobX DevTools 即依赖它来渲染节点标签。
名称由构造器名加全局自增编号构成,命名规则可以从 packages/mobx/tests/base/extras.js 的names、get debug name两个测试用例中归纳出来:
| 输入 | 返回示例 | 说明 |
|---|---|---|
observable.box(3) | ObservableValue@1 | 盒子(boxed)值 |
observable({ a: 3 })(整个对象) | ObservableObject@2 | 不传property时取对象管理节点 |
observable({ a: 3 })+"a" | ObservableObject@2.a | 传入属性名时取该属性原子 |
observable.map({ a: 3 }) | ObservableMap@3 | Map 整体 |
observable.map(...)+"a" | ObservableMap@3.a | 已有条目 |
observable.map(...)+"b"(仅用于has判断的键) | ObservableMap@3.b? | 问号后缀表示"存在性原子"(hasMap) |
observable([1, 2]) | ObservableArray@4 | 数组 |
computed(() => ...) | ComputedValue@5 | 计算值 |
autorun(...)/reaction(...) | Autorun@6/Reaction@… | 反应 |
命名细节值得注意:
- 命名函数优先:
autorun(function namedFunction(){})会直接使用namedFunction作为名字(见测试names)。 - action 特殊处理:
getDebugName对 action 直接返回thing.name(type-utils.ts)。测试getDebugName(action)表明:匿名 action 返回<unnamed action>,命名函数返回函数名,显式传名则返回自定义名。 - 自定义名称:
observable、computed、reaction、autorun等的第二参数选项对象都支持{ name },测试User provided debug names are always respected验证了开发与生产构建中自定义名都会被保留。 - 生产构建差异:开发模式编号为
@n(如ObservableObject@1),生产构建(minified)中编号与部分前缀会被移除,例如ObservableObject.key(见测试Default debug names - production)。
1.2getDependencyTree(thing, property?)
- 签名:
getDependencyTree(thing, property?) - 作用:返回一棵树,包含给定 reaction / computation 当前所依赖的全部可观察项。
实现逻辑(extras.ts)非常直观:先通过getAtom(thing, property)拿到根节点,再递归遍历节点的observing_列表并去重,形成{ name, dependencies }的嵌套结构。测试treeD给出了最典型的验证场景:
const a = m.observable.box(3) const b = m.computed(() => a.get() * a.get()) const c = m.autorun(() => b.get()) m.getDependencyTree(c[$mobx]) // 输出(编号按实际运行递增): // { // name: "Autorun@3", // dependencies: [{ // name: "ComputedValue@2", // dependencies: [{ name: "ObservableValue@1" }] // }] // }注意两点:其一,computed 在尚未被观察时没有依赖(测试中b刚创建时返回{ name: "ComputedValue@2" },不含dependencies字段)——MobX 计算值默认惰性求值,只有被读取才会建立依赖;其二,对observable.map的依赖会被拆分为多个节点,例如测试中的ObservableMap@4.keys()、ObservableMap@4.temperature(取值)、ObservableMap@4.temperature?与ObservableMap@4.absent?(has存在性检查),这正对应 Map 内部将"键集合、值、存在性"分别建模为独立原子的实现。
1.3getObserverTree(thing, property?)
- 签名:
getObserverTree(thing, property?) - 作用:返回一棵树,包含正在观察给定 observable 的全部 reaction / computation,方向与依赖树相反。
实现上(extras.ts)通过hasObservers/getObservers(定义于 packages/mobx/src/core/observable.ts)取得观察者集合并递归展开。与上例对称,测试treeD中:
m.getObserverTree(a) // { // name: "ObservableValue@1", // observers: [{ // name: "ComputedValue@2", // observers: [{ name: "Autorun@3" }] // }] // }依赖树与观察者树互为"反向视图":getDependencyTree回答"这个反应依赖什么",getObserverTree回答"这个状态被谁依赖"。
1.4getAtom(thing, property?)
- 签名:
getAtom(thing, property?) - 作用:返回给定可观察对象、属性、反应等背后的底层 Atom。Atom 是 MobX 响应式的最小单元,
observable.box、对象的每个属性、Map 的每个条目、数组整体、computed、reaction 背后都各自有一个 Atom(或继承自 Atom 的节点)。
不同输入的分发逻辑在 packages/mobx/src/types/type-utils.ts,测试get atom覆盖了主要路径:
| 输入 | 返回的节点类型 | 备注 |
|---|---|---|
observable.box(3) | ObservableValue | 盒子值本身即原子 |
observable({ a: 3 })+"a" | ObservableValue | 对象属性原子 |
observable({ a: 3 })(不带属性) | 抛错 | 提示必须指定属性(please specify a property) |
observable.map(...)(不带属性) | Atom(keysAtom_) | Map 的键集合原子 |
observable.map(...)+"a" | ObservableValue | 条目值原子或存在性原子 |
observable([1, 2]) | Atom | 数组的atom_ |
observable([1, 2])+0 | 抛错 | 数组不支持按索引取原子 |
computed(() => ...) | ComputedValue | 计算值本身 |
autorun(...) | Reaction | 反应本身 |
| 未观察对象的未知属性 | 抛错 | 提示no observable property 'b' found… |
1.5 组合实战:调试"反应为何没有触发"
把四个 API 组合起来,就能系统定位响应式失效问题。假设某个autorun没有按预期更新,可按以下步骤排查:
import { autorun, observable, getDebugName, getDependencyTree, getObserverTree, getAtom } from "mobx" const state = observable({ count: 0 }) const disposer = autorun(() => console.log("count is", state.count)) // 1. 这个反应叫什么?用于定位代码 console.log(getDebugName(disposer)) // Autorun@2 // 2. 它此刻依赖了什么?——若此处看不到 count,说明读取路径有误 console.log(getDependencyTree(disposer)) // 3. count 被谁观察?——双向确认 console.log(getObserverTree(state, "count")) // 4. 拿到底层原子,确认其身份与状态 const atom = getAtom(state, "count") console.log(atom.name_, atom.isBeingObserved_)一个常见坑是:在autorun回调中通过非响应式方式(如缓存引用、untracked、或在闭包外提前解构)读取了count,此时getDependencyTree会立即暴露问题——依赖列表中根本不会出现ObservableObject@1.count。另一个坑是直接对数组取getAtom(arr, 0),源码会抛出It is not possible to get index atoms from arrays,因为数组的索引原子并不单独建模。
二、Spy:全局事件监听器
如果说自省 API 是"静态照片",那么spy就是"实时录像"。它注册一个全局监听器,接收MobX 内部发生的所有事件——相当于同时对全部 observable 挂上observe监听,此外还能感知 action / reaction / computed 的执行过程。MobX DevTools 正是基于它构建的。
2.1 基本用法
- 签名:
spy(listener) - 返回值:一个
disposer函数,调用后取消监听。 - 生产构建行为:
spy在生产构建(minified)中是no-op——文档明确说明它会被压缩消除。源码 packages/mobx/src/core/spy.ts 印证:非开发环境会打印[mobx.spy] Is a no-op in production builds警告并返回空函数;在开发环境则把监听器推入globalState.spyListeners数组,并返回一个once包装的注销函数。事件分发函数spyReport同样带__DEV__守卫(spy.ts)。因此所有 spy 调试都必须在开发构建下进行。
官方文档给出的监听所有 action 的例子:
import { spy } from "mobx" const disposer = spy(event => { if (event.type === "action") { console.log(`${event.name} with args: ${event.arguments}`) } }) // 不再需要时: disposer()2.2 事件类型总览
spy监听器每次收到一个事件对象,通常至少包含type字段。默认由spy发出的事件类型如下(完整字段约定见 docs/intercept-and-observe.md):
| Type | observableKind | 其他字段 | 是否有嵌套子事件 |
|---|---|---|---|
action | — | name,object(作用域/this),arguments[] | 是 |
scheduled-reaction | — | name | 否 |
reaction | — | name | 是 |
error | — | name,message,error | 否 |
add/update/remove/delete/splice | 见事件总览 | 见 docs/intercept-and-observe.md | 是 |
report-end | — | spyReportEnd: true,time?(总执行时长 ms) | 否 |
其中report-end是某个先前以spyReportStart: true开头的事件的结束标记,由此将事件组织成"父事件 + 子事件"的嵌套组,并可能附带总执行时间。此外,类型定义(spy.ts)还列出了IComputedDidChange/IObjectDidChange/IArrayDidChange/IMapDidChange/ISetDidChange/IValueDidChange/IBoxDidChange等变更事件,它们与observe收到的事件对象完全一致。
2.3 从源码看事件何时触发
- action:
startAction中先发spyReportStart({ type: ACTION, name, object, arguments }),执行完毕后在endAction中发spyReportEnd({ time })(packages/mobx/src/core/action.ts)。因此一个 action 事件天然包裹着它内部所有状态变更事件。 - reaction / scheduled-reaction:packages/mobx/src/core/reaction.ts 中,当反应被调度时发
scheduled-reaction;实际执行时先发spyReportStart({ name, type: "reaction" }),trackDerivedFunction跑完后发spyReportEnd({ time })。 - 数组/对象/Map/Set 等变更:例如 packages/mobx/src/types/observablearray.ts 在
notifyHasObservers/ splice 逻辑中以spyReportStart(change)包裹真实变更,随后atom_.reportChanged()并通知observe监听器。
report-end的time字段取自Date.now() - startTime,可用于粗粒度性能分析(文档指出"可能"报告总执行时间)。
2.4 用快照测试观察真实事件流
仓库测试 packages/mobx/tests/base/spy.js 与快照 packages/mobx/tests/base/snapshots/spy.js.snap 完整记录了真实事件序列。以spy error用例为例,当 autorun 中 computed 抛出异常时,事件流呈现清晰的嵌套结构:
{ type: "reaction", name: "autorun", spyReportStart: true } ← 反应开始 { type: "update", observableKind: "computed", newValue: CaughtException{ cause: "Oops" }, ... } { type: "error", name: "autorun", message: "[mobx] Encountered an uncaught exception...", error: "Oops" } { type: "report-end", spyReportEnd: true } ← 反应结束 { type: "action", name: "setX", arguments: [4], spyReportStart: true } ← action 开始 { type: "update", observableKind: "object", name: "x", newValue: 4, oldValue: 3, ... } { type: "report-end", spyReportEnd: true }几个有价值的细节:
action事件的对象字段(object)指向正确的this作用域,即使 action 被解构后调用也如此——测试bound actions report correct object验证了makeAutoObservable的autoBind行为。- computed 的
update事件只在值真正变化时发出——测试computed shouldn't report update unless the value changed #3109证明:对偶数递增两次,isEven值不变,事件队列中取不到update。这符合 computed 基于结果缓存的语义。 spy监听器内部可以安全地注销自己(测试spy stop listen from handler, #1459),注销基于globalState.spyListeners的过滤。
2.5 典型应用:日志中间件与调试工具
基于spy编写一个简易的"行为日志器",把所有 action 及其耗时记录下来:
import { spy } from "mobx" const actionLog = [] const disposer = spy(event => { if (event.type === "action") { actionLog.push({ name: event.name, args: event.arguments, time: Date.now() }) console.log(`[action] ${event.name}`, event.arguments) } if (event.type === "error") { console.error(`[mobx error] ${event.message}`) } })也可以组合spy与自省 API:在scheduled-reaction事件到达时调用getDependencyTree(reactionNode),即可实现"当某个反应被调度时,顺带打印它的依赖快照",这本质上是 DevTools 中"dependency graph"视图的简化版实现。
三、实战建议与注意事项
- 仅开发环境可用:所有 spy 事件在
__DEV__下才分发,生产构建中spy是 no-op;getDebugName在生产构建下的名称也会失去编号细节(如ObservableObject.key)。调试请始终使用开发构建。 - 自省 API 面向调试与工具:
getDependencyTree/getObserverTree依赖内部observing_/observers_结构,属于内省性质;业务代码中应优先使用autorun/reaction/computed这类声明式 API,而不是轮询自省结果。 - 理解惰性:未被观察的 computed 没有依赖树,先被读取、被观察后才建立依赖;因此"依赖树为空"本身可能是一个有效状态而非 bug。
- 事件顺序即执行顺序:action 事件包裹其内部全部变更事件,reaction 事件包裹求值过程;利用
spyReportStart/spyReportEnd配对即可还原一次用户操作引发的完整因果链。
四、相关文档与源码索引
- 官方文档:docs/analyzing-reactivity.md、docs/intercept-and-observe.md、docs/api.md、docs/reactions.md
- 自省 API 实现:packages/mobx/src/api/extras.ts、packages/mobx/src/types/type-utils.ts
- Spy 实现与事件类型:packages/mobx/src/core/spy.ts、packages/mobx/src/core/action.ts、packages/mobx/src/core/reaction.ts
- 观察者集合与原子:packages/mobx/src/core/observable.ts
- 公共导出:packages/mobx/src/mobx.ts
- 测试用例:packages/mobx/tests/base/extras.js、packages/mobx/tests/base/spy.js、packages/mobx/tests/base/snapshots/spy.js.snap
【免费下载链接】mobxSimple, scalable state management.项目地址: https://gitcode.com/gh_mirrors/mo/mobx
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考