面试题手册

梳理高频技术问题,帮助你按主题复习和查漏补缺。

前端阅读 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); }}
前端阅读 05月27日 18:12

MobX autorun、reaction 和 when 有什么区别?

三个都是 MobX 的 reaction 工具,区别在于追踪粒度和执行策略:autorun 自动追踪所有依赖并立即执行,reaction 手动指定追踪数据并延迟执行,when 只在条件满足时执行一次就自动清理。autorun 最"懒人"——写一个函数,里面用到的 observable 变了它就重跑,创建时还会先跑一次。适合同步状态到 localStorage、更新 document.title 这类"有依赖就响应"的场景。缺点是容易多追踪,函数里不小心读了个不相关的 observable,它也会跟着重跑。reaction 把"追踪什么"和"做什么"拆成了两个函数,第一个函数返回值变了才触发第二个。默认不会立即执行(除非设 fireImmediately: true),而且第二个函数里读的 observable 不会被追踪。适合需要精确控制触发条件的情况,比如只监听 userId 变化去加载用户数据,而不想因为 user 对象其他字段变化而重复请求。when 是一次性的——条件函数返回 true 时执行效果函数,然后自动 dispose。适合等待初始化完成、等待数据加载这类"到了就执行,执行完就拉倒"的逻辑。如果用 autorun 或 reaction 模拟这个行为,你得手动判断条件再 dispose,容易忘。追问reaction 的 fireImmediately 和 autorun 有什么区别?fireImmediately 让 effect 函数在创建时执行一次,但追踪范围仍然是第一个函数指定的,不会追踪 effect 函数里的 observable。autorun 则是把整个函数里的 observable 都追踪了。所以 fireImmediately 只是改了执行时机,没改追踪逻辑。项目里 reaction 忘记 dispose 会怎样?和 useEffect 忘记清理一样——组件卸载后 reaction 还在跑,继续占用内存,observable 变了还会触发回调,可能操作已卸载的组件状态,导致内存泄漏甚至报错。autorun 和 when 同理,都必须在组件卸载时调用返回的 disposer。when 的条件一直不满足怎么办?when 会一直监听,永不执行 effect。可以配合 setTimeout 手动调用 disposer 来设超时,或者用 when 返回的 Promise(MobX 6+)配合 Promise.race 做超时控制:await when(() => store.loaded);// 或者带超时await Promise.race([ when(() => store.loaded), delay(5000).then(() => { throw new Error('timeout') })]);autorun 里访问数组长度和访问数组元素,追踪行为有区别吗?有。store.items.length 只追踪 length,store.items[0] 追踪具体下标,store.items.map(...) 追踪整个数组。用 reaction 可以避免这个问题——在 data 函数里只返回你需要的数据。写段代码// autorun: 自动追踪,立即执行autorun(() => { document.title = `${store.count} items`;});// reaction: 精确追踪,延迟执行reaction( () => store.userId, (id, prevId) => { loadProfile(id); });// when: 条件满足后执行一次when( () => store.initialized, () => { startApp(); });
前端阅读 05月27日 18:11

MobX 中 makeObservable、makeAutoObservable 和装饰器有什么区别?

