Immer 如何实现不可变更新?

用可变写法描述更新,用 Proxy 记录变化,最后通过写时复制生成结构共享的新状态

先给出面试版结论:Immer 解决的是嵌套状态不可变更新代码冗长、容易漏复制和误改原对象的问题。它把原状态包装成 Proxy draft,拦截对 draft 的读写;读取时不会立刻深拷贝,某个节点第一次被修改时才为它准备浅副本,并在结束阶段为该节点到根的路径生成新引用。未修改分支继续复用旧引用,这叫结构共享。

Immer 不是 React 内置功能,也不是深拷贝工具。React 负责保存和调度状态;开发者或 Immer 负责产生引用正确的新状态。

1. 前因:React 为什么强调不可变更新?

React 的 state 可以保存对象和数组,但更新时不应该直接修改已有状态:

const [user, setUser] = useState({
  name: "Tom",
  address: { city: "深圳" },
});

function moveToGuangzhou() {
  user.address.city = "广州"; // ❌ 修改旧状态
  setUser(user);              // 仍然传入原引用
}

React 会用 Object.is 判断新 state 与当前 state 是否相同:

Object.is(previousUser, nextUser); // true

引用没有改变,React 可能跳过这次更新。即使其他更新碰巧让页面重新渲染,直接修改旧对象仍会破坏状态快照,使历史 render、闭包、memo 和并发渲染面对同一个被篡改的对象。

正确的不可变更新是创建新版本:

setUser((user) => ({
  ...user,
  address: {
    ...user.address,
    city: "广州",
  },
}));

这里不是深拷贝整棵对象,而是复制从根到修改点的路径:

旧 user                    新 user
   │                          │
   ├─ name ───────────────────┘  原始值直接复用

   └─ 旧 address          新 address
          │                   │
          └─ 深圳             └─ 广州

不可变更新带来三个重要性质:

  • 旧状态仍是可靠的历史快照;
  • React 和 memo 可以用廉价的引用比较发现变化;
  • 没变化的子树可以安全复用,避免整棵深拷贝。

2. 痛点:手写不可变更新为什么麻烦?

状态层级越深,需要展开的路径越长:

const nextState = {
  ...state,
  user: {
    ...state.user,
    profile: {
      ...state.user.profile,
      address: {
        ...state.user.profile.address,
        city: "广州",
      },
    },
  },
};

这种代码的问题不是不能工作,而是:

  • 样板代码掩盖了真正的业务修改;
  • 漏复制任何一层都可能修改旧状态;
  • 数组的插入、删除、排序需要额外转换写法;
  • 一次修改多个嵌套位置时可读性迅速下降。

Immer 让同一更新写成:

const nextState = produce(state, (draft) => {
  draft.user.profile.address.city = "广州";
});

代码看起来像 mutation,但修改的是临时 draft,而不是 state

3. Immer 的基本用法

import { produce } from "immer";

const nextState = produce(baseState, (draft) => {
  draft.user.name = "Jerry";
});

produce 接收:

  1. 基础状态 baseState
  2. producer 函数;
  3. producer 中可修改的 draft

如果 producer 没有产生实际变化,Immer 可以直接返回原状态:

const nextState = produce(baseState, () => {});

Object.is(nextState, baseState); // true

如果发生变化,则返回新根引用:

const nextState = produce(baseState, (draft) => {
  draft.count += 1;
});

Object.is(nextState, baseState); // false

在 React 中可以这样使用:

function Profile() {
  const [user, setUser] = useState({
    name: "Tom",
    address: { city: "深圳" },
  });

  function updateCity() {
    setUser((currentUser) =>
      produce(currentUser, (draft) => {
        draft.address.city = "广州";
      }),
    );
  }

  return <button onClick={updateCity}>{user.address.city}</button>;
}

新状态依赖旧状态时仍然应使用函数式更新,Immer 不会替代 React 的更新队列语义。

4. 本质:Proxy、写时复制和 finalize

Immer 的核心流程可以简化为:

baseState
    ↓ 创建代理
draft Proxy
    ↓ 执行 producer
Proxy 拦截 get / set / delete 等操作

记录修改节点,按需创建浅副本
    ↓ finalize
修改路径组成新状态,未修改分支复用旧引用

