前端新人别再被深浅拷贝坑了!一文搞懂JS对象复制的那些坑和骚操作
前端新人别再被深浅拷贝坑了!一文搞懂JS对象复制的那些坑和骚操作
- 前端新人别再被深浅拷贝坑了!一文搞懂JS对象复制的那些坑和骚操作
- 先整明白:JS里为啥会有这种"联动"现象
- 浅拷贝:看着像复制,其实是个快捷方式
- 手写一个浅拷贝,原理一目了然
- ES6提供的现成工具:Object.assign()
- 浅拷贝到底啥时候够用?
- 深拷贝:真·克隆,但实现起来能让你怀疑人生
- 初级版:JSON.parse(JSON.stringify())——新手村神器
- 中级版:递归手写深拷贝——开始上强度了
- 高级版:解决循环引用的终极深拷贝
- 究极版:考虑原型链和属性描述符的深拷贝
- 现代浏览器的大招:structuredClone()
- 基本用法,简单到离谱
- 但structuredClone也不是万能的
- 兼容性处理
- 实战场景:这些坑我替你们踩过了
- 场景1:表单数据备份与重置
- 场景2:Redux/Vuex状态管理中的immutable更新
- 场景3:树形组件的数据操作
- 场景4:JSON序列化前的数据清理
- 场景5:性能优化:别滥用深拷贝
- 一些野路子和骚操作
- 1. 用MessageChannel做异步深拷贝
- 2. 用history对象做深拷贝(黑魔法,别生产环境用)
- 3. 处理函数拷贝的妥协方案
- 4. lodash的cloneDeep——老牌稳妥方案
- 总结:一张图看懂该用哪个
- 写在最后
前端新人别再被深浅拷贝坑了!一文搞懂JS对象复制的那些坑和骚操作
说实话,我当年刚写前端那会儿,被深浅拷贝这事儿坑得那叫一个惨。有一次改了个配置对象,结果页面其他地方莫名其妙跟着崩了,调试了整整一下午,最后发现是"复制"出来的对象在搞事情——我改的是副本,原对象居然也跟着变了!当时我就懵了,这JS的对象复制是有什么大病吗?
后来踩的坑多了才明白,JavaScript里的"复制"俩字,水深得能淹死一头大象。今天咱们就掰开了揉碎了聊聊这事儿,保证让你看完以后,下次再遇到拷贝问题,能拍着胸脯说:“这题我会,而且我会好几种解法!”
先整明白:JS里为啥会有这种"联动"现象
要讲清楚深浅拷贝,得先从JavaScript的基本数据类型说起。JS里数据类型分两派:基本类型(string、number、boolean、null、undefined、symbol、bigint)和引用类型(object、array、function等)。
基本类型存的是值本身,引用类型存的是内存地址。当你搞个对象赋值给另一个变量时,比如这样:
const obj1 = { name: '张三', age: 18 };
const obj2 = obj1;
obj2.name = '李四';
console.log(obj1.name); // 输出"李四",wtf?!
看到没?obj2压根不是新对象,它只是obj1的"别名",就像你给微信好友改了个备注,人家本名并不会变,但你们指向的是同一个人。obj1和obj2都指向内存里的同一块地址,改谁都等于改另一个。
这种直接赋值的方式,连浅拷贝都算不上,纯粹就是"共享对象"。很多新人(包括当年的我)就是栽在这个认知盲区上,以为const obj2 = obj1是复制,结果后面改obj2的时候把原数据也给污染了,调试起来那叫一个酸爽。
浅拷贝:看着像复制,其实是个快捷方式
浅拷贝这玩意儿,说白了就是"一层真复制,多层装糊涂"。它确实创建了新对象,第一层属性也确实是独立的,但要是属性值还是个对象或数组,那不好意思,里面那层依然是共享引用。
手写一个浅拷贝,原理一目了然
最简单的浅拷贝,用for...in循环就能实现:
function shallowClone(obj) {
// 先判断是不是对象,不是对象直接返回(基本类型直接赋值就是复制)
if (typeof obj !== 'object' || obj === null) {
return obj;
}
// 判断是数组还是普通对象,创建对应的新容器
const newObj = Array.isArray(obj) ? [] : {};
// 遍历原对象,把属性挨个复制过去
for (let key in obj) {
// 只复制对象自身的属性,不复制原型链上的
if (obj.hasOwnProperty(key)) {
newObj[key] = obj[key];
}
}
return newObj;
}
// 试试效果
const original = {
name: '王五',
info: {
city: '北京',
hobby: ['coding', '摸鱼']
}
};
const cloned = shallowClone(original);
// 第一层修改,互不影响
cloned.name = '赵六';
console.log(original.name); // 还是"王五",没问题
// 第二层修改,原形毕露
cloned.info.city = '上海';
console.log(original.info.city); // "上海"!原对象也被改了!
cloned.info.hobby.push '加班';
console.log(original.info.hobby); // ["coding", "摸鱼", "加班"],我裂开了
看到问题了吧?info这个嵌套对象在浅拷贝后,新旧对象还是共享同一个引用。这就好比你搬家,只把家具清单复印了一份,但家具本身还在老房子里,你在新家清单上划掉一个沙发,老房子的沙发其实也被搬走了(这个比喻有点抽象,但大概就这意思)。
ES6提供的现成工具:Object.assign()
ES6出了个Object.assign(),专门用来合并对象,顺便也能当浅拷贝用:
const obj1 = { a: 1, b: { c: 2 } };
const obj2 = Object.assign({}, obj1);
obj2.a = 100; // 第一层没事
obj2.b.c = 200; // 深层的c,原对象也跟着变成200了
console.log(obj1.b.c); // 200,淦!
Object.assign()的坑在于:它只对可枚举的自有属性有效,而且同样是浅拷贝。另外,如果属性值是getter/setter,它还会触发getter把值拿出来再塞进去,有时候会有意想不到的副作用。
还有个更骚的写法,用展开运算符...,写起来更清爽:
const obj1 = { x: 1, y: { z: 2 } };
const obj2 = { ...obj1 };
// 效果跟Object.assign一模一样,也是浅拷贝
obj2.y.z = 999;
console.log(obj1.y.z); // 999,熟悉的配方熟悉的味道
数组的浅拷贝就更常见了,slice()、concat()、展开运算符都能用:
const arr1 = [1, 2, { a: 3 }];
const arr2 = arr1.slice();
const arr3 = arr1.concat();
const arr4 = [...arr1];
// 这三个都是浅拷贝,改深层对象照样联动
arr2[2].a = 300;
console.log(arr1[2].a); // 300,毫无意外
浅拷贝到底啥时候够用?
说实话,大部分业务场景浅拷贝就够用了。比如你要改个表单数据,但想保留一份原始数据做对比或重置,如果表单结构只有一层,或者你确定不会动到嵌套对象,那{ ...formData }完全OK,性能还比深拷贝好。
但要是涉及到嵌套配置、树形结构、或者不确定数据层级的情况,浅拷贝就是颗定时炸弹,说不定哪天就给你炸出个线上bug。
深拷贝:真·克隆,但实现起来能让你怀疑人生
深拷贝的目标很明确:不管对象嵌套多深,都要创建完全独立的新对象,新旧对象之间彻底断绝关系,你改你的,我改我的,老死不相往来。
听起来简单,但自己实现一个靠谱的深拷贝,难度堪比让产品经理不改需求。咱们一层层往上堆,看看这里面有多少坑。
初级版:JSON.parse(JSON.stringify())——新手村神器
这是网上流传最广的"一行代码实现深拷贝",写法确实简单粗暴:
const obj = {
name: '张三',
age: 25,
info: {
city: '深圳',
tags: ['程序员', '单身狗']
},
// 还有个日期对象
createTime: new Date()
};
const cloned = JSON.parse(JSON.stringify(obj));
cloned.info.city = '杭州';
console.log(obj.info.city); // 还是"深圳",终于不联动了!
看起来完美对吧?但用这玩意儿是有代价的,而且代价不小:
// 1. 函数会消失
const objWithFunc = {
name: '李四',
sayHi: function() { console.log('hi'); },
arrowFunc: () => 'hello'
};
const clonedFunc = JSON.parse(JSON.stringify(objWithFunc));
console.log(clonedFunc.sayHi); // undefined,函数被吞了
// 2. undefined、Symbol、bigint也没了
const objWithSpecial = {
a: undefined,
b: Symbol('test'),
c: 123n
};
const clonedSpecial = JSON.parse(JSON.stringify(objWithSpecial));
console.log(clonedSpecial); // { c: null },undefined和Symbol直接消失,bigint报错
// 3. 日期对象变成字符串
const objWithDate = { now: new Date() };
const clonedDate = JSON.parse(JSON.stringify(objWithDate));
console.log(typeof clonedDate.now); // "string",不再是Date对象
// 4. 正则表达式、Map、Set、Error对象等,全都会出问题
const complexObj = {
reg: /abc/g,
map: new Map([['key', 'value']]),
set: new Set([1, 2, 3]),
err: new Error('出错了')
};
const clonedComplex = JSON.parse(JSON.stringify(complexObj));
console.log(clonedComplex.reg); // {},空对象,正则信息全丢
console.log(clonedComplex.map); // {},Map变成了普通对象,数据没了
最要命的是循环引用,直接给你报错:
const obj = { name: 'test' };
obj.self = obj; // 循环引用
try {
JSON.parse(JSON.stringify(obj));
} catch (e) {
console.log(e.message); // "Converting circular structure to JSON",直接炸
}
所以JSON.parse(JSON.stringify())这方法,只适合那种纯数据、没函数、没特殊对象、没循环引用的简单场景。比如从接口拿到的配置JSON,想备份一份再修改,用它没问题。但正经项目里,这玩意儿就是个玩具,生产环境用它能坑死你。
中级版:递归手写深拷贝——开始上强度了
既然JSON方法不靠谱,那就自己动手丰衣足食。递归是实现深拷贝的基本思路:遇到基本类型直接返回,遇到对象/数组就创建新的,然后递归处理每个属性。
function deepClone(obj) {
// 处理null、undefined、非对象类型
if (obj === null || typeof obj !== 'object') {
return obj;
}
// 处理日期对象
if (obj instanceof Date) {
return new Date(obj.getTime());
}
// 处理正则表达式
if (obj instanceof RegExp) {
return new RegExp(obj);
}
// 处理数组
if (Array.isArray(obj)) {
const arrCopy = [];
for (let i = 0; i < obj.length; i++) {
arrCopy[i] = deepClone(obj[i]);
}
return arrCopy;
}
// 处理普通对象
const objCopy = {};
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
objCopy[key] = deepClone(obj[key]);
}
}
return objCopy;
}
// 测试一下
const testObj = {
name: '王五',
age: 30,
info: {
address: {
city: '广州',
street: '天河路'
}
},
hobbies: ['读书', '打游戏'],
createdAt: new Date(),
pattern: /test/gi
};
const cloned = deepClone(testObj);
cloned.info.address.city = '深圳';
cloned.hobbies.push '睡觉';
console.log(testObj.info.address.city); // "广州",没问题
console.log(testObj.hobbies); // ["读书", "打游戏"],也没问题
console.log(cloned.createdAt instanceof Date); // true,日期对象保住了
console.log(cloned.pattern instanceof RegExp); // true,正则也保住了
这个版本已经能应付大部分场景了,但还有几个大坑没填:
坑1:循环引用直接栈溢出
const obj = { a: 1 };
obj.circular = obj;
const cloned = deepClone(obj); // RangeError: Maximum call stack size exceeded,浏览器卡死
坑2:没处理Map、Set、Error等对象
const mapObj = {
myMap: new Map([['key1', 'value1']]),
mySet: new Set([1, 2, 3])
};
const clonedMap = deepClone(mapObj);
console.log(clonedMap.myMap instanceof Map); // false,变成了普通对象
坑3:没处理Symbol作为键的情况
const sym = Symbol('test');
const obj = {
[sym]: 'symbol value',
normalKey: 'normal value'
};
const cloned = deepClone(obj);
console.log(cloned[sym]); // undefined,Symbol键丢了
高级版:解决循环引用的终极深拷贝
处理循环引用需要用一个"备忘录"(WeakMap)来记录已经拷贝过的对象,遇到循环引用时直接返回已拷贝的引用,而不是重新递归。
function deepCloneAdvanced(obj, hash = new WeakMap()) {
// 基本类型直接返回
if (obj === null || typeof obj !== 'object') {
return obj;
}
// 如果已经拷贝过,直接返回拷贝后的对象(解决循环引用)
if (hash.has(obj)) {
return hash.get(obj);
}
// 处理各种特殊对象类型
if (obj instanceof Date) {
return new Date(obj.getTime());
}
if (obj instanceof RegExp) {
return new RegExp(obj.source, obj.flags);
}
if (obj instanceof Error) {
return new Error(obj.message);
}
if (obj instanceof Map) {
const mapCopy = new Map();
hash.set(obj, mapCopy); // 先存入hash,防止循环引用
obj.forEach((value, key) => {
mapCopy.set(deepCloneAdvanced(key, hash), deepCloneAdvanced(value, hash));
});
return mapCopy;
}
if (obj instanceof Set) {
const setCopy = new Set();
hash.set(obj, setCopy);
obj.forEach(value => {
setCopy.add(deepCloneAdvanced(value, hash));
});
return setCopy;
}
// 处理数组
if (Array.isArray(obj)) {
const arrCopy = [];
hash.set(obj, arrCopy); // 关键:先存入hash
for (let i = 0; i < obj.length; i++) {
arrCopy[i] = deepCloneAdvanced(obj[i], hash);
}
return arrCopy;
}
// 处理普通对象(包括处理Symbol键)
const objCopy = {};
hash.set(obj, objCopy); // 关键:先存入hash,防止循环引用
// 获取所有键,包括Symbol键
const keys = [
...Object.keys(obj),
...Object.getOwnPropertySymbols(obj)
];
for (let key of keys) {
// 只拷贝可枚举的属性
if (obj.propertyIsEnumerable(key)) {
objCopy[key] = deepCloneAdvanced(obj[key], hash);
}
}
return objCopy;
}
// 测试循环引用
const circularObj = {
name: '循环测试',
info: { value: 100 }
};
circularObj.self = circularObj; // 直接循环
circularObj.info.parent = circularObj; // 间接循环
const clonedCircular = deepCloneAdvanced(circularObj);
console.log(clonedCircular.self === clonedCircular); // true,循环引用保留住了
console.log(clonedCircular.info.parent === clonedCircular); // true,间接循环也OK
clonedCircular.name = '修改后的';
console.log(circularObj.name); // "循环测试",原对象不受影响
// 测试Map和Set
const complexObj = {
myMap: new Map([['a', 1], ['b', { nested: true }]]),
mySet: new Set([1, 2, 3])
};
const clonedComplex = deepCloneAdvanced(complexObj);
clonedComplex.myMap.get('b').nested = false;
console.log(complexObj.myMap.get('b').nested); // true,深拷贝成功
这个版本已经相当完善了,能处理循环引用、各种特殊对象、Symbol键。但还是有极限的,比如:
- 函数拷贝:函数本质上没法真正"深拷贝",因为会涉及作用域链、闭包等问题,一般深拷贝都是直接引用原函数或者返回新函数(但新函数没有原函数的闭包环境)。
- 原型链:上面的实现只拷贝了自有属性,原型链上的方法没拷贝(其实也没必要拷贝,通常用
Object.create(Object.getPrototypeOf(obj))来保留原型)。 - 不可枚举属性:
propertyIsEnumerable判断会漏掉不可枚举属性,用Object.getOwnPropertyNames和Object.getOwnPropertySymbols可以获取所有属性,包括不可枚举的。
究极版:考虑原型链和属性描述符的深拷贝
如果你需要连属性的描述符(writable、enumerable、configurable、getter/setter)都一起拷贝,那得用Object.getOwnPropertyDescriptors:
function ultimateDeepClone(obj, hash = new WeakMap()) {
if (obj === null || typeof obj !== 'object') {
return obj;
}
// 处理循环引用
if (hash.has(obj)) {
return hash.get(obj);
}
// 处理各种特殊类型...(前面一样的代码省略)
// 创建新对象,保留原型链
let cloneObj;
if (Array.isArray(obj)) {
cloneObj = [];
} else {
cloneObj = Object.create(Object.getPrototypeOf(obj));
}
hash.set(obj, cloneObj);
// 获取所有属性描述符
const descriptors = Object.getOwnPropertyDescriptors(obj);
for (let [key, descriptor] of Object.entries(descriptors)) {
const newDescriptor = {};
// 如果有getter/setter,特殊处理(这里简单处理,直接取值)
if (descriptor.get || descriptor.set) {
newDescriptor.get = descriptor.get;
newDescriptor.set = descriptor.set;
newDescriptor.enumerable = descriptor.enumerable;
newDescriptor.configurable = descriptor.configurable;
} else {
// 普通属性,递归拷贝值
newDescriptor.value = deepCloneAdvanced(descriptor.value, hash);
newDescriptor.writable = descriptor.writable;
newDescriptor.enumerable = descriptor.enumerable;
newDescriptor.configurable = descriptor.configurable;
}
Object.defineProperty(cloneObj, key, newDescriptor);
}
// 处理Symbol属性
const symbols = Object.getOwnPropertySymbols(obj);
for (let sym of symbols) {
const descriptor = Object.getOwnPropertyDescriptor(obj, sym);
const newDescriptor = {
value: deepCloneAdvanced(descriptor.value, hash),
writable: descriptor.writable,
enumerable: descriptor.enumerable,
configurable: descriptor.configurable
};
Object.defineProperty(cloneObj, sym, newDescriptor);
}
return cloneObj;
}
这个版本已经逼近JS深拷贝的理论极限了,但代码量也爆炸了。实际项目中,除非你在写框架或工具库,否则没必要搞这么复杂。而且说实话,函数和闭包的问题依然没法完美解决,这是JS语言特性决定的。
现代浏览器的大招:structuredClone()
写到这儿必须提一嘴structuredClone(),这是浏览器原生提供的深拷贝API,2022年左右开始被现代浏览器广泛支持。这玩意儿一出来,前面那些手写深拷贝的代码瞬间就显得有点"考古"了。
基本用法,简单到离谱
const original = {
name: '原生深拷贝',
date: new Date(),
map: new Map([['key', 'value']]),
set: new Set([1, 2, 3]),
buffer: new ArrayBuffer(8),
nested: { a: 1 }
};
// 一行代码,深拷贝完成
const cloned = structuredClone(original);
cloned.nested.a = 999;
cloned.name = '修改后的';
console.log(original.nested.a); // 1,互不影响
console.log(original.name); // "原生深拷贝"
console.log(cloned.date instanceof Date); // true,类型保留
console.log(cloned.map instanceof Map); // true,Map也保留了
看到没?不用写递归,不用处理循环引用,不用判断各种类型,浏览器帮你全搞定了。而且它能正确处理:
- 循环引用(不会栈溢出)
Date、RegExp、Map、Set、ArrayBuffer等内置对象ImageData、Blob、File等二进制数据(在支持的浏览器里)
但structuredClone也不是万能的
首先,它不支持函数:
const objWithFunc = {
data: 'test',
fn: function() { return 'hello'; }
};
try {
structuredClone(objWithFunc);
} catch (e) {
console.log(e.message); // "function() { return 'hello'; } could not be cloned"
}
其次,它不支持DOM节点:
try {
structuredClone({ element: document.body });
} catch (e) {
console.log(e.message); // 报错,DOM节点无法克隆
}
还有原型链会丢失(克隆出来的对象原型是Object.prototype),以及某些浏览器可能还不支持(虽然2024年了主流浏览器都支持了,但老项目还是要考虑兼容性)。
兼容性处理
如果你需要兼容旧浏览器,可以做个兜底:
function safeStructuredClone(obj) {
if (typeof structuredClone === 'function') {
try {
return structuredClone(obj);
} catch (e) {
// 如果structuredClone报错(比如包含函数),降级处理
console.warn('structuredClone failed, falling back to manual clone:', e);
}
}
// 降级到手写深拷贝
return deepCloneAdvanced(obj);
}
实战场景:这些坑我替你们踩过了
光讲原理没意思,来点真实的业务场景,看看深浅拷贝在实际开发中是怎么坑人的。
场景1:表单数据备份与重置
这是最常见的需求,用户填了半天表单,想重置回初始状态。如果你直接const backup = formData,然后让用户修改formData,最后点重置时把backup赋值回去——恭喜你,你重置了个寂寞,因为backup早就被改得面目全非了。
// 错误示范
const initialData = {
username: '',
profile: {
age: 0,
tags: []
}
};
const formData = initialData; // 直接引用,大坑!
// 用户操作
formData.username = '张三';
formData.profile.age = 25;
formData.profile.tags.push '前端';
// 重置时
function resetForm() {
formData = initialData; // 没用!initialData也被改了!
// 或者 formData.username = initialData.username;
// 但嵌套的profile依然是共享引用,tags里还有'前端'
}
// 正确做法:初始化时就深拷贝
const formData = structuredClone(initialData);
// 或者 const formData = JSON.parse(JSON.stringify(initialData)); // 如果数据简单的话
// 重置时
function resetForm() {
// 再深拷贝一次,完全恢复
Object.assign(formData, structuredClone(initialData));
}
场景2:Redux/Vuex状态管理中的immutable更新
在用Redux或者Vuex时,要求状态更新必须是immutable的(不能直接修改原状态,要返回新状态)。这时候浅拷贝和深拷贝的选择就很讲究。
// Redux reducer例子
function userReducer(state = initialState, action) {
switch (action.type) {
case 'UPDATE_NAME':
// 正确:浅拷贝,只改第一层,嵌套对象共享引用(但这里name是第一层,所以没问题)
return {
...state,
name: action.payload
};
case 'UPDATE_ADDRESS':
// 注意:如果直接改state.address,就破坏了immutable原则
// 错误:state.address.city = '新城市'; return state;
// 正确:深拷贝address对象,或者至少浅拷贝address这一层
return {
...state,
address: {
...state.address,
city: action.payload
}
};
case 'ADD_HOBBY':
// 数组也是对象,要拷贝
return {
...state,
hobbies: [...state.hobbies, action.payload]
};
default:
return state;
}
}
看到没?Redux里通常用浅拷贝就够了,因为状态结构是扁平的,每一层都手动展开。但如果你在action里要深层修改,就得一层层展开,或者用immer这种库(immer内部用了Proxy,能帮你处理immutable更新,写起来像直接修改,实际是深拷贝)。
场景3:树形组件的数据操作
比如你要做个组织架构树,每个节点有children数组。复制一个节点插入到别的地方时,如果不做深拷贝,修改新节点的属性会影响原节点。
const treeData = [
{
id: 1,
name: '技术部',
children: [
{ id: 2, name: '前端组', children: [] },
{ id: 3, name: '后端组', children: [] }
]
}
];
// 想把前端组复制到产品部下面
const frontEndTeam = treeData[0].children[0]; // 直接引用!
const productDept = {
id: 4,
name: '产品部',
children: [frontEndTeam] // 这里只是引用,不是复制
};
// 修改产品部下面的前端组名字
productDept.children[0].name = '产品-前端组';
// 完蛋,技术部下面的前端组名字也变了
console.log(treeData[0].children[0].name); // "产品-前端组"
// 正确做法:深拷贝节点
const clonedFrontEnd = structuredClone(treeData[0].children[0]);
clonedFrontEnd.id = 999; // 给个新ID,避免重复
productDept.children = [clonedFrontEnd];
场景4:JSON序列化前的数据清理
有时候你要把数据发给后端,但数据里有些前端用的临时字段(比如_loading、_error、或者函数),需要清理掉。这时候如果直接JSON.stringify会报错或丢失数据,可以先深拷贝再删除。
const submitData = {
name: '项目A',
_internalId: 'temp_123', // 前端临时ID,不需要提交
_isEditing: true, // 前端状态
config: {
value: 100,
_cache: someCacheData // 可能包含循环引用或函数
}
};
// 错误:直接提交,会带上垃圾字段
// api.submit(submitData);
// 正确:深拷贝后清理(用structuredClone会报错因为有_cache可能包含函数)
// 所以这里用JSON方法先转一圈,虽然会丢失函数,但正好是我们想要的
const cleanedData = JSON.parse(JSON.stringify(submitData));
delete cleanedData._internalId;
delete cleanedData._isEditing;
delete cleanedData.config._cache;
api.submit(cleanedData);
场景5:性能优化:别滥用深拷贝
深拷贝是个昂贵的操作,对象越大、嵌套越深,性能消耗越高。有时候可以用浅拷贝+精准修改来优化。
// 假设有个巨大的配置对象
const hugeConfig = {
database: { /* 几百个配置项 */ },
cache: { /* 几百个配置项 */ },
api: {
endpoints: { /* 几百个接口配置 */ },
timeout: 5000
}
};
// 只需要改api.timeout,如果深拷贝整个对象,太浪费了
const newConfig = structuredClone(hugeConfig); // 性能差,没必要
// 优化:浅拷贝+局部深拷贝
const newConfig = {
...hugeConfig,
api: {
...hugeConfig.api,
timeout: 10000 // 只改这一处
}
};
// 这样database和cache还是共享引用(但你不改它们,所以安全),只有api这一层是新对象
一些野路子和骚操作
除了正经的深浅拷贝,还有一些"邪门歪道"(褒义)的方法,特定场景下特别好用。
1. 用MessageChannel做异步深拷贝
structuredClone是同步的,但如果你的数据超级大,阻塞主线程会导致页面卡顿。可以用MessageChannel把深拷贝放到另一个线程(虽然是hack,但确实有效):
function asyncDeepClone(obj) {
return new Promise((resolve) => {
const { port1, port2 } = new MessageChannel();
port2.onmessage = (ev) => resolve(ev.data);
port1.postMessage(obj);
});
}
// 使用
const hugeData = { /* 巨大的对象 */ };
asyncDeepClone(hugeData).then(cloned => {
console.log('异步拷贝完成', cloned);
});
// 原理:postMessage会自动使用structuredClone算法序列化数据,而且是异步的
2. 用history对象做深拷贝(黑魔法,别生产环境用)
浏览器的历史记录APIhistory.pushState会用structuredClone序列化数据,所以你可以利用它:
function hackyClone(obj) {
const oldState = history.state;
history.pushState(obj, '');
const cloned = history.state;
history.replaceState(oldState, '');
return cloned;
}
警告:这玩意儿会污染浏览器历史,而且pushState有频率限制(某些浏览器每秒只能调用几次),纯属娱乐,千万别用!
3. 处理函数拷贝的妥协方案
如果你确实需要拷贝一个包含函数的对象,但又想用structuredClone,可以先把函数转成字符串,拷贝完再转回来(eval警告⚠️):
function cloneWithFunctions(obj) {
// 先把函数存起来
const functionStore = new Map();
let functionId = 0;
// 第一遍遍历,把函数替换成特殊标记
const replacer = (key, value) => {
if (typeof value === 'function') {
const id = `__FUNC_${functionId++}__`;
functionStore.set(id, value.toString());
return id;
}
return value;
};
// 深拷贝(此时函数变成了字符串标记)
const cloned = JSON.parse(JSON.stringify(obj, replacer));
// 第二遍遍历,把标记转回函数(用new Function,注意安全性!)
const reviver = (key, value) => {
if (typeof value === 'string' && value.startsWith('__FUNC_')) {
const funcStr = functionStore.get(value);
// 危险操作!只在可信数据上用!
return new Function('return ' + funcStr)();
}
return value;
};
return JSON.parse(JSON.stringify(cloned), reviver);
}
// 使用
const obj = {
name: 'test',
greet: function() { return 'Hello ' + this.name; }
};
const cloned = cloneWithFunctions(obj);
console.log(cloned.greet()); // "Hello test"
再次警告:new Function和eval一样危险,如果obj来自用户输入,这就是个XSS漏洞。只在完全可信的数据上用!
4. lodash的cloneDeep——老牌稳妥方案
如果你项目里已经用了lodash,那_.cloneDeep是最稳妥的选择,它处理了各种边界情况,而且兼容性极好:
import _ from 'lodash';
const obj = { /* 各种复杂数据 */ };
const cloned = _.cloneDeep(obj);
// 还支持定制拷贝逻辑
const clonedWithCustom = _.cloneDeep(obj, (value) => {
if (value instanceof MyCustomClass) {
return value.clone(); // 自定义克隆逻辑
}
// 返回undefined表示用默认逻辑
});
lodash的源码实现非常经典,如果你想知道一个"工业级"的深拷贝应该怎么写,去github上搜lodash的baseClone.js文件,绝对能让你学到很多骚操作。
总结:一张图看懂该用哪个
写到这儿差不多也该收尾了,最后给个决策树,下次遇到拷贝问题直接对号入座:
需要复制对象吗?
├── 是基本类型(string/number等)→ 直接赋值,没问题
└── 是引用类型(object/array等)
├── 只有一层属性,或确定不会修改嵌套对象 → 浅拷贝({...obj} / Object.assign)
├── 有多层嵌套,需要完全独立
│ ├── 数据简单(纯JSON,无函数、无特殊对象、无循环引用)→ JSON.parse(JSON.stringify())
│ ├── 现代浏览器环境 → structuredClone()(推荐!)
│ ├── 需要兼容旧浏览器,或数据包含函数 → lodash.cloneDeep
│ └── 想自己造轮子,或面试装逼 → 手写递归深拷贝(记得处理循环引用)
└── 包含函数、DOM节点、或需要保留原型链 → 没有完美方案,只能根据场景妥协
写在最后
深浅拷贝这事儿,说简单也简单,说复杂能复杂到写几十行递归。很多前端新人(包括我)都是在踩了坑、加了班、被测试提了几个bug之后,才真正理解这里面的门道。
记住几个关键点:
- 直接赋值不是拷贝,只是共享引用
- 浅拷贝只拷第一层,嵌套对象依然共享
- 深拷贝不是免费的,有性能成本,别滥用
- structuredClone是神器,但注意兼容性
- 没有完美的深拷贝,函数和闭包是永远的痛
下次再有人问你深浅拷贝的区别,别背概念,直接给他看这段代码:
const obj = { a: { b: 1 } };
const shallow = { ...obj };
const deep = structuredClone(obj);
shallow.a.b = 2;
console.log(obj.a.b); // 2,原对象被改了!
deep.a.b = 3;
console.log(obj.a.b); // 还是2,原对象没事
一目了然,比说一堆理论强多了。
好了,这篇从入门到入土(不是)的深浅拷贝指南就到这儿。希望你看完以后,少踩点坑,多留点头发。毕竟前端这行,头发还是挺重要的,对吧?😏







