文章

Immutable不可变数据原理深度解析

深入解析不可变数据的概念、结构共享机制、Immer.js原理及React/Vue中的应用

Immutable不可变数据原理深度解析

一句话概括

不可变数据 = 数据一旦创建就不再原地修改,任何”改”都是产出一个新副本。配合结构共享只复制变化路径上的节点,让深嵌套更新也能既快又不会弄脏原数据。这就是 React 的 setState、Redux 的 reducer、Vue 的响应式能”一眼看出变了没”的底层基础。

核心知识点

1. 什么是不可变数据?一句话 + 一个例子就够了

不可变数据的本质:永远不碰原对象,每次变更都返回一个新引用

1
2
3
4
5
6
7
8
9
// ❌ 可变:原地改,引用不变
const user = { name: 'Alice', age: 25 };
user.age = 26;
console.log(user); // { name: 'Alice', age: 26 },原对象已被污染

// ✅ 不可变:返回新对象,原对象纹丝不动
const updated = { ...user, age: 26 };
console.log(user.age);    // 25 — 原对象完好
console.log(updated.age); // 26

面试官要听的不是你背定义,而是你明白它和”引用比较”之间的关系。

2. 为什么需要不可变数据?—— 引用相等是命门

React 的 PureComponent、memo、useMemo、useEffect 的依赖数组,全是靠 === 判断变了没。不可变数据让 === 判断变得 O(1):

1
2
3
4
5
const prev = { user: { name: 'Alice' } };
const next = { ...prev, user: { ...prev.user, name: 'Bob' } };

prev === next;         // false ✅ 根变了
prev.user === next.user; // false ✅ user 变了

如果原地修改 prev.user.name = 'Bob'prev === nexttrue,React 直接跳过渲染 — 这几乎是每个新手都会踩的坑。

3. 结构共享:只拷贝”变了的”,其余全复用

深拷贝整个对象树太浪费。结构共享的思路是:沿修改路径逐层浅拷贝,路径以外的节点直接复用原引用

1
2
3
4
5
6
7
8
9
10
11
12
13
const base = {
  a: { x: 1, y: 2 },
  b: { x: 3, y: 4 },
};

// "修改 a.x 为 10"
const next = {
  a: { ...base.a, x: 10 }, // a 是新对象
  b: base.b,               // b 直接复用
};

base.a === next.a; // false — 沿路径被拷贝
base.b === next.b; // true  — 没碰到,直接共享

结构共享把更新成本从 O(n) 降到 O(path depth),这才是 Immer 和 Immutable.js 性能不掉链子的核心。

4. Immer 原理:Proxy + Copy-on-Write

Immer 让你”假装”直接修改,实际在 Proxy 的掩护下默默记录变更路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { produce } from 'immer';

const state = { user: { name: 'Alice', tags: ['js'] } };

const nextState = produce(state, draft => {
  draft.user.name = 'Bob';
  draft.user.tags.push('react');
});

// state 纹丝不动
console.log(state.user.name);      // 'Alice'
console.log(state.user.tags);      // ['js']
// 未触及的节点结构共享
console.log(state.user === nextState.user); // false(user 在修改路径上)

简化版核心思路:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function produce(base, recipe) {
  const dirty = new WeakMap(); // 记录哪些对象被"碰过"

  const createProxy = target => new Proxy(target, {
    get(obj, key) {
      const val = obj[key];
      if (typeof val === 'object' && val !== null) return createProxy(val);
      return val;
    },
    set(obj, key, value) {
      dirty.set(obj, true);
      obj[key] = value;
      return true;
    },
  });

  const draft = createProxy(base);
  recipe(draft);

  // finalize: 脏的浅拷贝,干净的复用原引用
  const finalize = obj =>
    dirty.has(obj)
      ? Object.assign(Array.isArray(obj) ? [...obj] : { ...obj },
          ...Object.keys(obj).map(k => {
            const v = obj[k];
            return typeof v === 'object' && v !== null ? { [k]: finalize(v) } : { [k]: v };
          }))
      : obj;

  return finalize(base);
}

这段简化版省略了数组 push 等方法的代理,但 Proxy → 标记脏节点 → 沿路径拷贝这条链路一览无余。

5. React 中的不可变数据实践

1
2
3
4
5
6
7
8
9
10
// ❌ 直接 mutate:引用未变,React 直接不渲染
setState(prev => { prev.count = 1; return prev; });

// ✅ 不可变:新引用触发渲染
setState(prev => ({ ...prev, count: prev.count + 1 }));

// ✅ Immer 语法糖:写着爽,跑起来也正确
import { useImmer } from 'use-immer';
const [state, updateState] = useImmer({ count: 0, list: [] });
updateState(draft => { draft.count += 1; draft.list.push(42); });

其实你每天都在用

1. React 的 setState 你每次 setState({ ...prev, key }) 就是在做不可变更新。React 内部 Object.is(prev, next) 一比较,新引用 → 触发渲染。

2. Array 的 map/filter/reduce 这些方法都返回新数组,从不碰原数组。arr.map(x => x * 2) 就是不可变思想的日常实践。

3. Redux / Zustand 的 reducer Redux 要求 reducer 是纯函数 — 输入 state + action,输出新 state,绝不能原地改。你不遵守的话时间旅行就直接崩了。

4. Vue 的 computed computed 依赖的 ref/reactive 变了,computed 返回新值。虽然 Vue 的响应式允许直接改,但 Vuex/Pinia 的 store 设计本质也是在推崇不可变更新。

5. Git 的 commit 每次 commit 不修改历史 snapshot,而是创建一个新的。整个 Git 的 object store 就是结构共享的典范 — 没变的文件直接复用 blob hash。

常见误解

❌ 误区 1:”展开运算符 {...obj} 是深拷贝” 它是浅拷贝。嵌套对象仍共享引用,改内层照样会污染原对象。真要深拷贝不丢引用关系,得用递归或 structuredClone

❌ 误区 2:”Immer 内部做了深拷贝” Immer 恰恰不做深拷贝。它靠 Proxy 记录哪些节点被修改,只浅拷贝脏节点路径,其余全部共享原引用。深拷贝就走样了,性能反而崩。

❌ 误区 3:”Vue 3 的 Proxy 可以随意直接改,不需要不可变” Vue 3 确实能检测到直接修改,但 不等于 你应该随意 mutate。大规模应用里,不可变更新让数据流可预测、更好调试;想在 Vuex/Pinia 里拆 action 做时间旅行,必须走不可变路线。

❌ 误区 4:”Object.freeze 就是不可变数据” Object.freeze 只是浅冻结第一层,不让修改当前对象属性。它不负责深层冻结,也不帮你生成新版本 — 它是”锁住”,不是”不可变更新机制”。Immer 自动 freeze 结果是附加保护,不是不可变本身。

一句话总结

不可变数据的核心不是”不能改”,而是”改了就换” — 换一个引用的代价换来 O(1) 变更检测和可追溯的状态历史,这才是现代前端框架性能优化和调试体验的根。

本文由作者按照 CC BY 4.0 进行授权