做前端的朋友,很多都经历过这种纠结:手上有一批状态,想用不可变数据来保证数据的安全和回退,又想让界面自动跟着数据变。于是把 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 能感知到每一个微小变化,同时又能生成不可变快照。没有银弹,只有权衡。希望上面的思路和代码能帮你少踩几个坑,在实际项目里找到适合自己的那一套。