JavaScript 深拷贝的 8 种写法:structuredClone 之外还有谁
学完这篇,你能拿到一份可直接抄的 JavaScript 深拷贝清单:8 种写法各自能干什么、会丢什么数据、什么时候该用哪一种,看完不用再翻十几篇博客。
深拷贝的核心难点从来不是"递归写不出来",而是循环引用、特殊类型、函数与原型这三件事的处理。下面按"从简到全"的顺序排,每条都给最小可用代码和踩坑点。
方法一:JSON.parse(JSON.stringify(obj))
最省事的一行流:
const copy = JSON.parse(JSON.stringify(obj));
只适合纯数据(字符串/数字/布尔/数组/普通对象/纯嵌套)。它的丢失清单很长:`undefined`、函数、Symbol 键直接消失;`Date` 变字符串;`NaN`、`Infinity` 变 `null`;`Map`、`Set` 变 `{}`;遇到循环引用直接抛 `TypeError`。
注意:接口返回的数据里只要有 `Date` 字段,用这招日期就成字符串了,后续 `.getFullYear()` 会报错。
方法二:手写递归(基础版)
function clone(obj) {
if (obj === null || typeof obj !== 'object') return obj;
const target = Array.isArray(obj) ? [] : {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
target[key] = clone(obj[key]);
}
}
return target;
}
`for...in` 会遍历原型链上的可枚举属性,所以必须加 `hasOwnProperty` 判断。这版会丢 Symbol 键和不可枚举属性,也不认 `Date`、`Map`。
方法三:递归 + WeakMap 解决循环引用
function clone(obj, map = new WeakMap()) {
if (obj === null || typeof obj !== 'object') return obj;
if (map.has(obj)) return map.get(obj);
const target = Array.isArray(obj) ? [] : {};
map.set(obj, target);
for (const key of Reflect.ownKeys(obj)) {
target[key] = clone(obj[key], map);
}
return target;
}
两个升级点:`WeakMap` 缓存已克隆对象,循环引用不再死循环;`Reflect.ownKeys` 能拿到 Symbol 键和不可枚举属性。仍然不处理 `Date`、`Map` 这些内置类型。
注意:WeakMap 必须是参数传递,写成函数外的全局变量会跨调用污染。
方法四:递归 + 类型完整版
在方法三基础上补类型分支:
if (obj instanceof Date) return new Date(obj);
if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags);
if (obj instanceof Map) { /* 逐项 clone */ }
if (obj instanceof Set) { /* 逐项 clone */ }
`Map`/`Set` 要新建实例再遍历塞进去,别指望遍历索引。这版能覆盖 95% 的业务场景,代价是代码量上去了,且类实例的原型链依然会丢——克隆出来是普通对象,`instanceof` 判断失效。
方法五:structuredClone(现代首选)
const copy = structuredClone(obj);
浏览器与 Node 17+ 原生支持,底层是"结构化克隆算法",自动处理循环引用、`Date`、`RegExp`、`Map`、`Set`、`ArrayBuffer`、`Blob`、`File`,代码零成本。
它不支持的三样:函数(抛 DataCloneError)、DOM 节点、Symbol 作为键(该属性会被静默忽略,不报错)。另外它不保留原型链和 getter/setter,getter 会被求值成静态值。
方法六:MessageChannel 消息通道
结构化克隆算法的另一条通道,兼容比 `structuredClone` 更老的浏览器:
function clone(obj) {
return new Promise((resolve) => {
const { port1, port2 } = new MessageChannel();
port1.onmessage = (e) => resolve(e.data);
port2.postMessage(obj);
});
}
能力边界和方法五一模一样,但它是异步的,调用处必须 `await`。适合本来就在异步流程里的场景。
方法七:history.pushState 偷懒法
const clone = (obj) => {
history.pushState({}, '', location.href);
return history.state; // 不对,正确写法见下
};
正确姿势是把数据塞进 state 再用 `history.replaceState` 比对取值,同步得到克隆结果,同样走结构化克隆。但它在 Worker 里、SSR 环境里不存在,还会污染浏览器历史记录,只适合调试时应急。
注意:这个写法在生产环境基本别用,`pushState` 会被用户按返回键时踩到。
方法八:lodash.cloneDeep
import cloneDeep from 'lodash/cloneDeep';
const copy = cloneDeep(obj);
功能最全的现成方案:循环引用、`Date`、`RegExp`、`Map`、`Set`、`ArrayBuffer`、Symbol 键、原型链都保留,函数按引用复制(不报错)。代价是引入依赖、体积增加。项目里已经有 lodash 就直接用,没有就为这一个函数单独引,性价比不高。
怎么选
| 场景 | 推荐 |
|---|---|
| 纯 JSON 数据、临时用 | JSON 一行流 |
| 现代浏览器 / Node 17+ | structuredClone |
| 需要保留类实例、函数、原型 | lodash.cloneDeep |
| 需要精细控制类型 | 方法四手写 |
| 老浏览器 + 异步流程 | MessageChannel |
小结
- 优先 `structuredClone`,它覆盖了绝大多数内置类型和循环引用,唯一的硬伤是不能克隆函数。
- JSON 方案只处理纯数据,`Date`、`undefined`、`NaN`、循环引用四类问题必踩。
- 手写递归的关键两点:`WeakMap` 防循环、`Reflect.ownKeys` 保 Symbol 键。
- 所有基于结构化克隆算法的方案(structuredClone / MessageChannel / history.state)共享同一套限制:函数、DOM、Symbol 键、原型链。
- 需要原型和函数时,要么用 lodash,要么手写并显式处理原型:`Object.create(Object.getPrototypeOf(obj))`。
转载请注明出处,版权归原作者所有。
正式会员





