MobX Computed 完全指南:用 computed 派生状态、缓存结果与优化响应式性能
【免费下载链接】mobxSimple, scalable state management.项目地址: https://gitcode.com/gh_mirrors/mo/mobx
导读
本文围绕 MobX 的computed(计算值)机制展开,讲解如何用最少的状态、以声明式的方式派生信息:computed 惰性求值、缓存输出、只在底层 observable 变化时重算,并在无人观察时自动挂起。读完本文,你将掌握computed的五种使用形态(注解、选项、独立函数、getter 装饰器)、其挂起/缓存/通知的底层原理、computed.struct与equals等高级选项的取舍,以及"computed 是否一定要用"的工程判断,配套示例可直接在浏览器 Console 或 Node 环境运行验证。
说明:本文为 docs/computeds.md 的深度展开,所有源码依据均来自本仓库
packages/mobx包。
一、computed 是什么:响应式状态里的"电子表格公式"
Computed values 用于从其他 observable派生信息。它们:
- 惰性求值(lazy):只有被读取时才计算;
- 缓存输出(caching):底层 observable 未变化时,多次读取直接返回缓存;
- 只读重算(recompute only if needed):仅当所依赖的 observable 发生变化时才重新计算;
- 完全挂起(suspend):当没有任何 reaction 观察它时,计算被整体暂停。
从概念上讲,computed 与电子表格中的公式高度相似:你只维护原始单元格(state),其余全部由公式(derivation)推导。它帮你大幅减少需要存储的状态量,并且经过高度优化——能用 computed 的地方尽量用。
computed在 MobX 中有五种使用形态(见 packages/mobx/src/api/computed.ts 中工厂函数的实际分支):
| 形态 | 写法 | 适用场景 |
|---|---|---|
| 注解 | computed | 与makeObservable配合,标注 getter |
| 注解 + 选项 | computed(options) | 需要equals、keepAlive等定制 |
| 独立函数 | computed(fn, options?) | 创建"盒子化"的独立计算值,返回.get()/.set()对象 |
| getter 装饰器 | @computed | 启用装饰器语法后标注 getter |
| 装饰器 + 选项 | @computed(options) | 同上并携带选项 |
其中computed(fn, options?)会直接返回一个ComputedValue实例(实现于 packages/mobx/src/core/computedvalue.ts),可用.get()读取、.set()写入(若配置了 setter)。
二、基本用法:注解 getter + makeObservable
创建 computed 的标准方式,是用computed注解一个 JavaScript getter:
- 用
makeObservable显式把某个 getter 声明为 computed; - 如果想让类中所有 getter 自动成为 computed,可以直接使用
makeAutoObservable、observable或extendObservable; - computed getter 会成为**不可枚举(non-enumerable)**属性。
官方示例(原文代码,可直接运行):
import { makeObservable, observable, computed, autorun } from "mobx" class OrderLine { price = 0 amount = 1 constructor(price) { makeObservable(this, { price: observable, amount: observable, total: computed }) this.price = price } get total() { console.log("Computing...") return this.price * this.amount } } const order = new OrderLine(0) const stop = autorun(() => { console.log("Total: " + order.total) }) // Computing... // Total: 0 console.log(order.total) // (No recomputing!) // 0 order.amount = 5 // Computing... // (No autorun) order.price = 2 // Computing... // Total: 10 stop() order.price = 3 // Neither the computation nor autorun will be recomputed.示例中的autorun来自 Reactions 进阶章节,它创建了一个响应式副作用。下面逐行拆解这个输出,理解 computed 的"缓存点"价值:
autorun首次执行,读取order.total→ 触发一次计算("Computing..."),输出Total: 0;console.log(order.total)→不再重算,直接返回缓存 0;order.amount = 5→total重算了一次("Computing..."),但由于5 * 0 = 0结果没变,autorun 没有被触发;order.price = 2→ 重算("Computing..."),输出Total: 10,此时才通知 autorun;stop()之后修改order.price = 3→既不算也不通知,因为已经没有任何观察者。
关键在于第 3 步:amount变化触发了total重算,但total检测到输出未被影响(仍是 0),于是无需更新 autorun。作为对比,如果total没有被打上computed注解,autorun 将直接依赖price和amount两个原始值,上面的操作会触发 effect3 次。
三、computed 的依赖图与缓存点原理
下面这张依赖图直观展示了上述示例创建出的响应式拓扑:
图中price、amount是 observable 叶子节点,total是 computed 中间节点,autorun是最下游的反应。computed 之所以高效,是因为它把"宽"的依赖扇入收敛为"窄"的通知扇出:reaction 只订阅total,而不必分别订阅每个底层 observable。
从源码看,这一机制的实现位于 packages/mobx/src/core/computedvalue.ts 的get()方法,其状态机注释概括了完整生命周期(同文件 L65-L74):
- 首次访问时计算并记住结果,之后一直返回缓存;
- 任一深层依赖变化时,向所有观察者广播
POSSIBLY_STALE,等待下一步; - 再次被访问时,若浅层依赖确实变化则重算:若结果变化则向
POSSIBLY_STALE的观察者广播STALE,否则不通知; - 若在批处理之外且无人观察,则重置一切,回到第 1 步(即"挂起")。
重算时的"结果是否变化"由equals_比较器判定(同文件trackAndCompute(),L250-L279):
const changed = wasSuspended || isCaughtException(oldValue) || isCaughtException(newValue) || !this.equals_(oldValue, newValue)这就是第 2 步"amount变了但total结果没变、因而不通知 autorun"的源码级答案。另外值得注意:计算期间 MobX 会通过allowStateChangesStart(false)禁止状态写入(L281-L304),从机制上保证"computed 不应有副作用"这一规则。
四、computed 的使用规则(best practices)
使用 computed 时,官方文档给出三条必须遵守的最佳实践:
- 不应有副作用,也不应更新其他 observable——computed 是纯派生,其内部应只读取、不写入;源码层面 MobX 会在计算期间强制关闭状态变更(见上文
computeValue_的实现),违反时在开发模式下会报错; - 避免创建并返回新的 observable——例如不要在 getter 里
observable.box(...),否则每次求值都在制造新的响应式节点,破坏缓存与通知的稳定性; - 不应依赖非 observable 值——computed 只跟踪其在求值过程中读取的 observable;若依赖普通变量,变化时无法触发重算,缓存会失真。
此外还有一个隐含前提:computed只能标注在 getter(可含 setter)属性上。源码 packages/mobx/src/types/computedannotation.ts 的assertComputedDescriptor在开发模式下会直接报错:'computed' can only be used on getter(+setter) properties.
五、深入理解:挂起(suspension)与 keepAlive
Tip:不被观察的 computed 会被挂起。
这是 MobX 新手(尤其是从 Reselect 迁移过来)最容易困惑的点:如果你创建了一个 computed 属性,但从未在任何 reaction 中使用它,那么它不会被 memoize,看起来"重算得比预期频繁"。
例如,在上面示例调用stop()之后,如果连续两次console.log(order.total),值会被重算两次。官方解释如下:
- MobX 会自动挂起未被积极使用的计算,避免对未被访问的 computed 做无谓更新;
- 但如果 computed 没有被任何 reaction 使用,则每次请求其值都会重新求值——行为就像一个普通属性;
- 单独摆弄 computed 时它看起来并不高效,但一旦接入
observer、autorun等场景,它就变得极其高效。
演示代码:
// OrderLine has a computed property `total`. const line = new OrderLine(2.0) // If you access `line.total` outside of a reaction, it is recomputed every time. setInterval(() => { console.log(line.total) }, 60)如果确实需要"无人观察也保持缓存",有两种绕法:
- 给注解传入
keepAlive选项; - 创建一个 no-op 反应,如
autorun(() => { someObject.someComputed }),后续不再需要时再清理掉。
警告:以上两种方案都有造成内存泄漏的风险。改变默认行为属于反模式(anti-pattern),官方明确建议:默认不要改动,仅在确有必要时使用。
另一种更推荐的严格化手段是开启全局配置computedRequiresReaction(见 docs/configuration.md),它会在"computed 在响应式上下文之外被访问"时报错,从而保证你不会在 MobX 无法缓存它的地方使用它。该配置在源码 packages/mobx/src/api/configure.ts 中被读取并写入globalState.computedRequiresReaction,随后由 packages/mobx/src/core/computedvalue.ts 的warnAboutUntrackedRead_()触发警告:
[mobx] Computed value '<name>' is being read outside a reactive context. Doing a full recompute.典型的严格模式配置("always"级别)如下,与 docs/configuration.md 中的"Linting options"一致:
import { configure } from "mobx" configure({ enforceActions: "always", computedRequiresReaction: true, reactionRequiresObservable: true, observableRequiresReaction: true, disableErrorBoundaries: true })六、为 computed 定义 setter:作为推导的"逆运算"
Tip:computed 也可以有 setter。
computed 的 setter不能直接修改计算值本身(它没有可写存储),但可以用作推导的"逆运算"(inverse of the derivation)——即把"目标值"翻译回"对底层 state 的赋值"。setter 会被自动标记为 action(源码见 packages/mobx/src/core/computedvalue.ts,options.set被createAction包裹),因此修改 state 不会违反enforceActions规则。
官方示例:
class Dimension { length = 2 constructor() { makeAutoObservable(this) } get squared() { return this.length * this.length } set squared(value) { this.length = Math.sqrt(value) } }这里squared的 getter 派生自length,而squared的 setter 把面积反解为边长。注意:因为类中所有 getter 会自动成为 computed(makeAutoObservable的行为),带 setter 的 getter 会自动成为"可写的 computed"。
七、computed.struct:输出做结构化比较
Tip:computed.struct用于结构化比较输出。
默认情况下,computed 用引用相等(reference equality)判断新旧输出是否相同。当 computed 每次返回一个新的聚合对象时,它永远不可能与上一次输出"引用相等",观察者会被无谓地通知。computed.struct则先做结构化比较,再决定是否通知观察者:
class Box { width = 0 height = 0 constructor() { makeObservable(this, { width: observable, height: observable, topRight: computed.struct }) } get topRight() { return { x: this.width, y: this.height } } }源码中computed.struct的实现非常直接(packages/mobx/src/api/computed.ts):
const computedStructAnnotation = createComputedAnnotation(COMPUTED_STRUCT, { equals: compareStructural })即computed.struct本质就是computed+equals: compareStructural。
不过官方在示例后立刻给出一段泼冷水的说明:上面的例子其实不需要computed.struct!因为 computed 只在底层值变化时才重算——topRight只会响应width或height的变化,而只要其中一个变了,topRight的坐标本来就会不同,computed.struct永远不会命中缓存,纯属浪费。
实战结论:computed.struct的实际用处比听起来小。只在"底层 observable 变化后输出仍可能相同"时才使用它——例如先把坐标取整再返回,取整后的坐标可能和上一次取整结果相等,尽管原始值已经变了:
get topRight() { return { x: Math.round(this.width), y: Math.round(this.height) } }更通用的"如何判定输出是否变化"的定制入口是equals选项,见下文。
八、带参数的推导:computed 与参数的四条路径
Tip:computed 值带参数怎么办?
computed注解只能作用于 getter,而 getter 不能接收参数。但派生信息常常需要参数,典型场景是 React 多选列表:store.isSelected(item.id)。官方在 docs/computeds-with-args.md 中给出了四种方案,这里摘要如下:
- 派生不一定要是 computed:纯函数未加
computed也能被 MobX 跟踪——computed 只是"缓存点"。若派生是纯的,有无computed不改变行为,只是略微不高效。observer组件会直接订阅函数执行过程中读取到的所有 observable。这是"默认策略",直到数据证明需要优化; - 闭包捕获参数:在 reaction(如渲染函数)内部临时创建独立 computed——
const isSelected = computed(() => store.isSelected(item.id)).get()。每次渲染创建新 computed 作为缓存点,旧的自动被清理,组件只在isSelected翻转时重渲染。这是高级优化技巧; - 移动状态:把选择状态下沉到
Item上作为 observable,store 里的选择集用 computed 表达(get selection() { return this.items.filter(item => item.isSelected) }); - 使用
computedFn(来自mobx-utils,🚀):按参数组合自动 memoize 输出。官方提醒不要过早使用——memoization 需要先想清楚函数会被多少种不同参数调用,以便评估内存占用;不过它会在结果不再被 reaction 观察时自动清理条目,正常情况不会泄漏。
关于方案 2 中computed(fn)独立形态,见下文第九节。
九、独立计算值:computed(expression)
Tip:用computed(expression)创建独立计算值。
computed还可以直接作为函数调用,类似observable.box会创建一个独立的计算值对象。用返回对象上的.get()获取当前计算结果,.set()触发其 setter(若有)。
这种形式并不常用,但在需要把"盒子化"的 computed 值传来传去时非常有用,例如 docs/computeds-with-args.md 讨论的"在 reaction 中临时创建缓存点"场景。
源码层面,computed(fn, options?)的完整签名与实现(packages/mobx/src/api/computed.ts)做了三件事:
- 校验第一个参数必须是函数(开发模式下否则调用
die报错); - 解析可选的
options(必须是普通对象); opts.get = arg1,并把函数名作为默认name,然后new ComputedValue(opts)。
注意:以函数作为第二个参数传 setter 的旧式写法已不再支持,需改用{ set: fn }选项,违反时开发模式下会报错(同文件 L54-L58)。返回的IComputedValue<T>接口定义于 packages/mobx/src/core/computedvalue.ts:{ get(): T; set(value: T): void }。
十、computed 的 Options 详解
computed默认开箱即用,但可以通过options参数定制行为。完整的IComputedValueOptions<T>定义见 packages/mobx/src/core/computedvalue.ts:
export interface IComputedValueOptions<T> { get?: () => T set?: (value: T) => void name?: string equals?: IEqualsComparer<T> context?: any requiresReaction?: boolean keepAlive?: boolean }下面逐一展开文档重点讲解的四个选项。
name
字符串,用作Spy 事件监听器(见 docs/analyzing-reactivity.md)和MobX 开发者工具中的调试名称。若不提供,开发模式下会生成ComputedValue@<id>之类的自动名(packages/mobx/src/core/computedvalue.ts)。
equals
默认值compareDefault。它是一个比较函数,用于比较旧值与新值;若判定相等,则观察者不会被重新求值(见上文trackAndCompute()中!this.equals_(oldValue, newValue)的判断)。
典型用途:处理结构化数据或其他库的类型。例如一个 moment 实例的 computed,可以用(a, b) => a.isSame(b)作为比较器——这样即使底层触发重算,只要时间语义相同,观察者就免于更新。
MobX 内置四个比较函数(源码见 packages/mobx/src/utils/comparer.ts,并从 packages/mobx/src/mobx.ts 统一导出),覆盖equals选项的大多数需求:
| 比较器 | 语义 | 源码实现 |
|---|---|---|
compareIdentity | 引用同一性(===) | a === b |
compareDefault | Object.is判等 | Object.is |
compareStructural | 深度结构化比较 | deepEqual(a, b) |
compareShallow | 浅层结构化比较 | deepEqual(a, b, 1) |
这四个函数可以从mobx直接导入,同样可用于reaction的equals选项(packages/mobx/src/api/autorun.ts 中 reaction 默认也用compareDefault)。
关于computed.equals的行为,仓库测试覆盖了三种标注体系:Babel 装饰器测试、TypeScript 装饰器测试以及 2022.3 装饰器测试,验证了"结果引用不同但 equals 判定相等时不通知观察者"这一核心语义。
requiresReaction
强烈建议对非常昂贵的 computed 设为true。当你在响应式上下文之外读取它(此时可能无法缓存),computed 会抛出异常,而不是做一次昂贵的重算。这比静默重算更能暴露"错误使用位置"。它与全局配置computedRequiresReaction的关系是:单个选项优先,未设置时回落到全局配置(见 packages/mobx/src/core/computedvalue.ts 的warnAboutUntrackedRead_)。
keepAlive
true时,即使没有观察者,computed 也不会被挂起(见第五节)。代价是可能造成内存泄漏——与 reactions 文档 中讨论的泄漏风险类似:computed 会持续持有其依赖树与结果,若对象本身已无观察者,该对象与派生链上的所有节点都无法被垃圾回收。源码中suspend_()(packages/mobx/src/core/computedvalue.ts)会显式跳过keepAlive_的计算值;get()中"批外且无观察者"的分支同样以!this.keepAlive_为前提(L201-L212)。
十一、常见误区与调试建议
- "为什么我的 computed 总是重算?"—— 先检查它是否被任何 reaction 观察;未被观察时每次读取都会重算(第五节)。这是正常行为,不是 bug;
- "computed 里能改状态吗?"—— 不能。计算期间 MobX 会关闭 state 变更(
allowStateChangesStart(false)),需要写状态请改到 action 里; - "用 computed 还是普通函数?"—— 纯派生函数默认就是对的;computed 只是缓存点,当"重算开销显著"或"下游观察者众多"时才需要它(第八节方案 1 的结论);
- "什么时候该用 computed.struct?"—— 仅当底层值变化后输出仍可能相等(如取整场景)时,否则是纯浪费(第七节);
- "如何排查 computed 的求值/通知?"—— 开启
name选项配合spy事件监听,或在 MobX 开发者工具 介绍的分析手段。
小结
computed是 MobX "最小状态、最大化派生"哲学的核心构件:它惰性求值、缓存输出、无人观察时挂起,把宽依赖收敛为窄通知。正确使用姿势总结为:
- 用
makeObservable/makeAutoObservable把纯 getter 标记为computed; - 遵守三条规则:无副作用、不产新 observable、不依赖非 observable;
- 默认不开
keepAlive,用computedRequiresReaction兜底暴露误用; - 输出为聚合对象且可能"语义相等"时,考虑
equals: compareStructural或computed.struct; - 带参数的推导优先用普通函数或闭包 computed,
computedFn只在评估过参数组合规模后使用。
相关延伸阅读:docs/reactions.md(autorun/reaction)、docs/computeds-with-args.md(带参派生四方案)、docs/configuration.md(computedRequiresReaction等 lint 配置)、docs/enabling-decorators.md(@computed装饰器启用方式)。
【免费下载链接】mobxSimple, scalable state management.项目地址: https://gitcode.com/gh_mirrors/mo/mobx
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考