前端阅读 05月27日 18:16
MobX 组件不更新、异步报错怎么办?常见坑和解决方案
MobX 的响应式机制看起来很简单——observable 包数据、observer 包组件、action 改状态,三件套一配就完事了。但实际项目里踩坑的地方不少:组件明明包了 observer 却不更新、异步操作改了状态没反应、computed 值死活不刷新。这篇文章把最常遇到的坑按出现频率排了一遍,每个坑讲清楚为什么掉进去以及怎么爬出来。组件包了 observer 却不更新这是用 MobX 最容易遇到的问题,没有之一。现象很明确:数据变了,组件纹丝不动。原因通常就那么几个。没有真正访问 observable 属性。 observer 只追踪 render 过程中实际读取的 observable 属性。如果你在 render 外面把值解构出来,MobX 根本不知道这个组件依赖那个属性:// 不会更新——render 里没有直接访问 observableconst { count } = store;return <div>{count}</div>;// 会更新——render 过程中访问了 store.countreturn <div>{store.count}</div>;用了普通对象而非 observable。 这看起来很低级,但在项目里经常出现——某个同事新加了一个对象,忘了用 observable 包一下,组件读它当然不会更新。MobX 6 没开 action 就改状态。 MobX 6 默认 enforceActions: 'observed',意味着所有 observable 状态的修改必须在 action 里进行。在 action 外直接 this.count++ 会报错。如果你为了省事关了这个检查(configure({ enforceActions: 'never' })),表面上看不出问题,但 MobX 内部的批量更新机制会失效,导致多次修改触发多次渲染。class Store { count = 0; constructor() { makeAutoObservable(this); } increment() { this.count++; // makeAutoObservable 自动把方法变成 action }}用 makeAutoObservable 就不用手动标注 action 了,它会自动推断。render 里创建了新的引用类型。 每次 render 都 new 一个对象或数组,即使内容一样引用也不同,React 做 shallow compare 会认为 props 变了,触发不必要的子组件重渲染。更隐蔽的问题是,如果你把这个新对象传给另一个 observer 组件,MobX 会误以为依赖变了。异步操作改了状态不生效@action 只能同步地修改状态。一旦遇到 await,action 的边界就断了——await 之后的代码不在 action 作用域内,MobX 会在控制台疯狂报警告。三种解决方案,推荐程度从高到低:用 flow(最推荐)。 flow 是 MobX 专门为异步设计的,用 generator 函数写,每个 yield 之间的代码自动包裹在 action 里,心智负担最小:class Store { data = null; loading = false; constructor() { makeAutoObservable(this); } fetchData = flow(function* () { this.loading = true; try { const res = yield fetch('/api/data'); this.data = yield res.json(); } catch (e) { console.error(e); } finally { this.loading = false; } });}用 runInAction。 如果不想用 generator,在 await 之后手动把状态修改包进 runInAction:async fetchData() { this.loading = true; try { const res = await fetch('/api/data'); runInAction(() => { this.data = res.json(); }); } finally { runInAction(() => { this.loading = false; }); }}注意 runInAction 需要从 mobx 导入,而且每次修改状态都要包一次,容易漏。用 action 包裹整个 async 函数再配合 runInAction。 这是上面两种的混合,不推荐,代码更啰嗦。computed 值不更新或更新不对computed 有两个大坑:一个是在里面搞副作用,一个是依赖追踪丢了。computed 里搞副作用。 computed 本质是一个缓存计算值,MobX 期望它是纯函数——给同样的输入返回同样的输出,不做任何额外的事。如果你在 computed 里调接口、打日志、改其他状态,MobX 的缓存策略会乱套:// 大错特错get filteredList() { console.log(this.items.length); // 副作用 fetch('/api/track', { body: this.query }); // 副作用 return this.items.filter(i => i.active);}// 正确——纯计算get filteredList() { return this.items.filter(i => i.active);}需要副作用的场景用 autorun 或 reaction,别用 computed。依赖追踪丢了。 MobX 的响应式只在属性被实际读取时才建立追踪。常见写法是解构了 observable 再用,或者条件分支里读了一个属性但返回值没用到:// 不会追踪 this.dataget bad() { const data = this.data; // 读了但没用 return this.items.length;}// 会正确追踪两个依赖get good() { return this.data.length + this.items.length;}内存泄漏:reaction 没清理autorun、reaction、when 这些函数调用后都会返回一个 disposer,组件卸载时必须调用。忘了清理的话,组件都销毁了,reaction 还在跑,轻则内存泄漏,重则操作已卸载组件的 DOM 报错。React 项目里用 useEffect 的 cleanup 来处理:useEffect(() => { const dispose = autorun(() => { console.log(store.count); }); return () => dispose();}, []);when 也要清理——虽然 when 会在条件满足后自动清理,但组件卸载时条件可能还没满足,reaction 还在等。性能问题:渲染太频繁MobX 的 observer 做得已经很精细了——只有组件实际读取的 observable 变了才会重渲染。但还是会遇到性能问题,常见原因:单个组件读太多 observable。 一个大组件读了 store 里的十几个属性,其中任何一个变了都会重渲染整个组件。解法是拆组件——每个小组件只读自己关心的那几个属性:// 一个大组件读了很多数据,任何一个变了都重渲染const Dashboard = observer(() => ( <div> <p>{store.user.name}</p> <p>{store.settings.theme}</p> <p>{store.stats.count}</p> </div>));// 拆成小组件,各管各的const UserName = observer(() => <p>{store.user.name}</p>);const Theme = observer(() => <p>{store.settings.theme}</p>);const Count = observer(() => <p>{store.stats.count}</p>);列表渲染没做细化。 如果列表组件整体用 observer 包裹,修改一条数据的某个字段会导致整个列表重渲染。给每条数据单独包一个 observer 组件,MobX 就能做到只更新变化的那一条。装饰器配置问题装饰器 @observable、@action、@computed 需要 Babel 或 TypeScript 的装饰器插件支持,配置稍微不对就报错。而且 ECMAScript 装饰器提案改了好几版,Babel 的 legacy: true 对应的是旧版提案,和 TypeScript 的 experimentalDecorators 也不是完全一回事。MobX 6 开始官方推荐 makeAutoObservable / makeObservable,不再需要装饰器:class Store { count = 0; list = []; constructor() { makeAutoObservable(this); } // 普通方法自动变成 action increment() { this.count++; } // getter 自动变成 computed get double() { return this.count * 2; }}makeAutoObservable 能推断大多数场景,但有个限制:子类继承时需要手动在子类构造函数里再调一次。makeObservable 更灵活但需要手动标注每个属性。数组操作踩坑MobX 6 默认用 Proxy 实现 observable,数组操作基本和原生数组行为一致,push、splice、map、filter 都能正常触发更新。但有几个细节:直接赋值替换数组。 在 MobX 6 的 Proxy 模式下直接赋值是可以的(this.items = newArray),但如果你在用旧版 MobX 或者关了 Proxy(useProxies: 'never'),需要用 replace():@actionreplaceItems(newItems) { this.items.replace(newItems); // 旧版 MobX 安全写法}传给非 MobX 库时要转原生。 有些第三方库(如 Lodash 的某些方法、antd 的 Table dataSource)对 observable 数组的兼容性不好,传之前用 .slice() 或 toJS() 转成普通数组:import { toJS } from 'mobx';lodashChain(toJS(this.items));Array.isArray 在旧版返回 false。 Proxy 模式下没问题,但旧版 observable 数组不是真数组,Array.isArray(observable([1,2,3])) 返回 false。用 isObservableArray 检测或 .slice() 转换。循环依赖导致无限更新两个 store 的 computed 互相依赖对方的数据,改一个触发另一个重算,另一个重算又触发第一个重算,死循环。MobX 会检测到循环依赖并抛出错误,但有时候循环不是那么明显——比如 A 的 reaction 修改了 B 的数据,B 的 reaction 又修改了 A 的数据,这种间接循环更难定位。解法是理清数据流方向,让依赖关系变成单向的。如果两个 store 确实需要共享数据,抽出一个更高层的 store 来管理共享状态,让两个子 store 都依赖父 store 而不是互相依赖。调试手段MobX 提供了几个调试工具,按实用程度排序:trace():放在 computed 或 observer 的 render 里,控制台会打印这个计算/渲染依赖了哪些 observable,以及是否在某个 observable 变化时重新计算。trace(true) 会在 debugger 断点停下,方便逐步排查。spy():全局监听所有 MobX 事件(action 执行、observable 修改、computed 重算、reaction 触发),适合定位"到底是什么触发了这次渲染":import { spy } from 'mobx';spy((event) => { if (event.type === 'action') { console.log('Action:', event.name); }});getDependencyTree / getObserverTree:程序化地获取依赖关系树,可以判断某个 computed 依赖了哪些 observable,或者某个 observable 被哪些 observer 观察。MobX DevTools:浏览器扩展,可视化展示 observable 树和依赖关系。功能不如 Redux DevTools 丰富,但对于排查响应式链路断裂够用了。TypeScript 类型问题makeObservable 的泛型参数容易写错。MobX 6 要求传入类型参数来推断 this 的类型:class Store { count: number = 0; constructor() { makeObservable<Store>(this, { count: observable, increment: action, }); } increment() { this.count++; }}如果用 makeAutoObservable,大多数情况不需要手动传泛型,TypeScript 能自动推断。但 makeAutoObservable 不支持子类,子类需要在构造函数里手动调 makeObservable 并列出所有需要观测的属性。另一个常见问题是 observable 的类型推断——observable({ list: [] }) 里的 list 会被推断为 never[] 而不是 any[],需要手动标注类型:class Store { list: Item[] = []; constructor() { makeAutoObservable(this); }}