【每(mei)日一面】實(shi)現(xian)一個深拷(kao)貝(bei)函數(shu)
基礎問答
問:知道淺拷(kao)貝和深拷(kao)貝嗎?為(wei)什么要用深拷(kao)貝?
答:拷貝,可以認為是賦值,對于 JavaScript 中的基礎類型,如 string, number, null, boolean, undefined, symbol 等,在賦值給一個變量的時候,是直接拷貝值給變量,而對于引用類型,如 object, array, function 等,則會拷貝其引用(地址)。
使用(yong)深拷貝,是為了避免操作公共(gong)對(dui)象(xiang)的(de)時候,影響到其他使用(yong)該對(dui)象(xiang)的(de)組件。
擴展延伸
一個拷貝函數(shu),可以直(zhi)接(jie)評(ping)估(gu)出來你對 JavaScript 基礎能力掌握水平。
在理解淺拷貝和深拷貝前,需先明確拷貝的本質。在 JavaScript 中數據類型分為基本類型(string、number、boolean、null、undefined、symbol、bigint)和引用類型(object、array、function 等),這兩種類型在內存(cun)中(zhong)存(cun)儲方(fang)式是(shi)不(bu)一樣的:
- 基本類型:值直接存儲在棧內存中,賦值時直接拷貝值。
- 引用類型:值存儲在堆內存中,棧內存僅存儲指向堆內存的引用地址,賦值時僅拷貝引用地址(而非實際值)。
所以,根據這兩種存儲方式很容易想到,淺拷貝和深拷貝的區別就在于 是否遞歸復制嵌套的引用類型。 這里給出一個簡(jian)單的定義:
- 淺拷貝(Shallow Copy):僅復制對象的表層屬性,若屬性值為引用類型(如嵌套對象、數組),則拷貝的是引用地址(引用地址就是表層屬性),新舊對象共享嵌套數據。
- 深拷貝(Deep Copy):遞歸復制對象的所有屬性,包括嵌套的引用類型,新舊對象完全獨立,修改拷貝后的對象不會影響原始對象的數據。
實現方式
淺拷貝
淺拷貝適用于無嵌套引用類型或無需獨立嵌套數據的場景,實現方式簡單,性能開銷(xiao)小。
- 淺拷貝對象
Object.assign()
Object.assign(target, ...sources)方法將源對象的可枚舉屬性復制到目標對象,最后返回的是目標對象,使用這個方法時要注意:該方法僅拷貝對象自身屬性(不包含繼承屬性),嵌套的對象僅拷貝引用,示例如下:
const obj = { a: 1, b: { c: 2 } };
const shallowCopy = Object.assign({}, obj);
// 測試基本類型屬性:修改不影響原對象
shallowCopy.a = 100;
console.log(obj.a); // 輸出:1(原對象不變)
// 測試嵌套對象:修改會影響原對象
shallowCopy.b.c = 200;
console.log(obj.b.c); // 輸出:200(原對象被修改)
- 淺拷貝數組
Array.prototype.slice()和Array.prototype.concat()
這兩個方法返回的都是新數組(不在原數組上操作),示例如下:
const arr = [1, [2, 3]];
const shallowCopy1 = arr.slice(0); // 方法1:slice
const shallowCopy2 = [].concat(arr); // 方法2:concat
// 測試基本類型元素:修改不影響原數組
shallowCopy1[0] = 100;
console.log(arr[0]); // 輸出:1(原數組不變)
// 測試嵌套數組:修改會影響原數組
shallowCopy2[1][0] = 200;
console.log(arr[1][0]); // 輸出:200(原數組被修改)
- 擴展運算符
...
這個是 es6 新增的運算符,可以用于對象和數組的淺拷貝,語法相較于上面兩種方式比較簡單,示例如下:
// 對象淺拷貝
const obj = { a: 1, b: { c: 2 } };
const shallowObj = { ...obj };
// 數組淺拷貝
const arr = [1, [2, 3]];
const shallowArr = [...arr];
深拷貝
深拷貝適用于包含嵌套引用類型且需要完全獨立副本的場景,實現復雜度較高(gao),需處理(li)遞(di)歸、循環引用等邊界(jie)情況。屬于前端(duan)八股(gu)面試必須準備(bei)的一個問題(ti)。
- 序列化方式拷貝
JSON.parse(JSON.stringify())
利用 JSON 序列化與反序列化實現深拷貝,語法簡單,多數時候夠用。
const obj = { a: 1, b: { c: 2 }, d: [3, 4] };
const deepCopy = JSON.parse(JSON.stringify(obj));
// 測試嵌套對象:修改不影響原對象
deepCopy.b.c = 200;
console.log(obj.b.c); // 輸出:2(原對象不變)
但是(shi)這個方(fang)式有一定的局(ju)限性:
- 不能拷貝函數(JSON不支持)
- 不能拷貝
undefined,Symbol類型 - 不能處理循環引用
- 不支持
BigInt類型 - 對于日期對象和正則對象,有特殊處理,解析后可能得不到我們想要的結果
- 自定義實現拷貝函數
思路:遍歷對象,每一次遍歷過程中判斷是否是引用類型(對象或數組),如果是,則遞歸的調用拷貝函數,若不是,則直接賦值進行下一步。
function deepCopy(target) {
// 基本類型直接返回
if (target === null || typeof target !== 'object') {
return target;
}
// 區分數組和對象
let copy;
if (Array.isArray(target)) {
copy = [];
} else {
copy = {};
}
// 遍歷屬性并遞歸拷貝
for (const key in target) {
if (target.hasOwnProperty(key)) {
// 遞歸處理引用類型
copy[key] = deepCopy(target[key]);
}
}
return copy;
}
// 測試
const obj = { a: 1, b: { c: 2 }, d: [3, 4] };
const copyObj = deepCopy(obj);
copyObj.b.c = 200;
console.log(obj, copyObj, obj === copyObj, obj.b.c); // 對比輸出結果,可以發現兩個對象是不同的
copyObj.d[0] = 300;
console.log(obj, copyObj, obj === copyObj, obj.d[0]); // 同上
但是(shi)這個沒有處(chu)理邊界情(qing)(qing)況,主要是(shi)兩種情(qing)(qing)況:
-
循環應用
循環引用指對象引用自身(如obj.self = obj),直接遞歸會導致無限循環棧溢出。可以用WeakMap存儲已拷貝的對象,避免在遞(di)歸過程中重(zhong)復(fu)拷貝。 -
特殊對象
類似于 Date,RegExp 的對象,需要我們手動特殊處理(根據類型直接 new)
完整的(de)深拷貝(bei)示例:
function deepCopy(target, hash = new WeakMap()) {
// 基本類型直接返回
if (target === null || typeof target !== 'object') {
return target;
}
// 處理循環引用:若已拷貝過,直接返回緩存的副本
if (hash.has(target)) {
return hash.get(target);
}
let copy;
// 處理Date
if (target instanceof Date) {
copy = new Date(target);
hash.set(target, copy);
return copy;
}
// 處理RegExp
if (target instanceof RegExp) {
copy = new RegExp(target.source, target.flags);
copy.lastIndex = target.lastIndex; // 保留lastIndex屬性
hash.set(target, copy);
return copy;
}
// 處理數組和對象
if (Array.isArray(target)) {
copy = [];
} else {
// 處理普通對象(包括自定義對象)
copy = new target.constructor(); // 保持原型鏈
}
// 緩存已拷貝的對象,解決循環引用
hash.set(target, copy);
// 遍歷屬性并遞歸拷貝
// 處理Map
if (target instanceof Map) {
target.forEach((value, key) => {
copy.set(key, deepCopy(value, hash));
});
return copy;
}
// 處理Set
if (target instanceof Set) {
target.forEach(value => {
copy.add(deepCopy(value, hash));
});
return copy;
}
// 處理普通對象和數組的屬性
for (const key in target) {
if (target.hasOwnProperty(key)) {
copy[key] = deepCopy(target[key], hash);
}
}
return copy;
}
// 測試循環引用
const obj = { name: 'test' };
obj.self = obj; // 循環引用
const copyObj = deepCopy(obj);
console.log(copyObj.self === copyObj, copyObj === obj, obj, copyObj);
// 測試特殊對象
const date = new Date();
const copyDate = deepCopy(date);
console.log(copyDate instanceof Date, copyDate === date, date, copyDate);
const reg = /abc/gim;
reg.lastIndex = 10;
const copyReg = deepCopy(reg);
console.log(copyReg, reg);
差異對比
這里我簡單總(zong)結一個(ge)表來讓(rang)你快速理解二者異同:
| 對比方向 | 淺拷貝 | 深拷貝 |
|---|---|---|
| 拷貝層級 | 僅拷貝對象表層屬性 | 遞歸拷貝所有層級(包括嵌套的引用類型) |
| 內存占用 | 較小(共享嵌套對象的內存) | 較大(完全復制所有數據,獨立占用內存) |
| 性能開銷 | 低(無需遞歸,操作簡單) | 高(遞歸處理,需處理邊界情況) |
| 拷貝前后對象的獨立性 | 表層屬性獨立,嵌套引用類型共享 | 完全獨立,新舊對象無任何關聯 |
| 適用場景 | 無嵌套引用類型、性能優先、無需獨立嵌套數據的情況,簡單來說,不需要前后獨立的,都可以直接用淺拷貝 | 有嵌套引用類型、需完全隔離數據、修改不能相互影響的情況 |
| 實現復雜度 | 簡單(可通過原生方法或簡單遍歷實現) | 復雜(需處理遞歸、循環引用、特殊對象類型) |
面試追問
-
直接使用
=賦值算淺拷貝還是深拷貝?
都不是,賦值運算符只是將一個值或者引用賦給一個變量,對于基本類型,賦值運算符是直接復制這個值給變量,對于引用類型,賦值運算符則是復制引用給變量,而非對象本身。
這個和淺拷(kao)貝(bei)的定義(yi)略有差異。 -
實現一個淺拷貝函數?
思路就(jiu)是,直接(jie)遍歷淺層對象(第一層),賦給(gei)新的對象。
function shallowCopy(target) {
// 區分目標是數組還是對象
if (Array.isArray(target)) {
const copy = [];
for (let i = 0; i < target.length; i++) {
copy[i] = target[i];
}
return copy;
} else if (target !== null && typeof target === 'object') {
const copy = {};
// 僅拷貝自身可枚舉屬性
for (const key in target) {
if (target.hasOwnProperty(key)) {
copy[key] = target[key];
}
}
return copy;
} else {
// 基本類型直接返回(無需拷貝)
return target;
}
}
// 測試
const obj = { a: 1, b: { c: 2 }, d: [3, 4] };
const copyObj = shallowCopy(obj);
copyObj.b.c = 200;
console.log(obj.b.c); // 輸出:200(嵌套對象共享引用)
-
深拷貝的時候,怎么特殊處理函數類型?
函數屬于引用類型,通常不需要深拷貝,因為函數體是改不了的,通常直接復制引用就行了。
如果面試時強烈要求你深拷貝,可以直接使用toString()+eval實現,但可能隨之而來的會將話題轉到eval上來問(wen)詞法作(zuo)用域、嚴格模式、安全問(wen)題等等,一(yi)般是來轉換個話題。 -
實(shi)際開發的(de)時候(hou),有經常用這兩(liang)種模式嗎?舉個場景(jing)說明(ming)一下(xia)
- 前端分頁,displayData 通常是直接通過 slice 獲取原始列表的一部分數據,由于不需要操作,所以也不需要深拷貝
- 接口傳參,有時候我們為了方便,會在請求數據信息之后,直接將這個返回的對象賦值給某個地方,之后再提交的時候,由于接口要求的信息不同,我們有可能會直接操作這個返回對象,導致使用返回對象的地方出現變化,這種情況就需要深拷貝。
本(ben)文首發于(yu),公眾(zhong)號訂閱請關注:

