Immer 如何实现不可变更新?
用可变写法描述更新,用 Proxy 记录变化,最后通过写时复制生成结构共享的新状态
先给出面试版结论:Immer 解决的是嵌套状态不可变更新代码冗长、容易漏复制和误改原对象的问题。它把原状态包装成 Proxy draft,拦截对 draft 的读写;读取时不会立刻深拷贝,某个节点第一次被修改时才为它准备浅副本,并在结束阶段为该节点到根的路径生成新引用。未修改分支继续复用旧引用,这叫结构共享。
Immer 不是 React 内置功能,也不是深拷贝工具。React 负责保存和调度状态;开发者或 Immer 负责产生引用正确的新状态。
1. 前因:React 为什么强调不可变更新?
React 的 state 可以保存对象和数组,但更新时不应该直接修改已有状态:
React 会用 Object.is 判断新 state 与当前 state 是否相同:
引用没有改变,React 可能跳过这次更新。即使其他更新碰巧让页面重新渲染,直接修改旧对象仍会破坏状态快照,使历史 render、闭包、memo 和并发渲染面对同一个被篡改的对象。
正确的不可变更新是创建新版本:
这里不是深拷贝整棵对象,而是复制从根到修改点的路径:
不可变更新带来三个重要性质:
- 旧状态仍是可靠的历史快照;
- React 和
memo可以用廉价的引用比较发现变化; - 没变化的子树可以安全复用,避免整棵深拷贝。
2. 痛点:手写不可变更新为什么麻烦?
状态层级越深,需要展开的路径越长:
这种代码的问题不是不能工作,而是:
- 样板代码掩盖了真正的业务修改;
- 漏复制任何一层都可能修改旧状态;
- 数组的插入、删除、排序需要额外转换写法;
- 一次修改多个嵌套位置时可读性迅速下降。
Immer 让同一更新写成:
代码看起来像 mutation,但修改的是临时 draft,而不是 state。
3. Immer 的基本用法
produce 接收:
- 基础状态
baseState; - producer 函数;
- producer 中可修改的
draft。
如果 producer 没有产生实际变化,Immer 可以直接返回原状态:
如果发生变化,则返回新根引用:
在 React 中可以这样使用:
新状态依赖旧状态时仍然应使用函数式更新,Immer 不会替代 React 的更新队列语义。
4. 本质:Proxy、写时复制和 finalize
Immer 的核心流程可以简化为:
4.1 Proxy draft
Immer 为可草稿化的数据创建代理。下面只是教学模型,并非真实源码:
draft 不是最终状态,也不应该逃逸到 produce 外长期使用。它只在 producer 执行期间代表“下一版本的可编辑视图”。
4.2 写时复制(copy-on-write)
Immer 不会一开始就深拷贝所有数据。只读某个属性时通常不需要复制;第一次真正修改某个节点时,才创建该节点的浅副本:
可以抽象成:
“第一次写入才复制”就是写时复制。对同一个 draft 节点继续修改,通常写在已有副本上,不会每次赋值都重新复制一份。
4.3 finalize
producer 结束后,Immer 会完成草稿:
- 没变化的节点返回原对象;
- 变化节点使用副本;
- 把嵌套 draft 转成最终对象;
- 让修改路径上的祖先指向新的子节点;
- 根据配置冻结结果,并可生成 patches。
真实实现还需要处理草稿状态、父子关系、赋值与删除标记、Map/Set 支持、冻结和错误情况。面试中说清设计链路即可,不要把简化 Proxy 示例当作完整源码。
5. 为什么被修改节点的祖先也必须复制?
假设只复制 address:
如果不创建新的 user 和根对象,就只能把旧 user.address 改成 nextAddress,这仍然修改了旧状态。正确结果必须从修改点一路产生新引用:
因此更准确的说法不是“哪个属性变了就复制哪个属性”,而是:
被修改对象第一次写入时产生副本;最终从该对象到根节点的祖先路径都必须形成新引用。
6. 结构共享到底是什么?
最终引用关系为:
结构如下:
结构共享不是深拷贝:深拷贝会让所有对象都产生新引用;Immer 只让变化路径产生新引用。
7. 共享原引用,原对象后来变化怎么办?
这是结构共享最关键的前提:被共享的对象必须继续保持不可变。
从普通 JavaScript 引用关系看,如果绕过 Immer 修改共享对象:
那么 state 和 nextState 都会观察到变化,因为它们指向同一个对象。这不是结构共享能自动隔离的情况,而是破坏了不可变约定。
正确做法是继续产生下一版本:
Immer 的自动冻结可以帮助尽早发现外部 mutation。在通常配置下,生成结果会被冻结;严格模式中直接赋值可能抛出错误。但冻结是防御机制,不是结构共享的本质,也不应该依赖它替代正确的数据边界。
8. 数组更新为什么更自然?
不用 Immer 时,数组更新通常用返回新数组的方法:
使用 Immer 后可以操作 draft:
添加、删除和排序也可以使用可变数组 API:
push、splice、sort 修改的是 draft。Immer 最终返回新数组,不会修改原数组。
9. 在 React 中的几种集成方式
方式一:useState 配合 produce
方式二:柯里化 producer
只给 produce 传 producer,可以得到一个接收基础状态的更新函数:
方式三:Reducer 中使用
也可以使用第三方 use-immer 等封装。它们都不是 React 核心 API。
Redux Toolkit 的 createSlice 内部集成了 Immer,所以 reducer 可以写出看似直接修改 state 的语句;它修改的是 Immer draft,而不是 Redux 当前状态。
10. producer 的返回值规则
通常在 draft 上修改,不显式返回新状态:
也可以不修改 draft,直接返回完整替代值:
但不要既修改 draft 又返回另一个新对象:
箭头函数还有一个常见陷阱:赋值表达式自身有返回值。
11. React、Immer 和 memo 如何协作?
假设父组件把两个状态分支分别传给子组件:
只更新 user.name:
引用结果为:
因此默认浅比较能够得到:
结构共享为浅比较提供了可靠变化信号,但不等于自动优化所有组件。组件自身 state、Context、其他 props 和组件边界仍然会影响是否渲染。
12. 性能成本与适用边界
Immer 的收益是可维护性和正确性,不是无条件比手写更新更快。它会产生:
- Proxy 创建与属性拦截成本;
- 草稿状态和修改记录;
- 变化路径的浅复制;
- finalize 遍历;
- 自动冻结成本;
- patches 开启后的额外记录。
适合使用 Immer 的场景:
- 状态嵌套较深;
- 一次操作会修改多个关联位置;
- reducer 逻辑复杂;
- 数组插入、删除、排序较多;
- 团队容易因手写展开而产生 mutation bug。
不一定需要 Immer 的场景:
- 状态只有一两层,展开语法已经足够清晰;
- 高频动画、拖拽或大规模数据更新对性能非常敏感;
- 数据本来可以规范化或拆成更小的 state;
- 状态包含大量不适合代理的特殊对象。
优化顺序通常是先改善状态结构,例如 state 下移、扁平化或按实体 ID 规范化,再判断 Immer 是否能提高复杂更新的可读性。不要用 Immer 掩盖不合理的数据模型。
13. 边界与常见陷阱
陷阱一:修改了 draft 之外的原对象
必须沿 draft 路径修改:
陷阱二:以为 Immer 会深拷贝
未修改分支会复用引用。如果业务需要完全隔离的副本,Immer 的结构共享并不等同于深克隆。
陷阱三:把 draft 泄漏出去
draft 是临时代理,生命周期受当前 producer 管理。需要对外返回数据时应使用最终状态;调试或特殊转换应使用 Immer 提供的相应 API,而不是保存 draft。
陷阱四:把外部新对象赋给 draft 后继续当草稿修改
来自外部、刚插入 draft 的对象不应被想当然地视作原生 draft。更稳妥的做法是在插入前构造好最终值,或插入后通过明确的数据流程处理,避免混用外部引用。
陷阱五:特殊对象的支持范围
普通对象和数组是典型 draft 对象。Map、Set 需要按 Immer 版本和配置启用支持;类实例通常需要显式声明可草稿化。Date、DOM 节点等对象不能假设会像普通对象一样被安全代理。
陷阱六:嵌套 produce 忘记接收返回值
内部 produce 返回新结果,不会自动回写外层 draft:
如果可以,直接修改外层 draft 路径通常更简单。
陷阱七:以为结构共享永远安全
只有旧版本不再被修改时,共享引用才安全。绕过 Immer 修改任一共享对象会同时污染多个版本。自动冻结只能帮助发现部分错误,不能替代不可变约定。
14. Immer、手写展开和深拷贝怎么选?
选择标准不是“是否喜欢赋值语法”,而是状态复杂度、更新频率、数据规模、团队可维护性和性能测量结果。
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 草稿的可变操作,编译成对状态变化路径的不可变复制。
一分钟回答:说明它解决嵌套不可变更新的样板代码,用 Proxy、写时复制和结构共享生成新状态,并强调不是深拷贝。
三分钟回答:补充为什么祖先路径也要复制、未变分支为什么可以共享,以及 React 如何利用引用变化和 Object.is。
十分钟回答:从 React 状态快照讲到 draft 生命周期、copy-on-write、finalize、自动冻结、数组更新、producer 返回规则、特殊对象、性能成本,以及 Immer、手写展开和深拷贝的取舍。
这套理解还能迁移到 Redux Toolkit、持久化数据结构、Copy-on-Write 文件系统、React memo bailout、时间旅行调试和并发渲染。最重要的因果链是:
旧状态不能被修改 → 变化路径必须产生新引用 → 未变数据没必要复制 → 通过结构共享同时保留快照与浅比较能力 → Immer 用 Proxy 自动完成这套机械工作。

