Immer.js 核心原理深度解析:为什么「可变写法」能得到「不可变结果」
Immer 最精髓的底层逻辑 —— 明明我写的是 draft.xxx = xxx 这种「直接修改」的可变代码,为什么最终能生成符合 Redux 要求的「不可变新数据」,而且原数据完全不会被改动。
前置共识(先明确2个基础,理解会更顺畅)
- Immer 的核心输入输出:我们永远是调用
produce(originalState, (draft) => { 这里写可变修改 }),入参是「原数据」,返回值是「不可变新数据」,原数据永远只读、永不修改。 - Immer 的核心口号:
Write mutable code, get immutable data(写可变的代码,得到不可变的数据),这不是噱头,是真实的执行逻辑。
一、Immer 实现该能力的「三大核心基石」(一句话核心原理)
Immer 能做到「可变写法 + 不可变结果」,核心靠的是 3个核心技术的组合拳,缺一不可,这也是 Immer 底层的全部核心,没有任何黑魔法:
核心原理一句话总结:Immer 对「原数据」创建一个 Proxy 代理对象(命名为 draft),让开发者在「代理对象」上做任意「可变修改」;同时通过 浅拷贝(Shallow Copy) 做「按需惰性复制」,只复制被修改的层级/属性,不碰未修改的部分;最后通过 修改追踪 记录所有对 draft 的改动,基于原数据+追踪的改动,生成一个「全新的不可变数据」,原数据全程无任何修改。
三个核心基石的优先级和作用:
- Proxy 代理机制 → 核心载体,所有修改的「入口」
- 修改追踪机制 → 核心大脑,记录所有修改的「内容」
- 按需浅拷贝机制 → 核心效率,生成新数据的「方式」
二、基石一:Proxy 代理对象(draft 到底是什么?最关键的核心)
这是最核心的一点,也是大家最容易误解的点:你在回调函数里修改的 draft,绝对不是「原数据」本身,甚至不是原数据的「拷贝」。
什么是 draft?
draft 是 Immer 基于「原数据(originalState)」创建的一个 ES6 Proxy 代理对象,也叫「草稿对象」。
- Proxy 是 ES6 原生的「对象代理器」,作用是:对一个目标对象创建一个「代理」,所有对代理对象的「读/写/改」操作,都会先经过 Proxy 的拦截器,再决定是否执行。
- 对开发者而言:
draft看起来、用起来和原数据「一模一样」,你可以像操作普通对象一样操作它,比如draft.name='李四'、draft.user.info.age=20、draft.todos[0].done=true,完全没有学习成本。 - 对 Immer 而言:
draft是一个「监控器」,你对它的每一次修改,都会被 Proxy 拦截并记录下来,你修改的只是「代理」,不是原数据,也不是真实的副本。
关键结论
你写的所有「可变修改代码」,修改的不是原数据,而是原数据的 Proxy 代理对象 draft,这是「原数据永远不被修改」的根本保证。
三、基石二:惰性的「按需浅拷贝」(Immer 最牛的优化,为什么性能好+内存省)
这是 Immer 能兼顾「简洁」和「性能」的核心,也是很多人理解的误区:Immer 不会一上来就对原数据做「深拷贝」,也不会做「全量浅拷贝」,而是做「按需浅拷贝(Lazy Shallow Copy)」,也叫「惰性复制」。
先区分:浅拷贝 vs 深拷贝 vs 按需浅拷贝
- 深拷贝:把原数据的所有层级都复制一份全新的,内存占用高,性能差,比如 lodash 的
cloneDeep,Immer 绝对不会这么做; - 全量浅拷贝:一上来就用
{...original}复制原数据的第一层,未修改的层级也会被复制,没必要; - 按需浅拷贝(Immer 做法):只有当某个属性/层级「被修改」时,才对这个层级做一次浅拷贝,生成一个新的对象/数组,未被修改的属性/层级,会直接复用原数据的引用。
举个通俗的例子(多层嵌套对象)
假设原数据是多层嵌套的,这是我们最常见的业务场景:
const original = {
user: { info: { name: "张三", age: 20 } },
todos: [{ id: 1, done: false }, { id: 2, done: true }]
}
当你写 draft.user.info.name = "李四" 时:
- Immer 只会对「被修改的层级」做浅拷贝:只拷贝
original.user→ 再拷贝original.user.info; original.todos因为没被修改,会直接复用原数据的引用,不会做任何拷贝;- 最终生成的新数据里,
todos属性和原数据的todos是同一个引用,user是新引用,user.info也是新引用。
核心价值
- 极致的性能:只拷贝被修改的部分,内存占用极低,比手动写扩展运算符的不可变更新性能更好;
- 符合不可变规范:只要「被修改的层级」是新的引用,整个数据就是不可变的(Redux 的浅比较只看引用地址);
- 无冗余代码:开发者不用手动写
{...state, user: {...state.user}}这种嵌套拷贝,Immer 帮你自动完成。
四、基石三:精准的「修改追踪」(Immer 知道你改了什么)
你在 draft 上做的所有修改,Immer 都能「精准记录」,这是生成最终新数据的依据,这个能力由 Proxy 代理 + 内部的修改记录表 共同实现。
追踪的核心逻辑
Immer 在创建 draft 代理对象时,会同时初始化一个「修改追踪器」,当你执行任何修改操作(比如 draft.a = b、draft.c.push(d))时:
- Proxy 会拦截到这次修改操作,知道你「修改了哪个属性、修改前的值是什么、修改后的值是什么」;
- 追踪器会把这次修改「记录在册」,只记录「被改动的属性/层级」,未改动的部分不会有任何记录;
- 对于嵌套对象,会「递归追踪」:比如你改
draft.user.info.name,追踪器会记录user→info→name这个完整的修改路径。
关键特点
- 追踪是「精准的」:只记录真实的修改,无任何冗余;
- 追踪是「无感知的」:开发者完全不用关心追踪逻辑,只需要写自己的修改代码即可。
五、Immer 完整执行流程(从调用到返回,一步不落,彻底看懂)
结合上面的三大核心基石,我们把 Immer 的 produce 函数执行的完整生命周期拆成 5个步骤,用最开始的嵌套对象为例,你会彻底明白「可变写法」是如何变成「不可变结果」的,这是理解 Immer 的重中之重。
示例准备(固定原数据和修改逻辑)
import { produce } from "immer"
// 原数据(不可变,只读)
const originalState = {
user: { info: { name: "张三", age: 20 } },
todos: [{ id: 1, done: false }, { id: 2, done: true }]
}
// 调用Immer,写可变修改
const newState = produce(originalState, (draft) => {
draft.user.info.name = "李四" // 可变修改1:修改嵌套属性
draft.todos[0].done = true // 可变修改2:修改数组项的属性
})
步骤 1:初始化 → 基于「原数据」创建「draft 代理对象」
调用 produce(originalState, callback) 时,Immer 第一件事就是:
- 对
originalState这个原数据,创建一个 Proxy 代理对象 draft; - draft 是原数据的「镜像」,结构和原数据完全一致,但不是拷贝,只是一个「访问入口」;
- 此时,draft 还没有任何修改,追踪器是空的。
步骤 2:执行回调 → 在「代理对象」上做「可变修改」
Immer 执行我们传入的回调函数 (draft) => { ... },此时:
- 我们写的
draft.user.info.name = "李四"、draft.todos[0].done = true这些代码,本质是对「draft代理对象」的修改; - 这些修改不会直接作用于原数据,也不会直接修改任何真实的对象;
- 每一次修改,都会被 Proxy 拦截,然后被「修改追踪器」记录下来。
步骤 3:按需拷贝 → 对「被修改的层级」执行「惰性浅拷贝」
这是 Immer 最核心的优化步骤,只在修改发生时执行拷贝:
- 当你修改
draft.user.info.name时,Immer 会先对originalState.user做一次「浅拷贝」,生成一个新的user对象; - 再对新的
user.info做一次「浅拷贝」,生成一个新的info对象; - 把
name修改为「李四」,赋值给这个新的info对象; - 当你修改
draft.todos[0].done时,Immer 会先对originalState.todos做一次「浅拷贝」生成新数组,再对数组的第0项做浅拷贝生成新对象,修改done为true; - 未被修改的层级(比如
user.info.age、todos[1]),直接复用原数据的引用,不做任何拷贝。
步骤 4:生成新数据 → 合并「原数据」+「修改记录」,创建不可变新对象
当回调函数执行完毕后,Immer 会基于「原数据」和「修改追踪器」里的所有修改记录,执行最终的合并:
- 对于「未被修改」的属性/层级:直接把原数据的引用「复用」到新数据中;
- 对于「被修改」的属性/层级:把步骤3中「按需浅拷贝」生成的「新对象/新数组」赋值到新数据中;
- 最终生成一个 全新的、不可变的状态对象 newState。
步骤 5:返回结果 → 原数据完好无损,新数据符合不可变规范
Immer 把生成的 newState 作为返回值返回,此时有3个绝对成立的核心结果:
- 原数据 originalState 完全不变:没有任何修改,还是最初的样子,只读属性得到保证;
- 新数据 newState 是全新的不可变对象:被修改的层级都有「新的引用地址」,未修改的层级复用原引用,完美符合 Redux 的不可变要求;
- newState 和 originalState 是「结构共享」的:未修改的部分复用引用,极大节省内存和提升性能。
六、核心关键:Immer 的「结构共享」特性(为什么性能极佳,必懂)
这是 Immer 能成为 Redux 标配的重要原因之一,也是很多开发者忽略的点:Immer 生成的新数据,和原数据之间是「结构共享」的。
什么是结构共享?
结构共享 = 新数据中,未被修改的属性/层级,直接复用原数据的引用;只有被修改的属性/层级,才是新创建的对象/数组。
比如上面的例子中:
newState.user→ 新引用(因为被修改了);newState.user.info→ 新引用(因为被修改了);newState.user.info.age→ 原引用(值是20,没被修改);newState.todos→ 新引用(数组被修改了);newState.todos[1]→ 原引用(没被修改)。
结构共享的核心价值
- 极致的内存利用率:不用全量拷贝,只生成修改的部分,内存占用极低;
- 完美适配 React/Redux 的浅比较:React 的
memo、Redux 的 reducer 都是基于「浅比较(只对比引用地址)」判断是否更新,只要被修改的层级引用变了,就能精准触发更新,未修改的部分不会触发无效更新。
七、你一定会踩的坑:2个「绝对不能做」的操作
所有 Immer 的错误用法,根源都是 对 draft 这个「Proxy 代理对象」的理解不到位,结合上面的原理,我们能推导出 2 个「绝对禁止」的操作,这也是 Redux 官方反复强调的点,记住这两个规则,能避开 99% 的 Immer 错误:
❌ 禁止操作 1:在回调中「手动 return draft」
// 错误写法 ❌
produce(originalState, draft => {
draft.user.info.name = "李四"
return draft // 绝对不要return draft!
})
错误原因(原理层面): draft 是 Immer 创建的 Proxy 代理对象,不是「真实的不可变数据」,它只是一个「草稿监控器」。如果手动 return draft,相当于把「代理对象」作为返回值,这个对象不满足不可变规范,Redux 无法感知状态变化,还会导致各种诡异的 bug。
✅ 正确做法:修改 draft 后,什么都不要 return,Immer 会自动帮你生成并返回正确的不可变新数据。
❌ 禁止操作 2:在回调中「既修改 draft,又 return 一个新对象」
// 错误写法 ❌
produce(originalState, draft => {
draft.user.info.name = "李四"
return { name: "王五" } // 既改draft,又return新值
})
错误原因(原理层面): Immer 的执行逻辑是「二选一」:
- 如果你「修改 draft 但不 return」:Immer 会基于 draft 的修改生成新数据;
- 如果你「不修改 draft,只 return 新值」:Immer 会直接把你 return 的新值作为结果;
- 如果你「既改 draft 又 return」:Immer 会完全忽略你对 draft 的所有修改,只返回你手动写的新值,你的修改等于白做。
✅ 正确做法:二选一即可,推荐「只修改 draft,不 return」,这是 Immer 的最佳实践。
八、补充:Immer 对数组的「可变操作」为什么也能生成不可变数组?
你大概率也会有这个疑问:数组的 push/splice/shift 这些方法是「直接修改原数组」的可变方法,为什么在 draft 上执行 draft.todos.push({id:3}),最终能得到一个「不可变的新数组」?
答案还是基于上面的原理,只是针对数组做了适配:
- 数组也是对象,draft 中的数组也是 Proxy 代理数组;
- 当你执行
draft.todos.push()时,Proxy 会拦截这个push操作,记录下来; - Immer 会对原数组做「按需浅拷贝」,生成一个新数组;
- 在新数组上执行
push操作,最终返回这个新数组; - 原数组完全不变,新数组是不可变的。
✅ 结论:在 Immer 的 draft 中,你可以放心使用 push/splice/shift/pop 等所有数组「可变方法」,Immer 都会帮你转换成「不可变的数组更新」。
总结
- 核心本质:Immer 是「语法糖」,底层是 ES6 Proxy + 浅拷贝 + 修改追踪,没有任何黑魔法;
- draft 是什么:draft 是「原数据的 Proxy 代理对象」,不是原数据,也不是拷贝,是修改的入口;
- 为什么可变写法可行:你修改的是「代理对象 draft」,不是原数据,所有修改都会被拦截和记录;
- 为什么能生成不可变结果:Immer 基于修改记录,对「被修改的层级」做按需浅拷贝,生成全新的对象/数组,未修改的部分复用原引用,最终返回不可变新数据;
- 核心特性:结构共享,极致性能,完美适配 Redux/React 的不可变要求;
- 避坑铁律:① 不手动 return draft;② 不混用「修改 draft」和「return 新值」。
Immer 的伟大之处,不在于它的原理有多复杂,而在于它用最简单的技术,解决了前端「不可变数据」这个最大的心智负担,让开发者能回归业务本身,不用再纠结于「怎么写不可变更新」,这也是它能成为 Redux 生态标配的根本原因。