数据不可变性

纯函数要求不修改入参,落到 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]
删除下标 iarr.splice(i, 1)arr.filter((_, idx) => idx !== i)
更新下标 iarr[i] = xarr.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);
}

这个极简版说明了原理,但离真实实现还差三件事

  1. 嵌套对象:draft.user.profile.age = 19 需要在 get 时为子对象也返回一个 draft 代理,并在子对象被修改后逐层向上创建副本。真实的 Immer 用一棵 draft 树 + 父指针来完成这件事。
  2. 数组与 Map/Set:需要各自的 handler,push 这类方法会触发多次 set(包括 length)。
  3. 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(先做性能测量再决定)

回到 👉 函数式编程基础 | 函数组合

上次更新:
贡献者: Joe