三者的核心区别在于声明方式:makeObservable 需要显式标注每个成员的类型,makeAutoObservable 自动推断成员类型,装饰器用 @ 语法标记但需要编译器支持。MobX 6 之后官方推荐函数式 API(makeObservable / makeAutoObservable),装饰器变为可选项。传统装饰器(legacy decorators)永远不会成为 JS 标准的一部分,MobX 7 将移除对它们的支持。如果你还在用 @observable 写法,迁移计划该提上日程了。makeObservable:精确控制每个属性class TodoStore { todos = []; loading = false; constructor() { makeObservable(this, { todos: observable.shallow, // 浅层观察,数组引用变才触发 loading: observable, unfinishedCount: computed, addTodo: action, fetchTodos: flow // flow 处理 async/await }); } get unfinishedCount() { return this.todos.filter(t => !t.done).length; } addTodo(text) { this.todos.push({ text, done: false }); } *fetchTodos() { this.loading = true; try { const res = yield fetch("/api/todos"); this.todos = yield res.json(); } finally { this.loading = false; } }}makeObservable 最大的价值是精细控制。observable.shallow 只观察引用变化,数组内部对象的改动不会触发响应——这在列表渲染场景下能避免大量不必要的 re-render。observable.ref 只观察赋值,不做深度转换,适合存不可变数据。flow 专门标注 generator 函数处理异步流程,自动管理 pending/error 状态。缺点也明显:每个属性都要手动标注,漏写一个就丢响应性,而且这类 bug 不会报错,只是默默不更新。makeAutoObservable:自动推断,省心省力class TodoStore { todos = []; loading = false; constructor() { makeAutoObservable(this); } get unfinishedCount() { return this.todos.filter(t => !t.done).length; } addTodo(text) { this.todos.push({ text, done: false }); }}推断规则很直接:字段 → observable,getter → computed,方法 → action。一个 makeAutoObservable(this) 就完事。如果某个成员不想被自动推断,可以覆盖:constructor() { makeAutoObservable(this, { todos: observable.shallow, // 覆盖:用浅层观察 helper: false, // 排除:不使其可观察 fetchTodos: flow // 覆盖:generator 用 flow });}以 _ 开头的属性默认不会被自动推断,这是 MobX 的约定。如果你有内部辅助字段不想暴露为响应式,加个下划线前缀就行。注意:makeAutoObservable 不能用在有超类的类上。子类继承时会报错,因为自动推断无法正确处理继承链上的属性。这种场景必须用 makeObservable。装饰器:语法糖,有前提条件class TodoStore { @observable todos = []; @observable loading = false; @computed get unfinishedCount() { return this.todos.filter(t => !t.done).length; } @action addTodo(text) { this.todos.push({ text, done: false }); }}装饰器写法最直观,属性和类型标注在一起,读起来很清晰。但有两个前提条件经常被忽略:必须配置编译器。TypeScript 需要在 tsconfig.json 中启用 experimentalDecorators,Babel 需要 @babel/plugin-proposal-decorators。没配对就报错,配错了行为也可能不一致。传统装饰器 vs 标准装饰器。MobX 6 同时支持两种,但行为不同。传统装饰器(legacy)用 @observable x = value,标准装饰器(Stage 3)用 @observable accessor x = value。2023 年之后 TC39 确定的标准写法是后者,传统写法已被废弃。另外,用了装饰器不代表可以省掉 makeObservable。MobX 6 中,即使类上写了 @observable,构造函数里还是得调用 makeObservable(this),否则装饰器不生效。这一点很多人踩坑。怎么选?| 场景 | 推荐 | 原因 ||------|------|------|| 新项目,没有装饰器依赖 | makeAutoObservable | 最少代码,自动推断 || 需要浅层观察或排除某些属性 | makeAutoObservable + 覆盖 | 覆盖写法比全手动省事 || 有继承关系的 Store | makeObservable | makeAutoObservable 不支持继承 || 需要 observable.shallow / observable.ref | makeObservable | 精细控制每个属性 || 项目已有装饰器配置,团队习惯 | 装饰器 + makeObservable(this) | 不用为了迁移而迁移 |一句话:默认用 makeAutoObservable,碰到继承或需要精细控制时换 makeObservable,装饰器只在已有项目依赖时继续用。追问makeAutoObservable 和 makeObservable 可以混用吗?不行。一个类里只能选一个。但 makeAutoObservable 的第二个参数本身就是覆盖写法,本质上就是 makeAutoObservable + 部分手动标注的混合体。装饰器写的老项目怎么迁移到 makeAutoObservable?分两步:先把 @observable / @computed / @action 标注转为 makeObservable(this, {...}) 的写法,确认行为一致后,再考虑能否简化为 makeAutoObservable。迁移过程中最容易漏的是 makeObservable(this) 这个调用——老代码用了装饰器但忘记在构造函数里调用它,迁移时同样容易忘。observable.shallow 和 observable 有什么区别?observable 会递归地把对象内部所有嵌套属性都变成可观察的,observable.shallow 只观察第一层引用。对于数组,observable.shallow 只在数组引用变化时触发响应,数组内部元素的属性变化不会触发。列表渲染场景用 observable.shallow 能显著减少不必要的更新。为什么 makeAutoObservable 不支持继承?因为自动推断在遍历 this 上的所有属性时,无法区分哪些是从父类继承的、哪些是子类自己的。父类可能已经对自己的属性做了 makeAutoObservable,子类再调一次就会重复处理。所以 MobX 直接禁止了这种用法,有继承需求的必须用 makeObservable 显式标注。写段代码// 实际项目中常见的模式:// 基类用 makeObservable,子类也用 makeObservable + overrideclass BaseStore { loading = false; constructor() { makeObservable(this, { loading: observable, }); }}class TodoStore extends BaseStore { todos = []; constructor() { super(); makeObservable(this, { loading: override, // 继承的属性用 override todos: observable.shallow, addTodo: action.bound, // 自动绑定 this }); } addTodo(text) { this.todos.push({ text, done: false }); }}
前端阅读 05月27日 18:11

MobX 6 有哪些主要变化和新特性?

MobX 6 最核心的变化有三个:强制 action 修改状态、引入 makeObservable/makeAutoObservable 替代装饰器、Proxy 成为底层实现。装饰器仍然支持但不再是推荐方式,配合 mobx-undecorate 工具可以一键迁移旧代码。追问makeObservable 和 makeAutoObservable 有什么区别?makeObservable 需要你手动标注每个成员的类型(observable、computed、action),适合需要精细控制的场景。makeAutoObservable 自动推断:getter 标记为 computed,方法标记为 action,其余字段标记为 observable。但 makeAutoObservable 不能用于子类,也不能标注被忽略的字段——这种时候用 makeObservable。class Store { count = 0; constructor() { // 二选一 makeObservable(this, { count: observable, doubled: computed, increment: action }); // 或者 makeAutoObservable(this); } get doubled() { return this.count * 2; } increment() { this.count++; }}为什么 MobX 6 强制要求在 action 中修改状态?MobX 5 可以在 action 外部直接修改 observable,这导致状态变更难以追踪,调试时无法定位是哪段代码改了数据。MobX 6 默认 enforceActions: "always",所有状态修改必须在 action 内进行,这样每次状态变更都有明确的调用栈,DevTools 也能清晰展示变更来源。如果迁移时不想立刻改,可以临时配置 enforceActions: "never" 回退到旧行为。装饰器为什么不再是推荐方式?TC39 装饰器提案经历了多次语法变更,Legacy Decorators(Babel experimentalDecorators)一直不是标准。MobX 6 选择拥抱标准:用 makeObservable 在 constructor 中声明式标注成员类型,这在任何 JS 环境下都能运行,不需要 Babel 插件或 TypeScript 实验性配置。如果你仍想用装饰器,MobX 6 也支持,但需要在 constructor 里补一句 makeObservable(this) 才能生效。MobX 5 升级到 6 的迁移步骤是什么?先升级到 MobX 5 的最新小版本,解决所有废弃警告安装 MobX 6,运行 npx mobx-undecorate 自动迁移代码TypeScript 项目设置 useDefineForClassFields: true;Babel 项目设置 ["@babel/plugin-proposal-class-properties", { "loose": false }]每个有 MobX 成员的类,在 constructor 中调用 makeObservable(this) 或 makeAutoObservable(this)用 configure({ enforceActions: "always" }) 启用严格模式替换已移除的 API:decorate() 用 makeObservable 替代,isObservableObject 用 isObservable 替代Proxy 在 MobX 6 中扮演什么角色?MobX 5 默认也用 Proxy,但可以降级到 getter/setter 实现。MobX 6 将 Proxy 作为唯一的响应式实现(IE 和旧版 React Native 除外,需配置 useProxies: "never")。Proxy 的好处是能拦截更多操作(如动态添加属性),Observable 对象的行为更接近普通对象,不需要额外的 API 来处理属性增删。写段代码import { makeAutoObservable } from "mobx";class TodoStore { todos = []; constructor() { makeAutoObservable(this); } get pending() { return this.todos.filter(t => !t.done); } addTodo(text) { this.todos.push({ text, done: false }); } toggle(id) { this.todos[id].done = !this.todos[id].done; }}