做前端的朋友,很多都经历过这种纠结:手上有一批状态,想用不可变数据来保证数据的安全和回退,又想让界面自动跟着数据变。于是把 Immutable.js 和 MobX 放在一起用,结果发现并不是那么顺畅。今天就用大白话,把这段“相处难题”拆解一下,再给几套能落地的方案。
一、问题从哪来?
1.1 不可变数据到底香在哪
不可变数据的核心很简单:数据一旦创建,就不能再改。你想改,就得创建一个新的副本。这个特性带来的好处很实际。比如,你可以放心地到处传递数据,不用担心某个角落把数据偷偷改了。再比如,做撤销功能的时候,只需要把之前的旧版本存起来,随时回退,不用拷贝来拷贝去,因为每次修改都是新引用,旧版本还在那儿。
Immutable.js 这个库就是专门干这个的。它内部的 Map、List 用了一套“结构共享”的算法,每次更新虽然会产生新对象,但没变的部分会被原样复用,所以内存和性能都还不错。
1.2 MobX 的脾气可不一样
MobX 走的是另外一个极端:它希望你直接改数据,改完之后屏幕自动刷新。它的底层像是有个“感应器”,你访问了某个属性,它就记下来“这个东西被谁用了”;等你改这个属性,它立刻通知所有用到它的地方去更新。
所以,MobX 喜欢的是普通对象、数组、原生 Map 这种“活”的数据结构。你用 observable 包一下,然后直接 obj.name = "张三",界面就变了,非常爽快。
1.3 两个“性格”撞在一起
一个要“不能改”,一个要“直接改”。这本来看起来水火不容,但很多人想:“我能不能把 Immutable.js 放进 MobX 里,一个管历史,一个管响应?” 理论上可以,但实际操作时你会发现,MobX 的“眼睛”盯不住 Immutable 对象内部的变化。
MobX 追踪的是“属性访问”。你写 store.list,它能捕捉到;但写 store.list.get("a"),它只能捕捉到 list 这个整体,没法进一步知道你在意的是里面的 a 还是 b。这就是冲突的本质:Immutable 的读取方式靠的是 get(key),而 MobX 没法把这个 key 解析成它熟悉的依赖。
二、到底哪里不兼容
2.1 一个典型的“粒度丢失”场景
假设页面上有两个区域,一个显示水果 1 的名字,一个显示水果 2 的名字。用普通对象,我只改水果 1,水果 2 那边的代码完全不用动。但如果用 Immutable.Map 存数据,情况就不一样了。
2.2 用代码看看问题
// 技术栈:JavaScript (ES6+) + MobX 6 + Immutable.js
import { observable, computed, autorun } from "mobx";
import { Map as ImmutableMap } from "immutable";
// 初始化一个可观察的 store,里面用 Immutable.Map 存状态
const store = observable({
list: ImmutableMap({
"1": "苹果",
"2": "香蕉"
})
});
// 分别创建两个计算值,一个关心 key=1,一个关心 key=2
const first = computed(() => store.list.get("1"));
const second = computed(() => store.list.get("2"));
// 用两个计数器记录它们各自被重新执行的次数
let firstRun = 0;
let secondRun = 0;
autorun(() => {
first.get(); // 这里读的是 key=1
firstRun++;
});
autorun(() => {
second.get(); // 这里读的是 key=2
secondRun++;
});
// 只更新 key=1 的值
store.list = store.list.set("1", "橙子");
// 输出结果
console.log("first 触发了", firstRun, "次"); // 2 次,因为它关心的数据确实变了
console.log("second 触发了", secondRun, "次"); // 也变成 2 次,但它关心的 key=2 根本没变
看到了吗?second 被白白触发了一次。原因就是两个 autorun 都追踪到了 store.list 这个整体。Immutable.set 返回了新 Map,store.list 的引用变了,所以哪怕内部的 "2" 原封未动,second 也会跟着重新跑。
如果数据量小还好,当你的页面有几十个字段,每次只改一个,结果所有依赖都刷新,性能就拖垮了。而且这还不是最头痛的,更麻烦的是,一些人会尝试把 Immutable 对象放进 observable 的深层结构里,结果 MobX 会尝试去代理 Immutable 的内部方法,行为变得非常诡异,比如你调 set 之后界面不更新,或者干脆报错。
三、怎么解决?—— 实用方案
方案有很多,关键看你的核心需求是什么。但是有一条基本原则:别把两套体系混着用在一个对象里。要么以 MobX 为主,Immutable 只在边界出现;要么用一些技巧把不可变数据“翻译”成可观察的。
3.1 方案一:干脆全都用 MobX 可观察对象
如果你要的是“界面自动更新”和“修改方便”,那就别用 Immutable.js。直接用普通对象或 observable.map 来存状态。这是最简单、最符合 MobX 本性的做法。
// 技术栈:JavaScript (ES6+) + MobX 6(不使用 Immutable.js)
import { observable, autorun } from "mobx";
// 直接用普通对象作为可观察状态
const store = observable({
list: {
"1": "苹果",
"2": "香蕉"
}
});
autorun(() => {
console.log("水果1是:", store.list["1"]);
});
autorun(() => {
console.log("水果2是:", store.list["2"]);
});
// 只改 key=1,第二个 autorun 不会触发
store.list["1"] = "橙子";
这个方法的好处是,MobX 的深层观察会帮你精确到每个字段。改 store.list["1"] 只有关心 "1" 的代码会自动跑,性能稳稳的。缺点也很明显:你失去了不可变数据带来的历史回溯、时间旅行这些特性。如果你的项目根本用不到这些,那这就是首选。
3.2 方案二:用 observable.box 把整个 Immutable 对象包起来
如果你特别舍不得 Immutable.js 的数据结构和不可变历史,也可以把整个 Immutable.Map 放进一个 observable.box 里,每次更新就“换一整个新 Map”。
// 技术栈:JavaScript (ES6+) + MobX 6 + Immutable.js
import { observable, autorun } from "mobx";
import { Map as ImmutableMap } from "immutable";
// 用一个 box 来存放整个 immutable 对象
const box = observable.box(ImmutableMap({
"1": "苹果",
"2": "香蕉"
}));
autorun(() => {
const map = box.get();
console.log("水果1是:", map.get("1"));
console.log("水果2是:", map.get("2"));
});
// 更新的时候,必须重新生成一个新 Map 并放回去
box.set(box.get().set("1", "橙子"));
这个方案就像一个“快递箱”。MobX 只认识箱子,不认识箱子里的东西。箱子换一下,它就知道里头全变了。所以这个方案的粒度比方案一粗,所有关心这个箱子的人都得跟着跑。如果数据量不大、更新不频繁,用起来也没问题。
要注意的是,千万不要试图直接修改 box 里那个 Map 的内部,因为它不可变。你只能通过 box.set 把整个新对象塞进去。
3.3 方案三:字段级拆分 + 不可变快照(桥接模式)
这是我认为最优雅的一个折中:内部用多个小的 observable.box 来存储每个字段,让 MobX 能细粒度感知;对外提供两种读取方式,一个是字段级读取,一个是生成完整的 Immutable 快照。这样你需要的时候,照样能拿到不可变数据;不需要的时候,也能享受精确更新。
// 技术栈:JavaScript (ES6+) + MobX 6 + Immutable.js
import { observable, computed } from "mobx";
import { Map as ImmutableMap } from "immutable";
class ImmutableFriendlyStore {
constructor(initialMap) {
// 每个字段单独存一个 observable.box
this._boxes = {};
initialMap.forEach((value, key) => {
this._boxes[key] = observable.box(value);
});
}
// 字段级读取:哪个字段变了,只有依赖这个字段的人会更新
select(key) {
const targetBox = this._boxes[key];
if (!targetBox) return undefined;
return targetBox.get();
}
// 字段级更新:直接修改对应的 box
update(key, value) {
if (this._boxes[key]) {
this._boxes[key].set(value);
}
}
// 整体快照:生成一个全新的 Immutable.Map,方便做历史记录或传给外部系统
get snapshot() {
let map = ImmutableMap();
for (const key in this._boxes) {
// 注意:遍历所有字段,所以这个方法会依赖所有字段
map = map.set(key, this._boxes[key].get());
}
return map;
}
}
// 使用示例
const store = new ImmutableFriendlyStore(ImmutableMap({
"1": "苹果",
"2": "香蕉"
}));
// 用 autorun 演示字段级依赖
import { autorun } from "mobx";
autorun(() => {
// 只关注 key=1,key=2 变了不会触发我
console.log("我关心水果1:", store.select("1"));
});
autorun(() => {
// 只关注 key=2
console.log("我关心水果2:", store.select("2"));
});
// 修改 key=1
store.update("1", "橙子");
// 此时第二个 autorun 不会执行,因为它的 box 没变
// 随时取完整不可变快照
const history = store.snapshot;
console.log(history.toJS()); // { 1: "橙子", 2: "香蕉" }
这个方案的思路是:把不可变数据当成“出口”而不是“存储”。内部状态用 MobX 的盒子管理,等你需要传递一份给别人、或者要存档时,再组装成 Immutable 对象。这样,你和外部系统之间依然能享受不可变带来的好处,而内部交互则全部走响应式,性能也说得过去。
当然,这个方案也有代价,就是每个字段多了一层 box 的开销,字段特别多、嵌套特别深时,手动拆分会比较累。如果项目里有非常多的嵌套结构,建议用类似方式封装一个通用的 Map 类,别在业务代码里到处拆。
四、应用场景
- 场景一:后台管理系统,状态很多但逻辑简单。比如表单、列表筛选。直接上方案一,MobX 的原生 observable 足够,不要引入 Immutable,省心省力。
- 场景二:需要和时间旅行、撤销重做打交道。比如画板、编辑器、游戏状态。方案二或三都行。如果状态结构稳定且不大,方案二最简单;如果字段很多,希望界面别频繁刷新,方案三更合适。
- 场景三:有团队规范必须用 Immutable.js 传数据。比如后端返回的数据,或者项目里的公共状态库。建议内部用 MobX,在数据进入或离开边界时转换成 Immutable,也就是方案三的思路。
五、优缺点对比
方案一:全部用 MobX observable
- 优点:开发体验最顺,性能最优,符合 MobX 设计,代码量少。
- 缺点:没有不可变快照,历史记录得自己额外做,需要养成不随手改数据的习惯。
方案二:用 box 包整个 Immutable
- 优点:简单直接,保留了 Immutable 的整体结构,切换成本低。
- 缺点:粒度粗,只关心一个字段的人也会跟着整体刷新;嵌套数据时还是坑。
方案三:字段级拆分 + 不可变快照
- 优点:兼顾了细粒度响应和不可变导出;适合对性能有要求又离不开 Immutable 的项目。
- 缺点:结构复杂,需要自己维护映射关系;多层嵌套时需要额外封装工具类,否则代码会很啰嗦。
六、注意事项
- 不要把 Immutable 对象直接塞进
observable的嵌套对象里。MobX 不会真正观察 Immutable 内部,却可能会尝试转换它,导致行为不可控。 - 不要试图通过
reaction去监听immutableMap.get("key")的返回值,因为 MobX 无法追踪get方法的内部。你只会追踪到对这个immutableMap引用的访问。 - 更新数据时一定要走“替换引用”的路。比如
store.list = store.list.set(...),不要想着去改原对象,因为 Immutable 本来就不允许。 - 如果选择方案三,注意
snapshot会依赖所有字段,频繁获取快照可能让所有字段相关的计算都失效。建议只在需要快照的地方使用它,普通界面读取用select(key)。 - 团队协作时统一规范。同一份状态,别一会儿写成 Immutable,一会儿改成 observable,时间长了就是灾难。
- 测试要覆盖“字段级更新”。写单元测试时,特别验证只有依赖对应字段的 computed 或 autorun 会触发,避免因为结构变化导致意外联动。
七、总结
不可变数据和 MobX 本质上不是死对头,只是它们对“数据变化”的理解完全不同。Immutable 强调“生成新版本”,MobX 强调“修改并通知”。要把它们放在一起用,关键在于“谁主谁次”:要么完全以 MobX 为主,Immutable 只在边界输出;要么用 box 或字段级拆分的桥接技术,让 MobX 能感知到每一个微小变化,同时又能生成不可变快照。没有银弹,只有权衡。希望上面的思路和代码能帮你少踩几个坑,在实际项目里找到适合自己的那一套。
评论
围绕“不可变数据与MobX冲突处理:当Immutable.js遇上可观察对象”参与讨论