MobX Computed 完全指南:用 computed 派生状态、缓存结果与优化响应式性能
2026/9/19 1:35:32 网站建设 项目流程

MobX Computed 完全指南:用 computed 派生状态、缓存结果与优化响应式性能

【免费下载链接】mobxSimple, scalable state management.项目地址: https://gitcode.com/gh_mirrors/mo/mobx

导读

本文围绕 MobX 的computed(计算值)机制展开,讲解如何用最少的状态、以声明式的方式派生信息:computed 惰性求值、缓存输出、只在底层 observable 变化时重算,并在无人观察时自动挂起。读完本文,你将掌握computed的五种使用形态(注解、选项、独立函数、getter 装饰器)、其挂起/缓存/通知的底层原理、computed.structequals等高级选项的取舍,以及"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 中工厂函数的实际分支):

形态写法适用场景
注解computedmakeObservable配合,标注 getter
注解 + 选项computed(options)需要equalskeepAlive等定制
独立函数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,可以直接使用makeAutoObservableobservableextendObservable
  • 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 的"缓存点"价值:

  1. autorun首次执行,读取order.total→ 触发一次计算("Computing..."),输出Total: 0
  2. console.log(order.total)不再重算,直接返回缓存 0;
  3. order.amount = 5total重算了一次("Computing..."),但由于5 * 0 = 0结果没变,autorun 没有被触发
  4. order.price = 2→ 重算("Computing..."),输出Total: 10,此时才通知 autorun;
  5. stop()之后修改order.price = 3既不算也不通知,因为已经没有任何观察者。

关键在于第 3 步:amount变化触发了total重算,但total检测到输出未被影响(仍是 0),于是无需更新 autorun。作为对比,如果total没有被打上computed注解,autorun 将直接依赖priceamount两个原始值,上面的操作会触发 effect3 次


三、computed 的依赖图与缓存点原理

下面这张依赖图直观展示了上述示例创建出的响应式拓扑:

图中priceamount是 observable 叶子节点,total是 computed 中间节点,autorun是最下游的反应。computed 之所以高效,是因为它把"宽"的依赖扇入收敛为"窄"的通知扇出:reaction 只订阅total,而不必分别订阅每个底层 observable。

从源码看,这一机制的实现位于 packages/mobx/src/core/computedvalue.ts 的get()方法,其状态机注释概括了完整生命周期(同文件 L65-L74):

  1. 首次访问时计算并记住结果,之后一直返回缓存;
  2. 任一深层依赖变化时,向所有观察者广播POSSIBLY_STALE,等待下一步;
  3. 再次被访问时,若浅层依赖确实变化则重算:若结果变化则向POSSIBLY_STALE的观察者广播STALE,否则不通知;
  4. 若在批处理之外且无人观察,则重置一切,回到第 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 时,官方文档给出三条必须遵守的最佳实践:

  1. 不应有副作用,也不应更新其他 observable——computed 是纯派生,其内部应只读取、不写入;源码层面 MobX 会在计算期间强制关闭状态变更(见上文computeValue_的实现),违反时在开发模式下会报错;
  2. 避免创建并返回新的 observable——例如不要在 getter 里observable.box(...),否则每次求值都在制造新的响应式节点,破坏缓存与通知的稳定性;
  3. 不应依赖非 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 时它看起来并不高效,但一旦接入observerautorun等场景,它就变得极其高效。

演示代码:

// 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.setcreateAction包裹),因此修改 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只会响应widthheight的变化,而只要其中一个变了,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 中给出了四种方案,这里摘要如下:

  1. 派生不一定要是 computed:纯函数未加computed也能被 MobX 跟踪——computed 只是"缓存点"。若派生是纯的,有无computed不改变行为,只是略微不高效。observer组件会直接订阅函数执行过程中读取到的所有 observable。这是"默认策略",直到数据证明需要优化;
  2. 闭包捕获参数:在 reaction(如渲染函数)内部临时创建独立 computed——const isSelected = computed(() => store.isSelected(item.id)).get()。每次渲染创建新 computed 作为缓存点,旧的自动被清理,组件只在isSelected翻转时重渲染。这是高级优化技巧;
  3. 移动状态:把选择状态下沉到Item上作为 observable,store 里的选择集用 computed 表达(get selection() { return this.items.filter(item => item.isSelected) });
  4. 使用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
compareDefaultObject.is判等Object.is
compareStructural深度结构化比较deepEqual(a, b)
compareShallow浅层结构化比较deepEqual(a, b, 1)

这四个函数可以从mobx直接导入,同样可用于reactionequals选项(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)。


十一、常见误区与调试建议

  1. "为什么我的 computed 总是重算?"—— 先检查它是否被任何 reaction 观察;未被观察时每次读取都会重算(第五节)。这是正常行为,不是 bug;
  2. "computed 里能改状态吗?"—— 不能。计算期间 MobX 会关闭 state 变更(allowStateChangesStart(false)),需要写状态请改到 action 里;
  3. "用 computed 还是普通函数?"—— 纯派生函数默认就是对的;computed 只是缓存点,当"重算开销显著"或"下游观察者众多"时才需要它(第八节方案 1 的结论);
  4. "什么时候该用 computed.struct?"—— 仅当底层值变化后输出仍可能相等(如取整场景)时,否则是纯浪费(第七节);
  5. "如何排查 computed 的求值/通知?"—— 开启name选项配合spy事件监听,或在 MobX 开发者工具 介绍的分析手段。

小结

computed是 MobX "最小状态、最大化派生"哲学的核心构件:它惰性求值、缓存输出、无人观察时挂起,把宽依赖收敛为窄通知。正确使用姿势总结为:

  • makeObservable/makeAutoObservable把纯 getter 标记为computed
  • 遵守三条规则:无副作用、不产新 observable、不依赖非 observable;
  • 默认不开keepAlive,用computedRequiresReaction兜底暴露误用;
  • 输出为聚合对象且可能"语义相等"时,考虑equals: compareStructuralcomputed.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),仅供参考

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

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

立即咨询