nextState

4.1 Proxy draft

Immer 为可草稿化的数据创建代理。下面只是教学模型,并非真实源码:

function createDraft(base) {
  return new Proxy(base, {
    get(target, property) {
      // 返回值;嵌套对象需要时再提供对应 draft
      return Reflect.get(target, property);
    },
    set(target, property, value) {
      // 标记变化、创建浅副本,并把修改写入副本
      return true;
    },
    deleteProperty(target, property) {
      // 记录删除
      return true;
    },
  });
}

draft 不是最终状态,也不应该逃逸到 produce 外长期使用。它只在 producer 执行期间代表“下一版本的可编辑视图”。

4.2 写时复制(copy-on-write)

Immer 不会一开始就深拷贝所有数据。只读某个属性时通常不需要复制;第一次真正修改某个节点时,才创建该节点的浅副本:

const nextState = produce(state, (draft) => {
  console.log(draft.settings.theme); // 只读
  draft.user.name = "Jerry";         // 写入 user
});

可以抽象成:

读 settings → 不复制 settings
写 user.name → 浅复制 user
结束生成结果 → 根节点也需要新引用

“第一次写入才复制”就是写时复制。对同一个 draft 节点继续修改,通常写在已有副本上,不会每次赋值都重新复制一份。

4.3 finalize

producer 结束后,Immer 会完成草稿:

  • 没变化的节点返回原对象;
  • 变化节点使用副本;
  • 把嵌套 draft 转成最终对象;
  • 让修改路径上的祖先指向新的子节点;
  • 根据配置冻结结果,并可生成 patches。

真实实现还需要处理草稿状态、父子关系、赋值与删除标记、Map/Set 支持、冻结和错误情况。面试中说清设计链路即可,不要把简化 Proxy 示例当作完整源码。

5. 为什么被修改节点的祖先也必须复制?

假设只复制 address

const nextAddress = {
  ...state.user.address,
  city: "广州",
};

如果不创建新的 user 和根对象,就只能把旧 user.address 改成 nextAddress,这仍然修改了旧状态。正确结果必须从修改点一路产生新引用:

const nextUser = {
  ...state.user,
  address: nextAddress,
};

const nextState = {
  ...state,
  user: nextUser,
};

因此更准确的说法不是“哪个属性变了就复制哪个属性”,而是:

被修改对象第一次写入时产生副本;最终从该对象到根节点的祖先路径都必须形成新引用。

6. 结构共享到底是什么?

const state = {
  user: {
    address: { city: "深圳" },
  },
  settings: {
    theme: "dark",
  },
};

const nextState = produce(state, (draft) => {
  draft.user.address.city = "广州";
});

最终引用关系为:

nextState !== state; // true
nextState.user !== state.user; // true
nextState.user.address !== state.user.address; // true

nextState.settings === state.settings; // true

结构如下:

state                         nextState
  │                               │
  ├─ user A                       ├─ user B
  │    └─ address A               │    └─ address B
  │         └─ "深圳"             │         └─ "广州"
  │                               │
  └──────── settings A ───────────┘
              未修改,安全共享

结构共享不是深拷贝:深拷贝会让所有对象都产生新引用;Immer 只让变化路径产生新引用。

策略变化路径未变化分支浅比较友好主要成本
直接修改旧引用旧引用破坏快照和正确性
深拷贝新引用也全是新引用能发现根变化,但无法识别未变分支遍历和复制整棵数据
结构共享新引用复用旧引用代理、记录、路径复制

7. 共享原引用,原对象后来变化怎么办?

这是结构共享最关键的前提:被共享的对象必须继续保持不可变。

nextState.settings === state.settings; // true

从普通 JavaScript 引用关系看,如果绕过 Immer 修改共享对象:

state.settings.theme = "light";

那么 statenextState 都会观察到变化,因为它们指向同一个对象。这不是结构共享能自动隔离的情况,而是破坏了不可变约定。

正确做法是继续产生下一版本:

const thirdState = produce(nextState, (draft) => {
  draft.settings.theme = "light";
});

thirdState.settings !== nextState.settings; // true
thirdState.user === nextState.user;         // true

