C06 深拷贝与结构化克隆
C06 深拷贝与结构化克隆
摘要:浅拷贝只复制第一层,嵌套对象仍共享引用;
JSON往返转换能断开嵌套,但会丢弃undefined、函数、Symbol,把Date变成字符串,Map/Set变成空对象,遇到循环引用直接抛错。structuredClone使用结构化克隆算法,支持循环引用与多数内置类型,不支持函数与 DOM 节点。本文给出三种深拷贝实现的边界、类型支持对照表与深比较的两种写法。
关键词:深拷贝,结构化克隆,循环引用,深比较,不可变
所属:frontend/js / C-高级 | 上一篇:C05-JavaScript工程化 | 下一篇:C07-观察者与WebAPI | 返回:C00-总结
1 引言
「改副本污染了原数据」与「拷贝后数据变样」是同一件事的两面:前者用浅拷贝处理嵌套数据,后者用 JSON 处理含特殊类型的数据。两类缺陷都发生在拷贝方式与被拷贝内容不匹配时。
本文给出三种方式的适用边界。范围见表 1-1。
| 项 | 本文覆盖 | 本文不覆盖 |
|---|---|---|
| 拷贝 | 展开、JSON、structuredClone、手写递归 |
不可变数据库(Immer) |
| 边界 | 循环引用、内置类型、属性描述符 | 跨线程传输(C07) |
| 比较 | 浅比较、深比较、循环引用处理 | 差异化比对算法 |
表 1-1 本文覆盖范围
2 预备知识
| 项 | 要求 | 核对方式 |
|---|---|---|
| 环境 | Node.js 20+(内置 structuredClone) |
node -e "structuredClone({})" |
| 前置 | A02、B07 | 引用与集合已掌握 |
| 文件 | js-practice/clone.mjs |
编码 UTF-8 |
表 2-1 环境与前置要求
3 原理与实现
3.1 三种方式的复制深度
复制深度决定嵌套对象是否共享,如图 3-1 所示。
flowchart TD
A["源对象 source"] --> B["浅拷贝 展开/assign"]
A --> C["JSON 往返"]
A --> D["structuredClone"]
B --> E["第一层独立,嵌套层共享"]
C --> F["全部独立,但类型被重编码"]
D --> G["全部独立,类型按需保留"]
E --> H["改嵌套影响源对象"]
F --> I["Date/Map/函数丢失或变形"]
G --> J["函数与 DOM 节点抛错"]
图 3-1 三种拷贝方式的深度与副作用
图上可见:深度与保真度不可兼得,选择依据是数据里是否存在特殊类型与循环引用。
3.2 完整实现
// js-practice/clone.mjs;运行环境:Node.js 20+
const source = {
name: 'Lin',
when: new Date('2026-10-31T00:00:00Z'),
tags: ['a', 'b'],
meta: new Map([['k', 1]]),
nothing: undefined,
fn: () => 1,
};
// 1 浅拷贝:嵌套层仍共享
const shallow = { ...source, tags: source.tags.slice(0, 1) };
source.tags.push('c');
console.log(shallow.tags); // ['a'],已手工断开一层
// 2 JSON 往返:类型被重编码
const viaJson = JSON.parse(JSON.stringify(source));
console.log(viaJson.when instanceof Date); // false,是字符串
console.log(viaJson.meta instanceof Map); // false,是空对象
console.log('nothing' in viaJson); // false,undefined 被丢弃
console.log(typeof viaJson.fn); // undefined
// 3 structuredClone:保留内置类型,不支持函数
const plain = {
name: 'Lin',
when: new Date('2026-10-31T00:00:00Z'),
tags: ['a', 'b'],
meta: new Map([['k', 1]]),
nested: { deep: { n: 1 } },
};
const cloned = structuredClone(plain);
console.log(cloned.when instanceof Date); // true
console.log(cloned.meta instanceof Map); // true
cloned.nested.deep.n = 2;
console.log(plain.nested.deep.n); // 1,完全独立
// 4 循环引用:JSON 抛错,structuredClone 正常
const cyclic = { self: null };
cyclic.self = cyclic;
try {
JSON.stringify(cyclic);
} catch (err) {
console.log(err.name); // TypeError
}
const cyclicClone = structuredClone(cyclic);
console.log(cyclicClone.self === cyclicClone); // true,引用结构被保留
// 5 手写递归:可控且可处理特殊规则
function deepClone(value, seen = new WeakMap()) {
if (value === null || typeof value !== 'object') return value;
if (seen.has(value)) return seen.get(value); // 处理循环引用
if (value instanceof Date) return new Date(value);
if (value instanceof Map) return new Map([...value].map(([k, v]) => [k, deepClone(v, seen)]));
if (value instanceof Set) return new Set([...value].map((v) => deepClone(v, seen)));
const out = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
seen.set(value, out);
for (const [key, val] of Object.entries(value)) out[key] = deepClone(val, seen);
return out;
}
console.log(deepClone(plain).meta instanceof Map); // true
// 6 深比较:先比引用再比结构
function deepEqual(a, b) {
if (a === b) return true;
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
const keys = Object.keys(a);
if (keys.length !== Object.keys(b).length) return false;
return keys.every((k) => deepEqual(a[k], b[k]));
}
console.log(deepEqual(cloned, plain)); // true
console.log(deepEqual({ a: 1 }, { a: 2 })); // false
三种方式的类型支持见表 3-1。
| 内容 | 展开 | JSON |
structuredClone |
手写递归 |
|---|---|---|---|---|
| 嵌套对象与数组 | 否 | 是 | 是 | 是 |
Date |
共享 | 变字符串 | 保留 | 需专门处理 |
Map/Set |
共享 | 变空对象 | 保留 | 需专门处理 |
RegExp/Error |
共享 | 变空对象 | 保留(Error 部分) | 需专门处理 |
undefined/函数 |
保留 | 丢弃 | 函数抛错 | 保留 |
| 循环引用 | 保留引用 | 抛 TypeError |
支持 | 需 WeakMap |
| 属性描述符 | 丢失 | 丢失 | 丢失 | 可保留 |
表 3-1 三种深拷贝方式的类型支持对照
选择依据见表 3-2。
| 场景 | 选择 | 理由 |
|---|---|---|
| 纯数据,需跨线程或持久化 | structuredClone / JSON |
结果可结构化传输 |
| 含函数或 DOM 节点 | 浅拷贝 + 手工处理 | 语义无法自动复制 |
| 需要保留描述符与原型 | 手写递归 | 唯一可控方式 |
| 状态更新(不可变) | 局部展开 | 只复制变化的路径,成本最低 |
表 3-2 四种场景的选择
4 验证与讨论
- 执行脚本,确认
viaJson.when不是Date,而cloned.when是Date。 - 在控制台执行
structuredClone({ fn: () => 1 }),确认抛出DataCloneError。 - 构造
cyclic后分别调用三种方式,确认只有JSON抛错。 - 用
deepEqual比较deepClone(plain)与plain,确认结构一致且引用不同。
| 检查项 | 预期结果 | 说明 |
|---|---|---|
| 浅拷贝边界 | 嵌套层仍共享引用 | 图 3-1 |
| 类型保真 | Date/Map 在克隆后仍可用 |
表 3-1 |
| 循环引用 | structuredClone 保留结构 |
3.2 节 |
| 函数抛错 | 克隆含函数对象抛 DataCloneError |
表 3-1 |
| 深比较 | 结构相同返回 true |
3.2 节 |
表 4-1 五项验收
讨论与局限。 structuredClone 的复制成本随对象规模线性增长,高频场景(如每帧状态更新)应改为局部展开。另,深比较对含循环引用的对象会无限递归,需在实现中加入 WeakMap 记录已比较的配对;3.2 节第 6 段的简化实现只比较可枚举属性,不比较 Date、Map、Set 的内部内容,需要精确比较时应按类型分支处理。
5 常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 改副本污染原数据 | 用了浅拷贝 | 改用 structuredClone 或逐层展开 |
拷贝后 Date 变成字符串 |
走了 JSON 往返 |
改用 structuredClone |
Converting circular structure |
JSON 遇到循环引用 |
改用 structuredClone 或手写带 WeakMap |
DataCloneError |
对象含函数或 DOM 节点 | 先剔除这些字段再克隆 |
Map 拷贝后变成空对象 |
走了 JSON |
表 3-1 选 structuredClone |
| 深比较卡死 | 存在循环引用 | 用 WeakMap 记录已比较配对 |
表 5-1 深拷贝的六类问题
6 结论
| 序号 | 结论 | 依据 |
|---|---|---|
| 1 | 浅拷贝只断开第一层 | 图 3-1 |
| 2 | JSON 往返会重编码类型,仅纯数据可用 |
表 3-1 |
| 3 | 含特殊类型或循环引用用 structuredClone |
表 3-2 |
| 4 | 需保留描述符与原型时手写递归 | 表 3-2 |
表 6-1 结论汇总
- 上一篇:C05-JavaScript工程化
- 下一篇:C07-观察者与WebAPI
- 返回:C00-总结 | 科目 README
参考文献
[1] MDN Web Docs · structuredClone,https://developer.mozilla.org/zh-CN/docs/Web/API/structuredClone(访问于 2026-10-31)
[2] HTML Standard · Structured serialize,WHATWG,https://html.spec.whatwg.org/multipage/structured-data.html(访问于 2026-10-31)

评论 0
还没有评论,快来抢沙发吧~