
前几年做深拷贝,标准答案基本是 JSON.parse(JSON.stringify(obj))。这招的问题在于它会偷偷改数据:Date 变成字符串,undefined 和函数直接消失,碰上循环引用还会报错。
后来 structuredClone 进了标准,Chrome 98 之后都能用。一开始以为终于有正经的深拷贝了,用了一阵子才发现它也有自己的脾气。下面 13 条检查项,每条一句结论,紧跟着的是脚本里的实际输出,不是从文档抄的。第 13 条是取舍。
能克隆的
01 嵌套对象和数组都是深拷贝,改副本动不到原对象
const src = { a: 1, b: { c: [1, 2, 3] } };
const cp = structuredClone(src);
cp.b.c.push(4);
// 原对象 b.c = [1,2,3] / 副本 b.c = [1,2,3,4]
02 循环引用也能克隆,JSON 方案在这一步直接报错
const cyc = { name: 'x' };
cyc.self = cyc;
const c = structuredClone(cyc);
c.self === c; // true
03 内置类型逐个还原,Map 和 Set 不会变成空对象
| 类型 | 结果 |
|---|---|
| Date | instanceof Date 为 true,时间值一致 |
| Map | 还原成 Map,值是深拷贝 |
| Set | 还原成 Set |
| BigInt | 还是 bigint |
| Uint8Array | 还是 Uint8Array |
| TypeError | 还原了,name 和 message 都在 |
Map 和 Set 这一条挺重要。JSON 方案会把它们变成空对象 {},而且不报错,等发现的时候通常已经在线上跑了。
前三条都属于日常最常见的数据形态,克隆结果和原件完全断开,改哪边都不影响另一边。后面十条讲的是会变形或者会抛错的地方。
会失效的那几类
04 函数、Symbol、WeakMap、Proxy 直接抛异常,不返回 undefined
structuredClone({ fn: () => 1 });
// DOMException: () => 1 could not be cloned.
structuredClone({ s: Symbol('x') });
// DOMException: Symbol(x) could not be cloned.
structuredClone(new WeakMap());
// DOMException: #<WeakMap> could not be cloned.
structuredClone(new Proxy({ a: 1 }, {}));
// DOMException: #<Object> could not be cloned.
弱引用类型克隆不了是合理的,克隆出来的弱引用该指向谁呢。Proxy 克隆不了也好理解,代理的目标和陷阱函数没法序列化。
这些值都报同一种错,问题直接落在调用点上,比悄悄改写容易发现。脚本里每一项都套了 try/catch,抛出来的构造函数名和 message 原样打在控制台,文章里的报错原文就是那几行输出。
05 异常类型是 DOMException,按 TypeError 分支去写会漏
实测下来有个坑:在 Node 里写 catch (e) { if (e instanceof TypeError) ... } 是抓不到的,错误处理分支根本没进去。它抛的是 DOMException。
06 类实例的原型会丢,数据全在而方法没了
class User {
constructor(n) { this.name = n; }
greet() { return 'hi ' + this.name; }
}
const u = structuredClone(new User('金铭'));
u instanceof User; // false
u.name; // '金铭' —— 数据在
u.greet; // undefined —— 方法没了
数据全都复制过来了,但 prototype 没跟过来。规范里的说法是「结构化克隆」,不是「对象克隆」,它只处理数据,不处理行为。结果就是克隆出来的东西长得像 User,实际是个普通对象,调用它的方法时才报错,而且报错位置往往离克隆的地方很远。
07 对象上有方法就别用它,补原型能把方法救回来
const u2 = structuredClone(plainUser); // 先拿数据快照
Object.setPrototypeOf(u2, User.prototype);
类里有私有字段(#field)的话还是救不了,私有字段不在结构化克隆的范围内。
08 getter 会被求值,变成可写的数据属性
const withGetter = {
_x: 1,
get x() { return this._x * 10; },
};
const g = structuredClone(withGetter);
g.x; // 10
Object.getOwnPropertyDescriptor(g, 'x');
// { value: 10, writable: true, enumerable: true, configurable: true }
克隆读的是属性值,不是属性描述符。原本那个只读的计算属性,克隆完变成了一个可写的数据属性。代码如果依赖 getter 做校验或者惰性计算,克隆之后就失效了。
09 不可枚举的属性会整个丢掉
const hidden = {};
Object.defineProperty(hidden, 'secret', { value: 42, enumerable: false });
structuredClone(hidden); // {} —— secret 没了
10 冻结状态不保留,克隆出来完全可写
const f = structuredClone(Object.freeze({ a: 1 }));
Object.isFrozen(f); // false
拿冻结对象当配置常量传给别处、指望它防篡改的话,克隆一次就白冻了。
11 稀疏数组的洞会变成显式的 undefined
const sp = structuredClone([1, , 3]);
1 in sp; // true —— 原来是个洞,现在是个真实存在的 undefined
长度没错,但 in 判断和 Object.keys 的结果会变。
12 RegExp 的 lastIndex 会被重置
const re = /ab/g;
re.lastIndex = 5;
const c = structuredClone(re);
c.lastIndex; // 0,不是 5
带 g 或 y 标志的正则用 exec 连续匹配时依赖 lastIndex。克隆一个匹配到一半的正则,进度就丢了,下次又从字符串开头开始找。这个坑不常见,真碰上了很难查。
13 取舍:纯数据用它,行为别指望它
日常的纯数据,比如接口返回的 JSON、配置对象、要存进 IndexedDB 的东西,structuredClone 比 JSON 方案好,直接用。对象上有方法、有 getter、有私有字段,或者依赖冻结状态,这一类我不推荐用:它给出来的是一份数据的快照,不是那个对象的副本。至于 JSON.parse(JSON.stringify()),现在没有理由再用它了:克隆不了的值它会静默改写,structuredClone 至少会大声报错。