Immer 的自动冻结可以帮助尽早发现外部 mutation。在通常配置下,生成结果会被冻结;严格模式中直接赋值可能抛出错误。但冻结是防御机制,不是结构共享的本质,也不应该依赖它替代正确的数据边界。

8. 数组更新为什么更自然?

不用 Immer 时,数组更新通常用返回新数组的方法:

setTodos((todos) =>
  todos.map((todo) =>
    todo.id === id ? { ...todo, done: true } : todo,
  ),
);

使用 Immer 后可以操作 draft:

setTodos((todos) =>
  produce(todos, (draft) => {
    const todo = draft.find((item) => item.id === id);
    if (todo) todo.done = true;
  }),
);

添加、删除和排序也可以使用可变数组 API:

const nextTodos = produce(todos, (draft) => {
  draft.push(newTodo);

  const index = draft.findIndex((todo) => todo.id === deletedId);
  if (index !== -1) draft.splice(index, 1);

  draft.sort((a, b) => a.priority - b.priority);
});

pushsplicesort 修改的是 draft。Immer 最终返回新数组,不会修改原数组。

9. 在 React 中的几种集成方式

方式一:useState 配合 produce

setProject((project) =>
  produce(project, (draft) => {
    draft.tasks[id].done = true;
  }),
);

方式二:柯里化 producer

只给 produce 传 producer,可以得到一个接收基础状态的更新函数:

setProject(
  produce((draft) => {
    draft.tasks[id].done = true;
  }),
);

方式三:Reducer 中使用

function reducer(state, action) {
  return produce(state, (draft) => {
    switch (action.type) {
      case "todo/toggled": {
        const todo = draft.todos.find((item) => item.id === action.id);
        if (todo) todo.done = !todo.done;
        break;
      }
      case "todo/added":
        draft.todos.push(action.todo);
        break;
      default:
        break;
    }
  });
}

也可以使用第三方 use-immer 等封装。它们都不是 React 核心 API。

Redux Toolkit 的 createSlice 内部集成了 Immer,所以 reducer 可以写出看似直接修改 state 的语句;它修改的是 Immer draft,而不是 Redux 当前状态。

10. producer 的返回值规则

通常在 draft 上修改,不显式返回新状态:

produce(state, (draft) => {
  draft.count += 1;
});

也可以不修改 draft,直接返回完整替代值:

produce(state, () => ({ count: 0 }));

但不要既修改 draft 又返回另一个新对象:

produce(state, (draft) => {
  draft.count += 1;
  return { count: 100 }; // ❌ 两种更新来源冲突
});

箭头函数还有一个常见陷阱:赋值表达式自身有返回值。

// 容易被解释成既修改 draft 又返回表达式结果
produce(state, (draft) => (draft.count += 1));

// 使用代码块,明确不返回
produce(state, (draft) => {
  draft.count += 1;
});

11. React、Immer 和 memo 如何协作?

假设父组件把两个状态分支分别传给子组件:

const UserPanel = memo(function UserPanel({ user }) {
  return <div>{user.name}</div>;
});

const SettingsPanel = memo(function SettingsPanel({ settings }) {
  return <div>{settings.theme}</div>;
});

只更新 user.name

const nextState = produce(state, (draft) => {
  draft.user.name = "Jerry";
});

引用结果为:

nextState.user !== state.user;
nextState.settings === state.settings;

因此默认浅比较能够得到:

UserPanel 的 user prop 改变
    → 需要重新渲染

SettingsPanel 的 settings prop 相同
    → 满足其他 bailout 条件时可以跳过

结构共享为浅比较提供了可靠变化信号,但不等于自动优化所有组件。组件自身 state、Context、其他 props 和组件边界仍然会影响是否渲染。

12. 性能成本与适用边界

Immer 的收益是可维护性和正确性,不是无条件比手写更新更快。它会产生:

  • Proxy 创建与属性拦截成本;
  • 草稿状态和修改记录;
  • 变化路径的浅复制;
  • finalize 遍历;
  • 自动冻结成本;
  • patches 开启后的额外记录。

适合使用 Immer 的场景:

  • 状态嵌套较深;
  • 一次操作会修改多个关联位置;
  • reducer 逻辑复杂;
  • 数组插入、删除、排序较多;
  • 团队容易因手写展开而产生 mutation bug。

不一定需要 Immer 的场景:

  • 状态只有一两层,展开语法已经足够清晰;
  • 高频动画、拖拽或大规模数据更新对性能非常敏感;
  • 数据本来可以规范化或拆成更小的 state;
  • 状态包含大量不适合代理的特殊对象。

优化顺序通常是先改善状态结构,例如 state 下移、扁平化或按实体 ID 规范化,再判断 Immer 是否能提高复杂更新的可读性。不要用 Immer 掩盖不合理的数据模型。

13. 边界与常见陷阱

陷阱一:修改了 draft 之外的原对象

const externalUser = state.user;

produce(state, (draft) => {
  externalUser.name = "Jerry"; // ❌ 绕过 draft,修改原对象
});

必须沿 draft 路径修改:

produce(state, (draft) => {
  draft.user.name = "Jerry";
});

陷阱二:以为 Immer 会深拷贝

未修改分支会复用引用。如果业务需要完全隔离的副本,Immer 的结构共享并不等同于深克隆。

陷阱三:把 draft 泄漏出去

let leakedDraft;

produce(state, (draft) => {
  leakedDraft = draft;
});

// ❌ produce 结束后不要继续使用 draft

draft 是临时代理,生命周期受当前 producer 管理。需要对外返回数据时应使用最终状态;调试或特殊转换应使用 Immer 提供的相应 API,而不是保存 draft。

陷阱四:把外部新对象赋给 draft 后继续当草稿修改

const newTodo = { title: "学习 Immer", done: false };

const nextState = produce(state, (draft) => {
  draft.todos.push(newTodo);
  newTodo.done = true;
});

来自外部、刚插入 draft 的对象不应被想当然地视作原生 draft。更稳妥的做法是在插入前构造好最终值,或插入后通过明确的数据流程处理,避免混用外部引用。

陷阱五:特殊对象的支持范围

普通对象和数组是典型 draft 对象。MapSet 需要按 Immer 版本和配置启用支持;类实例通常需要显式声明可草稿化。Date、DOM 节点等对象不能假设会像普通对象一样被安全代理。

陷阱六:嵌套 produce 忘记接收返回值

内部 produce 返回新结果,不会自动回写外层 draft:

produce(state, (draft) => {
  draft.user = produce(draft.user, (userDraft) => {
    userDraft.name = "Jerry";
  });
});

如果可以,直接修改外层 draft 路径通常更简单。

陷阱七:以为结构共享永远安全

只有旧版本不再被修改时,共享引用才安全。绕过 Immer 修改任一共享对象会同时污染多个版本。自动冻结只能帮助发现部分错误,不能替代不可变约定。

14. Immer、手写展开和深拷贝怎么选?

对比项手写不可变更新Immer深拷贝后修改
写法显式复制路径修改 draft先复制整棵数据
未变分支可结构共享自动结构共享通常不共享
嵌套更新可读性层级深时下降通常较好表面简单但成本高
运行时开销可控、通常最低Proxy、finalize、冻结与整棵数据规模相关
出错风险可能漏复制可能误用 draft/外部引用特殊类型、原型和性能问题
React 浅比较友好友好未变分支也变引用,优化较差

选择标准不是“是否喜欢赋值语法”,而是状态复杂度、更新频率、数据规模、团队可维护性和性能测量结果。

15. 面试官递进追问

1. Immer 解决了什么问题?

它减少手写嵌套不可变更新的样板代码和 mutation 风险,让开发者用修改 draft 的方式生成不可变新状态。

为什么问: 检查是否先讲设计动机,而不是只背 Proxy。

2. Immer 的本质是什么?

Proxy 草稿、写时复制和结构共享。Proxy 记录修改,第一次写入时按需准备副本,finalize 时让变化路径产生新引用,未变分支复用旧引用。

为什么问: 检查能否建立输入到输出的完整链路。

3. Immer 是深拷贝吗?

不是。深拷贝通常复制整棵数据;Immer 只复制变化路径,未变化分支继续共享。

为什么问: 检查是否理解结构共享带来的性能与引用语义。

4. 为什么修改叶子节点时祖先也要产生新引用?

如果祖先保持旧引用,就必须修改旧祖先才能让它指向新子节点,这会破坏旧状态。要保留旧快照,修改点到根的路径都要形成新版本。

