创见博客
Immer.js原理
七崽爱吃小饼干2026/01/15阅读 0专栏 React

Immer.js 核心原理深度解析:为什么「可变写法」能得到「不可变结果」

Immer 最精髓的底层逻辑 —— 明明我写的是 draft.xxx = xxx 这种「直接修改」的可变代码,为什么最终能生成符合 Redux 要求的「不可变新数据」,而且原数据完全不会被改动。

前置共识(先明确2个基础,理解会更顺畅)

  1. Immer 的核心输入输出:我们永远是调用 produce(originalState, (draft) => { 这里写可变修改 }),入参是「原数据」,返回值是「不可变新数据」,原数据永远只读、永不修改。
  2. Immer 的核心口号:Write mutable code, get immutable data(写可变的代码,得到不可变的数据),这不是噱头,是真实的执行逻辑。

一、Immer 实现该能力的「三大核心基石」(一句话核心原理)

Immer 能做到「可变写法 + 不可变结果」,核心靠的是 3个核心技术的组合拳,缺一不可,这也是 Immer 底层的全部核心,没有任何黑魔法:

核心原理一句话总结:Immer 对「原数据」创建一个 Proxy 代理对象(命名为 draft),让开发者在「代理对象」上做任意「可变修改」;同时通过 浅拷贝(Shallow Copy) 做「按需惰性复制」,只复制被修改的层级/属性,不碰未修改的部分;最后通过 修改追踪 记录所有对 draft 的改动,基于原数据+追踪的改动,生成一个「全新的不可变数据」,原数据全程无任何修改。

三个核心基石的优先级和作用:

  1. Proxy 代理机制 → 核心载体,所有修改的「入口」
  2. 修改追踪机制 → 核心大脑,记录所有修改的「内容」
  3. 按需浅拷贝机制 → 核心效率,生成新数据的「方式」

二、基石一: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 做法):只有当某个属性/层级「被修改」时,才对这个层级做一次浅拷贝,生成一个新的对象/数组,未被修改的属性/层级,会直接复用原数据的引用。

举个通俗的例子(多层嵌套对象)

假设原数据是多层嵌套的,这是我们最常见的业务场景:

javascript
const original = {
  user: { info: { name: "张三", age: 20 } },
  todos: [{ id: 1, done: false }, { id: 2, done: true }]
}

当你写 draft.user.info.name = "李四" 时:

  1. Immer 只会对「被修改的层级」做浅拷贝:只拷贝 original.user → 再拷贝 original.user.info;
  2. original.todos 因为没被修改,会直接复用原数据的引用,不会做任何拷贝;
  3. 最终生成的新数据里,todos 属性和原数据的 todos 是同一个引用,user 是新引用,user.info 也是新引用。

核心价值

  1. 极致的性能:只拷贝被修改的部分,内存占用极低,比手动写扩展运算符的不可变更新性能更好;
  2. 符合不可变规范:只要「被修改的层级」是新的引用,整个数据就是不可变的(Redux 的浅比较只看引用地址);
  3. 无冗余代码:开发者不用手动写 {...state, user: {...state.user}} 这种嵌套拷贝,Immer 帮你自动完成。

四、基石三:精准的「修改追踪」(Immer 知道你改了什么)

你在 draft 上做的所有修改,Immer 都能「精准记录」,这是生成最终新数据的依据,这个能力由 Proxy 代理 + 内部的修改记录表 共同实现。

追踪的核心逻辑

Immer 在创建 draft 代理对象时,会同时初始化一个「修改追踪器」,当你执行任何修改操作(比如 draft.a = b、draft.c.push(d))时:

  1. Proxy 会拦截到这次修改操作,知道你「修改了哪个属性、修改前的值是什么、修改后的值是什么」;
  2. 追踪器会把这次修改「记录在册」,只记录「被改动的属性/层级」,未改动的部分不会有任何记录;
  3. 对于嵌套对象,会「递归追踪」:比如你改 draft.user.info.name,追踪器会记录 user → info → name 这个完整的修改路径。

关键特点

  • 追踪是「精准的」:只记录真实的修改,无任何冗余;
  • 追踪是「无感知的」:开发者完全不用关心追踪逻辑,只需要写自己的修改代码即可。

五、Immer 完整执行流程(从调用到返回,一步不落,彻底看懂)

结合上面的三大核心基石,我们把 Immer 的 produce 函数执行的完整生命周期拆成 5个步骤,用最开始的嵌套对象为例,你会彻底明白「可变写法」是如何变成「不可变结果」的,这是理解 Immer 的重中之重。

示例准备(固定原数据和修改逻辑)

javascript
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 最核心的优化步骤,只在修改发生时执行拷贝:

  1. 当你修改 draft.user.info.name 时,Immer 会先对 originalState.user 做一次「浅拷贝」,生成一个新的 user 对象;
  2. 再对新的 user.info 做一次「浅拷贝」,生成一个新的 info 对象;
  3. 把 name 修改为「李四」,赋值给这个新的 info 对象;
  4. 当你修改 draft.todos[0].done 时,Immer 会先对 originalState.todos 做一次「浅拷贝」生成新数组,再对数组的第0项做浅拷贝生成新对象,修改 done 为 true;
  5. 未被修改的层级(比如 user.info.age、todos[1]),直接复用原数据的引用,不做任何拷贝。

