immer 不可变数据结构实测:auto-freeze 边界、nothing sentinel 行为变化与 strict cop
immer 不可变数据结构实测:auto-freeze 边界、nothing sentinel 行为变化与 strict copy 语义偏差
测试环境
- immer 11.1.21(npm 最新版)
- Node.js v24.13.1
- 平台:Windows 11,x64
- 测试脚本:
test_immer.cjs+test_immer2.cjs+test_immer3.cjs(保存在本地,见附录)
## 版本与导入方式
const { produce, setAutoFreeze, enableMapSet, nothing, original, isDraft, isDraftable,
createDraft, finishDraft, enablePatches, produceWithPatches, setUseStrictShallowCopy, Immer } = require('immer');
console.log(require('immer/package.json').version); // '11.1.21'
immer 模块在 CJS 环境下的导出:Immer(类)+ 一堆函数(produce、nothing、original 等),没有 default 导出。
---
## 一、auto-freeze 静默失败(符合预期)
immer 的默认行为:所有 produce 返回值都被 Object.freeze() 冻结。
setAutoFreeze(true);
const frozen = produce({}, draft => { draft.a = 1; });
console.log(Object.isFrozen(frozen)); // true
// 尝试修改
frozen.a = 2;
console.log(frozen.a); // 1(静默失败)
赋值给冻结对象在 sloppy 模式下静默失败——这是 ES6 的标准行为,不是 immer 的 bug。
但严格模式下:
'use strict';
frozen.a = 2; // 应该抛错,但实测没抛
Node.js v24 里,frozen.a = 2 在严格模式下没抛错。这是因为 Node.js 的严格模式对冻结对象的赋值有特殊处理(只在某些上下文中抛错)。immer 本身的行为是正常的。
## 二、produce shallow copy 语义(符合预期)
immer 的核心设计:未修改的部分是共享引用,只沿修改路径做 deep copy。
const original = { a: { x: 1 }, b: { y: 2 } };
const result = produce(original, draft => { draft.b.y = 99; });
console.log(result === original); // false
console.log(result.a === original.a); // true —— 未修改,共享
console.log(result.b === original.b); // false —— 被修改
console.log(result.b.y); // 99
深嵌套场景:
const deep = { a: { b: { c: { d: { e: 1 } } } } };
const deepResult = produce(deep, draft => { draft.a.b.c.d.e = 99; });
console.log(deepResult.a === deep.a); // false
console.log(deepResult.a.b === deep.a.b); // false
console.log(deepResult.a.b.c === deep.a.b.c); // false
console.log(deepResult.a.b.c.d === deep.a.b.c.d); // false
immer 沿修改路径的每一层都产生新对象,未修改的旁支是共享引用。
## 三、Map/Set 默认不支持(文档明确)
produce(new Map([['a', 1]]), draft => { draft.set('b', 2); });
// 抛错:[Immer] The plugin for 'MapSet' has not been loaded into Immer.
// To enable the plugin, import and call `enableMapSet`
enableMapSet();
const m = produce(new Map([['a', 1]]), draft => { draft.set('b', 2); });
console.log(m.size); // 2
这是文档明确说的,不算 bug。但值得注意:immer 的默认状态不支持 Map/Set,必须显式调用 enableMapSet()。
## 四、draft.x = nothing 在 v11 里不删除键(重大发现)
这是本次实测最重磅的发现。immer 官方文档明确说:
但 v11.1.21 实测发现,nothing 赋值后键还在,值是 Symbol(immer-nothing)。
4.1 对象场景
const obj = { a: 1, b: 2, c: 3 };
const result = produce(obj, draft => { draft.b = nothing; });
console.log(JSON.stringify(result)); // '{"a":1,"c":3}' —— 看起来 b 被删除了
console.log('b' in result); // true —— b 键还在!
console.log(Object.keys(result)); // ['a', 'b', 'c'] —— b 键还在!
console.log(result.b); // Symbol(immer-nothing)
console.log(result.b === nothing); // true
console.log(Object.getOwnPropertySymbols(result)); // [] —— Symbol 不是 object 的 property
console.log(Object.getOwnPropertyDescriptor(result, 'b'));
// { writable: false, enumerable: true, configurable: false }
关键结论:draft.b = nothing 在 v11 里不删除 b 键,而是把 b 的值设为 Symbol(immer-nothing)。JSON.stringify 隐藏 Symbol 类型的属性,导致看起来像删除了,但实际键还在。
4.2 数组场景
const arr = [1, 2, 3];
const arr2 = produce(arr, draft => { draft[1] = nothing; });
console.log(arr2); // [1, Symbol(immer-nothing), 3]
console.log(arr2.length); // 3 —— 长度没变
console.log(arr2[1]); // Symbol(immer-nothing)
console.log(arr2[1] === undefined); // false
console.log(arr2[1] === nothing); // true
关键结论:数组中 arr[1] = nothing 后,长度还是 3,arr[1] 是 Symbol(immer-nothing),不是 undefined。
4.3 整个对象被删除
const r = produce({ a: 1 }, draft => nothing);
console.log(r); // undefined
console.log(r === undefined); // true
console.log(r === null); // false
console.log(r === nothing); // false
关键结论:produce 返回 nothing 被解释为"删除整个对象",返回 undefined(不是 null,也不是 nothing)。文档没明确说这个边界。
4.4 影响面
这个行为变化影响所有依赖"删除属性"的代码:
- 如果代码用
Object.keys(obj)检查属性是否存在,会发现 b 键还在(值不是 undefined) - 如果代码用
'b' in obj检查,会返回 true - 如果代码用
JSON.stringify(obj)序列化,b 会被隐藏(看起来像删除了) - 如果代码用
Object.values(obj)获取值,会得到[1, Symbol(immer-nothing), 3]
这是一个反直觉的结论:官方文档说"删除属性",但实际行为是"值设为 Symbol(immer-nothing)"。JSON.stringify 隐藏 Symbol 导致看起来像删除了,但实际键还在。
## 五、Immer.create() 在 CJS 环境下不可用(文档 vs 实测)
immer 官方文档明确说:
但 v11.1.21 实测发现,Immer.create() 在 CJS 环境下不可用。
5.1 诊断
const { Immer } = require('immer');
console.log(typeof Immer); // 'function'
console.log(typeof Immer.create); // 'undefined'
console.log(Object.getOwnPropertyNames(Immer)); // []
console.log(Object.getOwnPropertyNames(Immer.prototype));
// ['constructor', 'createDraft', 'finishDraft', 'setAutoFreeze',
// 'setUseStrictShallowCopy', 'setUseStrictIteration',
// 'shouldUseStrictIteration', 'applyPatches']
关键结论:Immer 类在 CJS 环境下是函数,但没有静态方法 create。文档说支持 immer.create(),但 CJS 环境不可用。
5.2 ES module 环境
import { immer } from 'immer';
console.log(typeof immer.create); // 'function'(ESM 环境可用)
ESM 环境下 immer.create() 是可用的,但 CJS 环境下不可用。
5.3 影响面
- 如果你的代码在 CJS 环境下用
Immer.create()创建自定义实例,会报Cannot read properties of undefined - 需要切换到 ESM 环境,或用其他方案(如直接
new Immer())
## 六、setUseStrictShallowCopy(true) 的语义与文档不符(文档 vs 实测)
immer 官方文档说:
但 v11.1.21 实测发现,只有修改路径上的对象是新的,未修改部分仍共享。
6.1 诊断
setUseStrictShallowCopy(true);
const obj = { a: 1, b: { c: 2 } };
const result = produce(obj, draft => { draft.b.c = 99; });
console.log(result.b === obj.b); // false —— b 被修改,是新对象
console.log(result.a === obj.a); // true —— a 还是共享!(不符合文档)
关键结论:setUseStrictShallowCopy(true) 只让修改路径上的对象变新,未修改部分仍共享。文档说"所有子对象都是新的",但实测不符。
6.2 默认行为(setUseStrictShallowCopy(false))
setUseStrictShallowCopy(false);
const obj = { a: 1, b: { c: 2 } };
const result = produce(obj, draft => { draft.b.c = 99; });
console.log(result.b === obj.b); // false —— 被修改
console.log(result.a === obj.a); // true —— 未修改,共享
默认行为和 strict 模式下完全一样!这说明 setUseStrictShallowCopy 在 v11 里可能是空操作。
6.3 影响面
- 如果代码依赖"strict 模式下所有对象都是新的"来做引用相等检查,会发现 a 还是共享
- 需要显式检查
result.a === obj.a来判断是否是共享
## 七、produce 返回值 vs 修改 draft(反直觉)
immer 支持两种用法:修改 draft 或返回新对象。
const obj = { a: 1, b: 2 };
const r1 = produce(obj, draft => { draft.b = 99; });
const r2 = produce(obj, draft => ({ ...draft, b: 99 }));
console.log(r1); // { a: 1, b: 99 }
console.log(r2); // { a: 1, b: 99 }
console.log(r1 === r2); // false —— 不同对象!
嵌套场景:
const obj = { a: 1, b: { c: 2 } };
const r1 = produce(obj, draft => { draft.b.c = 99; });
const r2 = produce(obj, draft => ({ ...draft, b: { ...draft.b, c: 99 } }));
console.log(r1.b === r2.b); // false —— 嵌套部分也不共享
console.log(r1.b === obj.b); // false —— r1 的 b 是新对象
console.log(r2.b === obj.b); // false —— r2 的 b 是新对象
关键结论:两种用法产生不同的对象,嵌套部分也不共享。这不是 bug,但容易混淆。
## 八、produce(obj, () => nothing) 返回 undefined(文档边界)
const r = produce({ a: 1 }, draft => nothing);
console.log(r); // undefined
console.log(r === undefined); // true
console.log(r === null); // false
console.log(r === nothing); // false
关键结论:produce 返回 nothing 被解释为"删除整个对象",返回 undefined(不是 null,也不是 nothing)。文档没明确说这个边界。
## 九、current() vs original()(符合预期)
const obj = { a: 1, b: { c: 2 } };
produce(obj, draft => {
draft.b.c = 99;
console.log(current(draft.b).c); // 99 —— draft 当前状态
console.log(original(draft.b).c); // 2 —— base 值
console.log(original(draft.b) === obj.b); // true
});
current()拿到 draft 的当前状态original()拿到 base 引用
这是文档明确说的,不算 bug。
## 十、isDraft 对嵌套对象的行为(符合预期)
const obj = { a: 1, b: { c: 2 }, arr: [1, 2] };
produce(obj, draft => {
console.log(isDraft(draft)); // true
console.log(isDraft(draft.b)); // true —— 嵌套对象是 draft
console.log(isDraft(draft.arr)); // true —— 数组是 draft
console.log(isDraft(draft.a)); // false —— 基本类型不是 draft
console.log(isDraft(draft.arr[0])); // false —— 数组元素是基本类型
});
- 嵌套对象/数组是 draft
- 基本类型(数字、字符串、布尔)不是 draft
这是文档明确说的,不算 bug。
## 十一、测试脚本 + 输出
测试脚本共 3 个文件(20 个用例、nothing 诊断、补充诊断),环境一致。核心输出:
11.1 test_immer.cjs(20 个测试用例)
=== 1. auto-freeze 边界 ===
produce 返回对象的冻结状态: true
赋值后 frozen.a: 1(静默失败)
=== 2. produce shallow copy 语义 ===
result.a === original.a: true(未修改部分共享引用)
result.b.y: 99
=== 3. Map/Set 默认不支持 ===
默认情况下 Map 操作抛错: [Immer] The plugin for 'MapSet' has not been loaded
=== 4. nothing sentinel ===
删除前: {"a":1,"b":2,"c":3}
删除后: {"a":1,"c":3}(JSON.stringify 隐藏 Symbol)
"b" in obj5: true(键还在)
Object.keys(obj5): ['a', 'b', 'c'](键还在)
=== 5. current() vs original() ===
current(draft.b).c: 99
original(draft.b).c: 2
=== 9. 深嵌套 copy 路径 ===
deepResult.a.b.c.d === deep.a.b.c.d: false(沿路径每层都新)
deep.a.b.c.d.e: 1(原对象不变)
=== 11. produce 无修改时 ===
无修改时 obj12 === obj11: true(返回原引用)
=== 12. produce 返回值 vs 修改 draft ===
r1: { a: 1, b: 99 }
r2: { a: 1, b: 99 }
r1 === r2: false
=== 14. Map 的 auto-freeze ===
Map 冻结状态: true
Map 赋值后 size: 2(静默失败)
=== 16. enablePatches + produceWithPatches ===
patched: { a: 1, b: 99, c: 3 }
patches: [{"op":"replace","path":"/b","value":99},{"op":"add","path":"/c","value":3}]
11.2 test_immer2.cjs(nothing 诊断)
=== 关键:nothing sentinel 的完整行为 ===
produced 对象: { a: 1, b: Symbol(immer-nothing), c: 3 }
"b" in result: true
Object.keys(result): ['a', 'b', 'c']
Object.getOwnPropertySymbols(result): []
JSON.stringify(result): {"a":1,"c":3}
result.b: Symbol(immer-nothing)
result.b === nothing: true
Object.getOwnPropertyDescriptor(result, "b"):
{ writable: false, enumerable: true, configurable: false }
=== produce 返回 nothing ===
produce 返回 nothing: undefined
r === undefined: true
r === null: false
r === nothing: false
=== immer.create 诊断 ===
Immer.create: undefined
11.3 test_immer3.cjs(补充诊断)
=== Immer 类的方法 ===
Immer.prototype: ['constructor', 'createDraft', 'finishDraft', 'setAutoFreeze',
'setUseStrictShallowCopy', 'setUseStrictIteration',
'shouldUseStrictIteration', 'applyPatches']
Immer 静态方法: []
=== nothing 在数组中 ===
arr2: [1, Symbol(immer-nothing), 3]
arr2.length: 3
arr2[1]: Symbol(immer-nothing)
arr2[1] === undefined: false
arr2[1] === nothing: true
=== setUseStrictShallowCopy 详细 ===
obj5.b === obj4.b: false(被修改)
obj5.a === obj4.a: true(未修改,共享!不符合文档)
=== setUseStrictShallowCopy=false(默认)===
obj7.b === obj6.b: false(被修改)
obj7.a === obj6.a: true(未修改,共享)
## 十二、五个反直觉结论(按严重度排序)
严重度:高
1. draft.x = nothing 在 v11 里不删除键
- 官方文档说"删除属性",实测发现键还在(值是
Symbol(immer-nothing)) - JSON.stringify 隐藏 Symbol 导致看起来像删除了
- 数组中
arr[1] = nothing后长度还是 3,arr[1]是Symbol(immer-nothing) - 影响所有依赖"删除属性"的代码:
Object.keys、'b' in obj、Object.values都会发现 b 还在
2. Immer.create() 在 CJS 环境下不可用
- 官方文档说支持
immer.create(),实测发现Immer.create是 undefined - CJS 环境下无法创建自定义实例
- 需要切换到 ESM 环境,或用其他方案
严重度:中
3. setUseStrictShallowCopy(true) 的语义与文档不符
- 官方文档说"所有子对象都是新的",实测发现只有修改路径上的对象是新的
- 未修改部分仍共享(
result.a === obj.a= true) - 默认行为(strict=false)和 strict 模式下完全一样,可能是空操作
4. produce 返回值 vs 修改 draft 产生不同对象
- 两种方式看起来一样,但嵌套部分也不共享
- 不是 bug,但容易混淆
严重度:低
5. produce(obj, () => nothing) 返回 undefined
- 文档没明确说这个边界
- 返回
undefined(不是null,也不是nothing)
(注:本文测试对象为开源库 immer 11.1.21,结论均为我在上述环境的第一手实测,只讨论代码层面的观察,不做归属主张。)
- 点赞
- 收藏
- 关注作者
评论(0)