为什么问: 检查是否真正理解不可变数据,而不是把它等同于复制一个属性。

5. 未修改分支复用原引用不会互相影响吗?

前提是共享对象保持不可变。若绕过 Immer 修改它,新旧版本都会受影响;因此后续更新也必须生成新版本,自动冻结可帮助发现违规修改。

为什么问: 检查是否理解结构共享成立的约束。

6. Immer 为什么不在开始时直接深拷贝?

深拷贝会遍历和复制未修改数据,破坏未变分支的引用稳定性。写时复制只为真正变化的路径付费,也更利于 React 浅比较 bailout。

为什么问: 检查设计取舍和成本模型。

7. Immer 会让 React 自动不重渲染吗?

不会。它提供正确的新旧引用,使浅比较能够识别变化与未变化;是否重渲染还取决于 state、props、Context、组件结构和 memo 条件。

为什么问: 检查是否混淆状态生产工具和 React 协调机制。

8. producer 为什么不能既改 draft 又返回新对象?

这代表两个冲突的下一状态来源:一个来自 draft 修改,一个来自显式替换。应该二选一,使结果语义明确。

为什么问: 检查实际 API 边界。

9. Immer 有什么性能成本?

Proxy 拦截、草稿元数据、路径复制、finalize、冻结和可选 patches 都有成本。复杂低频更新通常值得;超大数据或高频路径应测量,并考虑扁平化或手写定向更新。

为什么问: 检查是否会做工程权衡,而不是认为抽象没有代价。

10. React 为什么不内置 Immer?

React 的职责是 UI 状态调度与渲染,不规定应用数据必须通过某一种工具更新。简单状态不需要额外抽象,复杂状态也可以选择 Immer、Reducer 或外部状态库;自动代理或深复制还会引入语义和性能成本。

为什么问: 检查对框架边界的理解。

16. 常见错误回答

  • “Immer 监听变量变化,然后复制整个 state。” 不准确。它拦截 draft 操作,按需复制变化路径,不是监听任意变量,也不是整棵深拷贝。
  • “改哪个属性就只复制它所属的对象。” 不完整。该对象到根节点的祖先路径也要形成新引用。
  • “没变化的分支复用引用,所以可以继续直接修改。” 错误。结构共享要求所有已发布版本保持不可变。
  • “用了 Immer 就不需要函数式 setState。” 错误。新状态依赖旧状态时仍应使用 React 的函数式更新。
  • “Immer 能保证组件不重渲染。” 错误。它提供浅比较友好的引用,不能替代正确组件边界和 memo 条件。
  • “Proxy 会自动代理任何 JavaScript 对象。” 错误。不同数据类型有不同支持边界。
  • “Immer 一定比展开语法性能好。” 错误。它优化的是复杂更新的表达和结构共享,运行时本身也有成本。

17. 最终记忆框架

核心关键词:不可变快照、Proxy draft、写时复制、变化路径、结构共享、finalize

一句话本质:Immer 把对临时 Proxy 草稿的可变操作,编译成对状态变化路径的不可变复制。

旧状态 base
    ↓ Proxy
可修改的 draft
    ↓ 记录写操作
变化节点按需浅复制
    ↓ finalize
新状态 next
├─ 变化路径:新引用
└─ 未变分支:共享旧引用

一分钟回答:说明它解决嵌套不可变更新的样板代码,用 Proxy、写时复制和结构共享生成新状态,并强调不是深拷贝。

三分钟回答:补充为什么祖先路径也要复制、未变分支为什么可以共享,以及 React 如何利用引用变化和 Object.is

十分钟回答:从 React 状态快照讲到 draft 生命周期、copy-on-write、finalize、自动冻结、数组更新、producer 返回规则、特殊对象、性能成本,以及 Immer、手写展开和深拷贝的取舍。

这套理解还能迁移到 Redux Toolkit、持久化数据结构、Copy-on-Write 文件系统、React memo bailout、时间旅行调试和并发渲染。最重要的因果链是:

旧状态不能被修改 → 变化路径必须产生新引用 → 未变数据没必要复制 → 通过结构共享同时保留快照与浅比较能力 → Immer 用 Proxy 自动完成这套机械工作。