步骤 4:生成新数据 → 合并「原数据」+「修改记录」,创建不可变新对象

当回调函数执行完毕后,Immer 会基于「原数据」和「修改追踪器」里的所有修改记录,执行最终的合并:

  • 对于「未被修改」的属性/层级:直接把原数据的引用「复用」到新数据中;
  • 对于「被修改」的属性/层级:把步骤3中「按需浅拷贝」生成的「新对象/新数组」赋值到新数据中;
  • 最终生成一个 全新的、不可变的状态对象 newState。

步骤 5:返回结果 → 原数据完好无损,新数据符合不可变规范

Immer 把生成的 newState 作为返回值返回,此时有3个绝对成立的核心结果:

  1. 原数据 originalState 完全不变:没有任何修改,还是最初的样子,只读属性得到保证;
  2. 新数据 newState 是全新的不可变对象:被修改的层级都有「新的引用地址」,未修改的层级复用原引用,完美符合 Redux 的不可变要求;
  3. newState 和 originalState 是「结构共享」的:未修改的部分复用引用,极大节省内存和提升性能。

六、核心关键:Immer 的「结构共享」特性(为什么性能极佳,必懂)

这是 Immer 能成为 Redux 标配的重要原因之一,也是很多开发者忽略的点:Immer 生成的新数据,和原数据之间是「结构共享」的。

什么是结构共享?

结构共享 = 新数据中,未被修改的属性/层级,直接复用原数据的引用;只有被修改的属性/层级,才是新创建的对象/数组。

比如上面的例子中:

  • newState.user → 新引用(因为被修改了);
  • newState.user.info → 新引用(因为被修改了);
  • newState.user.info.age → 原引用(值是20,没被修改);
  • newState.todos → 新引用(数组被修改了);
  • newState.todos[1] → 原引用(没被修改)。

结构共享的核心价值

  1. 极致的内存利用率:不用全量拷贝,只生成修改的部分,内存占用极低;
  2. 完美适配 React/Redux 的浅比较:React 的 memo、Redux 的 reducer 都是基于「浅比较(只对比引用地址)」判断是否更新,只要被修改的层级引用变了,就能精准触发更新,未修改的部分不会触发无效更新。

七、你一定会踩的坑:2个「绝对不能做」的操作

所有 Immer 的错误用法,根源都是 对 draft 这个「Proxy 代理对象」的理解不到位,结合上面的原理,我们能推导出 2 个「绝对禁止」的操作,这也是 Redux 官方反复强调的点,记住这两个规则,能避开 99% 的 Immer 错误:

❌ 禁止操作 1:在回调中「手动 return draft」

javascript
// 错误写法 ❌
produce(originalState, draft => {
  draft.user.info.name = "李四"
  return draft // 绝对不要return draft!
})

错误原因(原理层面): draft 是 Immer 创建的 Proxy 代理对象,不是「真实的不可变数据」,它只是一个「草稿监控器」。如果手动 return draft,相当于把「代理对象」作为返回值,这个对象不满足不可变规范,Redux 无法感知状态变化,还会导致各种诡异的 bug。

✅ 正确做法:修改 draft 后,什么都不要 return,Immer 会自动帮你生成并返回正确的不可变新数据。

❌ 禁止操作 2:在回调中「既修改 draft,又 return 一个新对象」

javascript
// 错误写法 ❌
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}),最终能得到一个「不可变的新数组」?

答案还是基于上面的原理,只是针对数组做了适配:

  1. 数组也是对象,draft 中的数组也是 Proxy 代理数组;
  2. 当你执行 draft.todos.push() 时,Proxy 会拦截这个 push 操作,记录下来;
  3. Immer 会对原数组做「按需浅拷贝」,生成一个新数组;
  4. 在新数组上执行 push 操作,最终返回这个新数组;
  5. 原数组完全不变,新数组是不可变的。

✅ 结论:在 Immer 的 draft 中,你可以放心使用 push/splice/shift/pop 等所有数组「可变方法」,Immer 都会帮你转换成「不可变的数组更新」。


总结

  1. 核心本质:Immer 是「语法糖」,底层是 ES6 Proxy + 浅拷贝 + 修改追踪,没有任何黑魔法;
  2. draft 是什么:draft 是「原数据的 Proxy 代理对象」,不是原数据,也不是拷贝,是修改的入口;
  3. 为什么可变写法可行:你修改的是「代理对象 draft」,不是原数据,所有修改都会被拦截和记录;
  4. 为什么能生成不可变结果:Immer 基于修改记录,对「被修改的层级」做按需浅拷贝,生成全新的对象/数组,未修改的部分复用原引用,最终返回不可变新数据;
  5. 核心特性:结构共享,极致性能,完美适配 Redux/React 的不可变要求;
  6. 避坑铁律:① 不手动 return draft;② 不混用「修改 draft」和「return 新值」。

Immer 的伟大之处,不在于它的原理有多复杂,而在于它用最简单的技术,解决了前端「不可变数据」这个最大的心智负担,让开发者能回归业务本身,不用再纠结于「怎么写不可变更新」,这也是它能成为 Redux 生态标配的根本原因。

评论
0/100