JavaScript 深拷贝的正确姿势:从 JSON.stringify 到 structuredClone
拷贝一个对象,听起来是再基础不过的操作。但在 JavaScript 里,"拷贝"这个词下面埋着一堆坑:浅拷贝和深拷贝的区别、JSON.parse(JSON.stringify(x)) 这个经典写法的边界、以及不同场景下到底该用哪个 API。这篇文章把这几个问题一次讲清楚,并给出可以跑通的代码示例。
适合对象:日常写前端或 Node.js、需要复制复杂对象(嵌套对象、数组、Date、Map、Set 等)的开发者。
一、先分清:赋值、浅拷贝、深拷贝
这三者的差异,用一个嵌套对象最能直观说明:
js
const source = {
name: "alice",
address: { city: "Shanghai", zip: "200000" },
};
// 1. 赋值:两个变量指向同一个对象
const byRef = source;
byRef.address.city = "Beijing";
console.log(source.address.city); // "Beijing" ------ source 被一起改了
// 2. 浅拷贝:只复制第一层,嵌套对象仍是共享引用
const shallow = { ...source };
shallow.address.city = "Shenzhen";
console.log(source.address.city); // "Shenzhen" ------ 嵌套对象又被改了
console.log(shallow.name === source.name); // true(字符串,值相等)
// 3. 深拷贝:嵌套对象也完全独立
const deep = structuredClone(source);
deep.address.city = "Hangzhou";
console.log(source.address.city); // 仍是 "Shenzhen",互不影响
结论一句话:浅拷贝只断开第一层引用,深拷贝递归地断开所有层级的引用。
常见的浅拷贝写法有:展开运算符 { ...obj }、Object.assign({}, obj)、数组的 slice() / concat() / [...arr]。它们处理一层够用,遇到嵌套结构就失效了。
二、JSON 方案:好用,但有一大堆例外
最流行的深拷贝写法大概是这个:
js
const copy = JSON.parse(JSON.stringify(obj));
它的优点就一条:写起来短,且对纯粹的 JSON 数据(字符串、数字、布尔、null、数组、普通对象)完全够用。但它的缺点一抓一大把:
1. 丢类型信息。 任何不是 JSON 原生支持的值,都会在序列化过程中被悄悄改变:
js
const data = {
date: new Date("2026-01-01T00:00:00Z"),
undef: undefined,
fn: function () { return 1; },
nan: NaN,
inf: Infinity,
sym: Symbol("id"),
};
const copy = JSON.parse(JSON.stringify(data));
console.log(copy.date); // "2026-01-01T00:00:00.000Z" ------ 变成了字符串,不再是 Date
console.log(copy.undef); // 属性被直接丢弃(undefined、函数、Symbol 都不参与序列化)
console.log("fn" in copy); // false
console.log(copy.nan); // null ------ NaN/Infinity 变成 null
2. 环引用直接报错。 对象里如果有自引用,JSON.stringify 会抛异常:
js
const circular = { name: "x" };
circular.self = circular;
try {
JSON.stringify(circular);
} catch (e) {
console.log(e.message); // "Converting circular structure to JSON"
}
3. Map、Set、ArrayBuffer 等现代结构会被破坏。 Map 序列化后变成空对象 {},Set 变成 {},这是最容易踩的隐性 bug:
js
const m = new Map([["k", 1]]);
const cloned = JSON.parse(JSON.stringify(m));
console.log(cloned); // {} ------ 数据丢了
所以 JSON.parse(JSON.stringify()) 只适合明确已知是纯 JSON 数据 的场景,比如接口返回的响应对象。凡是可能含有 Date、Map、Set、环引用、函数的对象,都别用。
三、structuredClone:现代标准答案
从 2022 年起,浏览器和 Node.js(≥17)都原生支持 structuredClone()。它是对手写递归方案的官方替代,语义明确。
js
const original = {
name: "alice",
date: new Date("2026-01-01T00:00:00Z"),
map: new Map([["k", 1]]),
set: new Set([1, 2, 3]),
buffer: new ArrayBuffer(8),
nested: { list: [1, 2, 3] },
};
const copy = structuredClone(original);
copy.map.set("k", 99);
copy.date.setFullYear(2030);
console.log(original.map.get("k")); // 1 ------ 完全独立
console.log(original.date.getFullYear()); // 2026
console.log(copy.buffer instanceof ArrayBuffer); // true ------ 二进制数据也支持
structuredClone 支持的场景远超 JSON 方案:
-
Date、Map、Set、ArrayBuffer、Blob、RegExp、TypedArray等都能正确克隆; -
支持环引用 :
jsconst circ = { name: "x" }; circ.self = circ; const copy = structuredClone(circ); console.log(copy.self === copy); // true ------ 环被保留了
但它也有两个明确的"不支持":
-
函数 ------
structuredClone无法克隆函数,会抛DataCloneError:jstry { structuredClone({ fn: () => 1 }); } catch (e) { console.log(e.name); // "DataCloneError" } -
类实例的原型链 ------克隆结果是普通对象,
instanceof判断会失效,自定义类的私有字段也不会被复制。
如果你的对象就是"数据"而非"带方法的对象",structuredClone 是现在性价比最高的选择。
兼容性提示 :structuredClone 在 Chrome 98+、Firefox 94+、Safari 15.4+、Node 17+ 可用。需要兼容更老环境时,可以用 core-js 的 polyfill 或退回手写递归,但 Node 端直接升级版本通常更省事。
四、手写递归拷贝:理解原理,也用于兜底
了解原理有助于判断边界。一个能处理嵌套对象、数组、Date、Map、Set、环引用的实现大致如下:
js
function deepClone(value, seen = new Map()) {
// 基本类型直接返回
if (value === null || typeof value !== "object") {
return value;
}
// 处理环引用:如果已经拷贝过这个对象,直接返回它的副本
if (seen.has(value)) {
return seen.get(value);
}
if (value instanceof Date) {
return new Date(value.getTime());
}
if (value instanceof RegExp) {
return new RegExp(value.source, value.flags);
}
if (value instanceof Map) {
const copy = new Map();
seen.set(value, copy);
for (const [k, v] of value) {
copy.set(deepClone(k, seen), deepClone(v, seen));
}
return copy;
}
if (value instanceof Set) {
const copy = new Set();
seen.set(value, copy);
for (const v of value) {
copy.add(deepClone(v, seen));
}
return copy;
}
if (Array.isArray(value)) {
const copy = [];
seen.set(value, copy);
for (const item of value) {
copy.push(deepClone(item, seen));
}
return copy;
}
// 普通对象
const copy = {};
seen.set(value, copy);
for (const key of Object.keys(value)) {
copy[key] = deepClone(value[key], seen);
}
return copy;
}
验证一下:
js
const src = {
date: new Date(),
map: new Map([["a", { deep: true }]]),
nested: [1, [2, 3]],
};
src.loop = src;
const dst = deepClone(src);
console.log(dst.loop === dst); // true(环被正确保留)
console.log(dst.map.get("a") !== src.map.get("a")); // true(深拷贝生效)
console.log(dst.date.getTime() === src.date.getTime()); // true
这段代码用 seen(一个 Map)记录"原对象 → 新副本"的映射,遇到已经处理过的对象直接返回之前的副本,从而解决环引用。
它的局限 :这段实现用 Object.keys 遍历普通对象,会忽略不可枚举属性和 Symbol 键;也没处理 ArrayBuffer、Blob 等二进制对象,更没有保留原型链。对于这些需求,structuredClone 是更稳妥的选择------手写版本更适合用来理解原理或做针对性定制。
五、怎么选:一张对比表
| 方案 | 适用场景 | 主要坑 |
|---|---|---|
赋值 = |
就是想要共享同一份数据 | 改一个两个都变 |
浅拷贝 {...} / Object.assign |
只改第一层,或明确知道没有嵌套 | 嵌套对象仍是共享引用 |
JSON.parse(JSON.stringify()) |
纯 JSON 数据(接口响应等) | 丢类型、丢 undefined/函数,环引用报错,Map/Set 变空 |
structuredClone |
现代环境下的通用数据克隆(推荐) | 不能克隆函数,不保留类原型 |
手写 deepClone |
需要定制逻辑、或极老环境兜底 | 边界情况多,容易漏处理 |
小结
- 深拷贝的本质是递归断开所有层级的引用,浅拷贝只断开第一层。
JSON.parse(JSON.stringify())只适合纯 JSON 数据,遇到Date、Map、Set、环引用就会出错。- 现代项目里,首选
structuredClone():语义清晰、支持类型广、还能处理环引用,唯一明显短板是不支持函数和类原型。 - 需要极致定制或兼容老环境时,才考虑手写递归实现,并充分理解它的边界。
延伸阅读:
- MDN:
structuredClone()------ https://developer.mozilla.org/en-US/docs/Web/API/structuredClone - MDN:The structured clone algorithm ------ https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
- JSON.stringify 的序列化行为 ------ https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify