数据不可变性
纯函数要求不修改入参,落到 JS 上就是引用类型的不可变处理。
基本原则:对于引用类型数据,能拷贝就不直接修改。
一、const 不等于不可变
const arr = [1, 2, 3];
arr.push(4); // ✅ 完全合法
arr = []; // ❌ TypeError
const 约束的是变量绑定(不能重新赋值),不是值本身。要冻结值需要 Object.freeze:
const obj = Object.freeze({ a: 1, nested: { b: 2 } });
obj.a = 99; // 静默失败(严格模式下抛 TypeError)
obj.nested.b = 99; // ✅ 改成功了!Object.freeze 是浅冻结
深冻结需要自己递归:
function deepFreeze(obj) {
for (const value of Object.values(obj)) {
if (value && typeof value === 'object' && !Object.isFrozen(value)) deepFreeze(value);
}
return Object.freeze(obj);
}
生产环境不要滥用 deepFreeze
递归冻结在大对象上开销可观,而且冻结后的对象无法再被任何库修改(有些库会在内部临时改属性)。常见做法是只在开发环境启用,用它来提前暴露"谁偷偷改了数据",生产环境靠约定和代码审查。
二、数据的增量更新
不可变更新的核心手法:沿着要修改的路径逐层浅拷贝,其余部分直接复用引用。
const state = {
user: { name: 'Tom', profile: { age: 18, city: '北京' } },
list: [1, 2, 3],
};
// ✅ 只有路径上的 state / user / profile 是新对象,list 仍然是同一个引用
const next = {
...state,
user: {
...state.user,
profile: { ...state.user.profile, age: 19 },
},
};
next.list === state.list; // true ← 结构共享,没被复制
next.user.profile === state.user.profile; // false ← 路径上的节点是新的
数组的不可变操作速查:
| 操作 | ❌ 变异写法 | ✅ 不可变写法 |
|---|---|---|
| 追加 | arr.push(x) | [...arr, x] |
| 头插 | arr.unshift(x) | [x, ...arr] |
| 删除下标 i | arr.splice(i, 1) | arr.filter((_, idx) => idx !== i) |
| 更新下标 i | arr[i] = x | arr.with(i, x)(ES2023)或 arr.map((v, idx) => idx === i ? x : v) |
| 排序 | arr.sort(fn) | arr.toSorted(fn)(ES2023)或 [...arr].sort(fn) |
| 反转 | arr.reverse() | arr.toReversed() 或 [...arr].reverse() |
ES2023 新增的
toSorted/toReversed/toSpliced/with是四个不变异的版本,Node 20+ 和现代浏览器均已支持。
展开运算符是浅拷贝
const copy = { ...state };
copy.user.name = 'Jerry'; // 原 state.user.name 也变了!
{...obj} 只复制第一层,嵌套对象仍然是同一个引用。这是"我明明拷贝了为什么还互相影响"的头号原因。
需要真正的深拷贝时,用浏览器/Node 原生的 structuredClone():它能处理循环引用、Map / Set / Date / RegExp / 二进制数据,比 JSON.parse(JSON.stringify()) 可靠得多(后者会丢失 undefined、函数、Symbol,把 Date 变成字符串,遇到循环引用直接抛错)。注意 structuredClone 无法克隆函数和 DOM 节点。
三、为什么前端需要不可变
不可变数据最直接的收益是:判断"变没变"从深比较变成了一次 ===。
// 可变:必须深比较才知道有没有变化,O(n)
if (!deepEqual(prevProps.data, nextProps.data)) rerender();
// 不可变:引用不同就是变了,O(1)
if (prevProps.data !== nextProps.data) rerender();
这正是以下机制成立的前提:
- React 的
React.memo/PureComponent/useMemo依赖项,全部是浅比较(Object.is)。直接state.list.push(x)后setState(state.list)不会触发重渲染——引用没变; - Redux 要求 reducer 返回新对象,否则
connect/useSelector察觉不到更新; - 时间旅行调试 / 撤销重做:每次变更都留下一个完整快照,因为共享了未变的部分,内存开销可控(这与命令模式的 undo 是两种思路);
- 并发安全:数据不会在读取过程中被别人改掉。
Vue 是个例外:它靠响应式代理拦截变更,因此可以直接修改数据。这是两条不同的技术路线——Vue 追踪"谁改了什么",React 比较"前后是否是同一个引用"。
四、结构共享与 Immutable.js
手写展开运算符在层级深、更新频繁时会很啰嗦。Immutable.js 用持久化数据结构解决这个问题。
它的底层是 Trie(字典树) 的变体——HAMT(Hash Array Mapped Trie):对象/数组被组织成一棵多叉树(分支因子通常是 32),更新时只重建从根到目标节点这一条路径上的节点,其余子树直接共享引用。
root root' ← 新根
/ | \ / | \
A B C 更新 C.x A B C' ← 只有这条路径是新建的
/ \ / \ (共享) / \
… … x y x' y ← y 仍是同一个对象
因此一次更新的复杂度是 O(log₃₂ n)(树高很矮,实际接近常数),而不是深拷贝的 O(n)。
import { Map } from 'immutable';
const map1 = Map({ a: 1, b: { c: 2 } });
const map2 = map1.set('a', 99);
map1.get('a'); // 1,原对象未被修改
map2.get('a'); // 99
map1.get('b') === map2.get('b'); // true,未修改的部分是共享的
这和 Git 的存储模型是同一个思想:Git 快照保存的是文件索引而不是文件本身,变化的文件获得新的存储空间和新索引,不变的文件永远待在原地。
Immutable.js 的代价
它引入了一套自己的数据类型(Map / List / Record),与原生对象不通用:
- 每次跨边界都要
toJS()/fromJS(),而这两个操作是深度遍历,很贵; - 第三方库、
JSON.stringify、解构语法都不认识它; - 类型定义和调试体验较差(DevTools 里看到的是内部结构)。
它已经进入低维护状态,新项目基本不再选用——除非确实要处理超大规模、超高频更新的数据集。
五、基于 Proxy 的 Immer.js
Immer 是目前的主流方案(Redux Toolkit 内置)。它的思路是:让你用可变的写法,产出不可变的结果。
import produce from 'immer';
const next = produce(state, draft => {
draft.user.profile.age = 19; // 看起来是直接改
draft.list.push(4); // push 也可以用
});
// state 一动没动,next 是全新的不可变对象,且未修改的部分与 state 共享
"逐层浅拷贝"是 Immer 实现数据共享的关键。 极简版 produce:
function produce(base, recipe) {
// 预定义一个 copy 副本
let copy;
const baseHandler = {
get(obj, key) {
// 读的时候优先读 copy(如果已经写过),否则读 base
return (copy || obj)[key];
},
set(obj, key, value) {
// 第一次写入时才创建副本 —— 写时复制(copy-on-write)
if (!copy) copy = { ...base };
copy[key] = value;
return true;
},
};
// 被 proxy 包装后的 base 记为 draft
const draft = new Proxy(base, baseHandler);
recipe(draft);
// 没有发生过写操作时 copy 不存在,直接返回 base(引用不变,上层的 === 比较就能跳过更新)
return Object.freeze(copy || base);
}
这个极简版说明了原理,但离真实实现还差三件事
- 嵌套对象:
draft.user.profile.age = 19需要在get时为子对象也返回一个 draft 代理,并在子对象被修改后逐层向上创建副本。真实的 Immer 用一棵 draft 树 + 父指针来完成这件事。 - 数组与 Map/Set:需要各自的 handler,
push这类方法会触发多次set(包括length)。 - finalize 阶段:递归收敛所有被修改的 draft,未被修改的分支直接复用原引用——这才是"结构共享"真正发生的地方。
这版实现自己带了一个副作用
return Object.freeze(copy || base) 在 recipe 没有发生写操作时,冻结的是调用方传进来的 base 本身——一个以"不可变"为目标的函数,反而修改了入参的状态。
更麻烦的是它会让后续调用直接崩溃:
const base = { a: 1 };
produce(base, () => {}); // 空 recipe → base 被冻结了
produce(base, d => { d.a = 2; }); // ❌ TypeError: 'set' on proxy: trap returned truish
// for property 'a' which exists in the proxy target
// as a non-configurable and non-writable data property
原因是 Proxy 的不变量约束:目标对象上存在一个不可写、不可配置的属性时,set trap 不允许报告"写入成功"。
修正很简单——只冻结自己新建的副本,不碰入参:
return copy ? Object.freeze(copy) : base;
真实的 Immer 则是把冻结做成可配置项(setAutoFreeze()),且只冻结产出的结果。
为什么 Immer 赢了 Immutable.js:它操作的始终是原生对象,没有学习成本、没有 toJS() 的转换开销、所有现有工具链都能直接用。代价是依赖 Proxy(不支持 IE),以及大量小更新时性能略逊于 HAMT。
六、方案选型
| 场景 | 推荐 |
|---|---|
| 简单的、层级浅的状态更新 | 展开运算符 + ES2023 数组方法 |
| Redux / 复杂嵌套状态 | Immer(Redux Toolkit 已内置) |
| 需要一份彻底独立的副本 | structuredClone() |
| 开发期排查"谁改了我的数据" | deepFreeze,仅开发环境启用 |
| 超大数据集、极高频更新 | Immutable.js(先做性能测量再决定) |
