MobX 中 observable、computed 和 action 该怎么分工?
observable、computed 和 action 是 MobX 里最容易混在一起的三个词,但分工其实很清楚:observable 放状态,computed 放由状态推导出的值,action 放修改状态的过程。一个常见判断是,如果它需要被 UI 观察,就用 observable;如果它能由已有状态算出来,就别再存一份;如果它会改变状态,就让它进入 action 边界。
tsimport { makeAutoObservable, runInAction } from "mobx"; class TodoStore { todos = [] as { title: string; done: boolean }[]; filter: "all" | "done" = "all"; constructor() { makeAutoObservable(this); } get visibleTodos() { return this.filter === "done" ? this.todos.filter(t => t.done) : this.todos; } add(title: string) { this.todos.push({ title, done: false }); } }
上面 todos 和 filter 是 observable,visibleTodos 是 computed,add 是 action。使用 makeAutoObservable 时 MobX 会自动推断:字段变成 observable,getter 变成 computed,方法变成 action。它很省事,但不是没有边界;复杂 store 里仍然可以显式配置某些字段不追踪,避免把临时对象、第三方实例也变成 observable。
这三个概念放到一起看,还能避免一个常见误区:不要为了“方便更新”把所有派生值都存成 observable。比如 totalPrice、selectedCount、isValid 这类值大多能从已有状态计算出来,存两份反而会带来同步问题。computed 的价值就在这里,它让数据源保持单一,同时又避免每次渲染都重复计算。
makeAutoObservable 虽然好用,但在大型 store 里不要完全无脑依赖推断。某些字段可能只是缓存第三方实例,某些方法可能不应该自动绑定,某些深层对象也可能需要 observable.ref 这类更浅的追踪方式。选择默认推断还是显式 annotation,本质是开发效率和可控性的取舍。初期可以先自动推断,等 store 稳定后再把关键边界写清楚。
测试时也能看出三者分工是否合理。observable 负责准备初始状态,action 负责触发业务行为,computed 负责断言结果是否正确。如果测试必须手动改很多中间值,往往说明 store 把派生值也当状态保存了,或者 action 颗粒度切得太碎。
还有一个判断技巧:看这段代码是在回答“现在是什么”,还是“接下来做什么”。回答“现在是什么”的通常是 observable 或 computed,回答“接下来做什么”的通常是 action。比如 isSubmitDisabled 是当前表单状态的派生结论,应放 computed;submitForm 会校验、请求并写入结果,应放 action。边界清楚后,组件就不需要知道太多状态更新细节,只负责展示和触发行为。
追问
observable 是不是越多越好?
不是,observable 应该只包会影响界面的业务状态。把所有对象都做成 observable 会增加理解成本,也可能让外部库实例被代理后行为变怪。取舍在于便利和边界:页面状态、表单值、接口数据适合观察,DOM 节点、WebSocket 实例、不可变配置通常不适合。踩坑点是把服务类、路由对象也塞进 store,后面调试时很难分清哪些变化真的应该触发 UI。
computed 和普通函数有什么区别?
computed 有缓存,并且会被 MobX 追踪依赖;普通函数每次调用都会重新执行。对于列表过滤、金额汇总、权限派生这类由 observable 算出的值,computed 通常更合适。边界是 computed 不适合接收经常变化的临时参数,也不应该包含副作用。实际项目里如果一个 getter 里顺手改了 loading 或发了请求,后续很容易出现循环触发。
action 只是为了规范代码吗?
action 不只是风格约束,它还提供状态修改的事务边界。一个 action 里连续改多个 observable,MobX 会等 action 结束后再通知 reaction,避免 UI 看到中间状态。取舍是你需要把“读”和“写”分清楚,不能在任何地方随手改 store。开启严格模式后,没放进 action 的修改会报错,这在团队协作时反而是好事。
异步 action 应该怎么写才安全?
async 方法可以作为 action 入口,但 await 之后的代码已经进入新的异步片段。为了让后续赋值仍然处在 action 边界内,可以用 runInAction 包住结果写入。这个写法比直接赋值多一点样板,但能避免严格模式问题,也能让状态变更集中。踩坑点是接口失败时只改了 error,忘记把 loading 改回 false,页面就会一直转圈。
tsasync loadTodos() { this.loading = true; try { const todos = await api.listTodos(); runInAction(() => { this.todos = todos; }); } finally { runInAction(() => { this.loading = false; }); } }
三者在 React 组件里怎么配合?
组件里应该尽量读取 observable 和 computed,而不是把派生逻辑散落在 JSX 中。按钮点击、表单提交这类事件则调用 action,让修改路径可追踪。取舍是 store 会稍微“厚”一点,但组件会更薄,测试和复用更容易。边界是不要把所有 UI 细节都塞进全局 store,弹窗开关、输入框临时草稿可以留在局部组件状态里。
掌握这三者后,MobX 代码会变得很直观:状态放一处,派生值只计算不保存,修改都走明确的方法。真正要避免的不是 API 用错,而是把可观察状态、计算逻辑和副作用混在同一段代码里。