immer 不可变数据结构实测:auto-freeze 边界、nothing sentinel 行为变化与 strict cop

举报
yd_232353574 发表于 2026/10/23 10:27:47 2026/10/23
【摘要】 不可变数据状态库 Immer,实测发现删除属性、自定义实例、严格拷贝三处反直觉。

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,结论均为我在上述环境的第一手实测,只讨论代码层面的观察,不做归属主张。)

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。