【進階4-3期】面試題之如何實現一個深拷貝

木易楊說發表於2019-01-21

引言

上篇文章詳細介紹了淺拷貝 Object.assign,並對其進行了模擬實現,在實現的過程中,介紹了很多基礎知識。今天這篇文章我們來看看一道必會面試題,即如何實現一個深拷貝。本文會詳細介紹物件、陣列、迴圈引用、引用丟失、Symbol 和遞迴爆棧等情況下的深拷貝實踐,歡迎閱讀。

第一步:簡單實現

其實深拷貝可以拆分成 2 步,淺拷貝 + 遞迴,淺拷貝時判斷屬性值是否是物件,如果是物件就進行遞迴操作,兩個一結合就實現了深拷貝。

根據上篇文章內容,我們可以寫出簡單淺拷貝程式碼如下。

// 木易楊
function cloneShallow(source) {
    var target = {};
    for (var key in source) {
        if (Object.prototype.hasOwnProperty.call(source, key)) {
            target[key] = source[key];
        }
    }
    return target;
}

// 測試用例
var a = {
    name: "muyiy",
    book: {
        title: "You Don't Know JS",
        price: "45"
    },
    a1: undefined,
    a2: null,
    a3: 123
}
var b = cloneShallow(a);

a.name = "高階前端進階";
a.book.price = "55";

console.log(b);
// { 
//   name: 'muyiy', 
//   book: { title: 'You Don\'t Know JS', price: '55' },
//   a1: undefined,
//   a2: null,
//   a3: 123
// }
複製程式碼

上面程式碼是淺拷貝實現,只要稍微改動下,加上是否是物件的判斷並在相應的位置使用遞迴就可以實現簡單深拷貝。

// 木易楊
function cloneDeep1(source) {
    var target = {};
    for(var key in source) {
        if (Object.prototype.hasOwnProperty.call(source, key)) {
            if (typeof source[key] === 'object') {
                target[key] = cloneDeep1(source[key]); // 注意這裡
            } else {
                target[key] = source[key];
            }
        }
    }
    return target;
}

// 使用上面測試用例測試一下
var b = cloneDeep1(a);
console.log(b);
// { 
//   name: 'muyiy', 
//   book: { title: 'You Don\'t Know JS', price: '45' }, 
//   a1: undefined,
//   a2: {},
//   a3: 123
// }
複製程式碼

一個簡單的深拷貝就完成了,但是這個實現還存在很多問題。

  • 1、沒有對傳入引數進行校驗,傳入 null 時應該返回 null 而不是 {}

  • 2、對於物件的判斷邏輯不嚴謹,因為 typeof null === 'object'

  • 3、沒有考慮陣列的相容

第二步:拷貝陣列

我們來看下對於物件的判斷,之前在【進階3-3期】有過介紹,判斷方案如下。

// 木易楊
function isObject(obj) {
    return Object.prototype.toString.call(obj) === '[object Object]';
}
複製程式碼

但是用在這裡並不合適,因為我們要保留陣列這種情況,所以這裡使用 typeof 來處理。

// 木易楊
typeof null //"object"
typeof {} //"object"
typeof [] //"object"
typeof function foo(){} //"function" (特殊情況)
複製程式碼

改動過後的 isObject 判斷邏輯如下。

// 木易楊
function isObject(obj) {
	return typeof obj === 'object' && obj != null;
}
複製程式碼

所以相容陣列的寫法如下。

// 木易楊
function cloneDeep2(source) {

    if (!isObject(source)) return source; // 非物件返回自身
      
    var target = Array.isArray(source) ? [] : {};
    for(var key in source) {
        if (Object.prototype.hasOwnProperty.call(source, key)) {
            if (isObject(source[key])) {
                target[key] = cloneDeep2(source[key]); // 注意這裡
            } else {
                target[key] = source[key];
            }
        }
    }
    return target;
}

// 使用上面測試用例測試一下
var b = cloneDeep2(a);
console.log(b);
// { 
//   name: 'muyiy', 
//   book: { title: 'You Don\'t Know JS', price: '45' },
//   a1: undefined,
//   a2: null,
//   a3: 123
// }
複製程式碼

第三步:迴圈引用

我們知道 JSON 無法深拷貝迴圈引用,遇到這種情況會丟擲異常。

// 木易楊
// 此處 a 是文章開始的測試用例
a.circleRef = a;

JSON.parse(JSON.stringify(a));
// TypeError: Converting circular structure to JSON
複製程式碼

1、使用雜湊表

解決方案很簡單,其實就是迴圈檢測,我們設定一個陣列或者雜湊表儲存已拷貝過的物件,當檢測到當前物件已存在於雜湊表中時,取出該值並返回即可。

// 木易楊
function cloneDeep3(source, hash = new WeakMap()) {

    if (!isObject(source)) return source; 
    if (hash.has(source)) return hash.get(source); // 新增程式碼,查雜湊表
      
    var target = Array.isArray(source) ? [] : {};
    hash.set(source, target); // 新增程式碼,雜湊表設值
    
    for(var key in source) {
        if (Object.prototype.hasOwnProperty.call(source, key)) {
            if (isObject(source[key])) {
                target[key] = cloneDeep3(source[key], hash); // 新增程式碼,傳入雜湊表
            } else {
                target[key] = source[key];
            }
        }
    }
    return target;
}
複製程式碼

測試一下,看看效果如何。

// 木易楊
// 此處 a 是文章開始的測試用例
a.circleRef = a;

var b = cloneDeep3(a);
console.log(b);
// {
// 	name: "muyiy",
// 	a1: undefined,
//	a2: null,
// 	a3: 123,
// 	book: {title: "You Don't Know JS", price: "45"},
// 	circleRef: {name: "muyiy", book: {…}, a1: undefined, a2: null, a3: 123, …}
// }
複製程式碼

完美!

2、使用陣列

這裡使用了ES6 中的 WeakMap 來處理,那在 ES5 下應該如何處理呢?

也很簡單,使用陣列來處理就好啦,程式碼如下。

// 木易楊
function cloneDeep3(source, uniqueList) {

    if (!isObject(source)) return source; 
    if (!uniqueList) uniqueList = []; // 新增程式碼,初始化陣列
      
    var target = Array.isArray(source) ? [] : {};
    
    // ============= 新增程式碼
    // 資料已經存在,返回儲存的資料
    var uniqueData = find(uniqueList, source);
    if (uniqueData) {
        return uniqueData.target;
    };
        
    // 資料不存在,儲存源資料,以及對應的引用
    uniqueList.push({
        source: source,
        target: target
    });
    // =============

    for(var key in source) {
        if (Object.prototype.hasOwnProperty.call(source, key)) {
            if (isObject(source[key])) {
                target[key] = cloneDeep3(source[key], uniqueList); // 新增程式碼,傳入陣列
            } else {
                target[key] = source[key];
            }
        }
    }
    return target;
}

// 新增方法,用於查詢
function find(arr, item) {
    for(var i = 0; i < arr.length; i++) {
        if (arr[i].source === item) {
            return arr[i];
        }
    }
    return null;
}

// 用上面測試用例已測試通過
複製程式碼

現在已經很完美的解決了迴圈引用這種情況,那其實還是一種情況是引用丟失,我們看下面的例子。

// 木易楊
var obj1 = {};
var obj2 = {a: obj1, b: obj1};

obj2.a === obj2.b; 
// true

var obj3 = cloneDeep2(obj2);
obj3.a === obj3.b; 
// false
複製程式碼

引用丟失在某些情況下是有問題的,比如上面的物件 obj2,obj2 的鍵值 a 和 b 同時引用了同一個物件 obj1,使用 cloneDeep2 進行深拷貝後就丟失了引用關係變成了兩個不同的物件,那如何處理呢。

其實你有沒有發現,我們的 cloneDeep3 已經解決了這個問題,因為只要儲存已拷貝過的物件就可以了。

// 木易楊
var obj3 = cloneDeep3(obj2);
obj3.a === obj3.b; 
// true
複製程式碼

完美!

第四步:拷貝 Symbol

這個時候可能要搞事情了,那我們能不能拷貝 Symol 型別呢?

當然可以,不過 SymbolES6 下才有,我們需要一些方法來檢測出 Symble 型別。

方法一:Object.getOwnPropertySymbols(...)

方法二:Reflect.ownKeys(...)

對於方法一可以查詢一個給定物件的符號屬性時返回一個 ?symbol 型別的陣列。注意,每個初始化的物件都是沒有自己的 symbol 屬性的,因此這個陣列可能為空,除非你已經在物件上設定了 symbol 屬性。(來自MDN)

var obj = {};
var a = Symbol("a"); // 建立新的symbol型別
var b = Symbol.for("b"); // 從全域性的symbol註冊?表設定和取得symbol

obj[a] = "localSymbol";
obj[b] = "globalSymbol";

var objectSymbols = Object.getOwnPropertySymbols(obj);

console.log(objectSymbols.length); // 2
console.log(objectSymbols)         // [Symbol(a), Symbol(b)]
console.log(objectSymbols[0])      // Symbol(a)
複製程式碼

對於方法二返回一個由目標物件自身的屬性鍵組成的陣列。它的返回值等同於Object.getOwnPropertyNames(target).concat(Object.getOwnPropertySymbols(target))。(來自MDN)

Reflect.ownKeys({z: 3, y: 2, x: 1}); // [ "z", "y", "x" ]
Reflect.ownKeys([]); // ["length"]

var sym = Symbol.for("comet");
var sym2 = Symbol.for("meteor");
var obj = {[sym]: 0, "str": 0, "773": 0, "0": 0,
           [sym2]: 0, "-1": 0, "8": 0, "second str": 0};
Reflect.ownKeys(obj);
// [ "0", "8", "773", "str", "-1", "second str", Symbol(comet), Symbol(meteor) ]
// 注意順序
// Indexes in numeric order, 
// strings in insertion order, 
// symbols in insertion order
複製程式碼

方法一

思路就是先查詢有沒有 Symbol 屬性,如果查詢到則先遍歷處理 Symbol 情況,然後再處理正常情況,多出來的邏輯就是下面的新增程式碼。

// 木易楊
function cloneDeep4(source, hash = new WeakMap()) {

    if (!isObject(source)) return source; 
    if (hash.has(source)) return hash.get(source); 
      
    let target = Array.isArray(source) ? [] : {};
    hash.set(source, target);
    
    // ============= 新增程式碼
    let symKeys = Object.getOwnPropertySymbols(source); // 查詢
    if (symKeys.length) { // 查詢成功	
        symKeys.forEach(symKey => {
            if (isObject(source[symKey])) {
                target[symKey] = cloneDeep4(source[symKey], hash); 
            } else {
                target[symKey] = source[symKey];
            }    
        });
    }
    // =============
    
    for(let key in source) {
        if (Object.prototype.hasOwnProperty.call(source, key)) {
            if (isObject(source[key])) {
                target[key] = cloneDeep4(source[key], hash); 
            } else {
                target[key] = source[key];
            }
        }
    }
    return target;
}
複製程式碼

測試下效果

// 木易楊
// 此處 a 是文章開始的測試用例
var sym1 = Symbol("a"); // 建立新的symbol型別
var sym2 = Symbol.for("b"); // 從全域性的symbol註冊?表設定和取得symbol

a[sym1] = "localSymbol";
a[sym2] = "globalSymbol";

var b = cloneDeep4(a);
console.log(b);
// {
// 	name: "muyiy",
// 	a1: undefined,
//	a2: null,
// 	a3: 123,
// 	book: {title: "You Don't Know JS", price: "45"},
// 	circleRef: {name: "muyiy", book: {…}, a1: undefined, a2: null, a3: 123, …},
//  [Symbol(a)]: 'localSymbol',
//  [Symbol(b)]: 'globalSymbol'
// }
複製程式碼

完美!

方法二

// 木易楊
function cloneDeep4(source, hash = new WeakMap()) {

    if (!isObject(source)) return source; 
    if (hash.has(source)) return hash.get(source); 
      
    let target = Array.isArray(source) ? [] : {};
    hash.set(source, target);
    
  	Reflect.ownKeys(source).forEach(key => { // 改動
        if (isObject(source[key])) {
            target[key] = cloneDeep4(source[key], hash); 
        } else {
            target[key] = source[key];
        }  
  	});
    return target;
}

// 測試已通過
複製程式碼

這裡使用了 Reflect.ownKeys() 獲取所有的鍵值,同時包括 Symbol,對 source 遍歷賦值即可。

寫到這裡已經差不多了,我們再延伸下,對於 target 換一種寫法,改動如下。

// 木易楊
function cloneDeep4(source, hash = new WeakMap()) {

    if (!isObject(source)) return source; 
    if (hash.has(source)) return hash.get(source); 
      
    let target = Array.isArray(source) ? [...source] : { ...source }; // 改動 1
    hash.set(source, target);
    
  	Reflect.ownKeys(target).forEach(key => { // 改動 2
        if (isObject(source[key])) {
            target[key] = cloneDeep4(source[key], hash); 
        } else {
            target[key] = source[key];
        }  
  	});
    return target;
}

// 測試已通過
複製程式碼

在改動 1 中,返回一個新陣列或者新物件,獲取到源物件之後就可以如改動 2 所示傳入 target 遍歷賦值即可。

Reflect.ownKeys() 這種方式的問題在於不能深拷貝原型鏈上的資料,因為返回的是目標物件自身的屬性鍵組成的陣列。如果想深拷貝原型鏈上的資料怎麼辦,那用 for..in 就可以了。

我們再介紹下兩個知識點,分別是構造字面量陣列時使用展開語法構造字面量物件時使用展開語法。(以下程式碼示例來源於 MDN)

1、展開語法之字面量陣列

這是 ES2015 (ES6) 才有的語法,可以通過字面量方式, 構造新陣列,而不再需要組合使用 push, splice, concat 等方法。

var parts = ['shoulders', 'knees']; 
var lyrics = ['head', ...parts, 'and', 'toes']; 
// ["head", "shoulders", "knees", "and", "toes"]
複製程式碼

這裡的使用方法和引數列表的展開有點類似。

function myFunction(v, w, x, y, z) { }
var args = [0, 1];
myFunction(-1, ...args, 2, ...[3]);
複製程式碼

返回的是新陣列,對新陣列修改之後不會影響到舊陣列,類似於 arr.slice()

var arr = [1, 2, 3];
var arr2 = [...arr]; // like arr.slice()
arr2.push(4); 

// arr2 此時變成 [1, 2, 3, 4]
// arr 不受影響
複製程式碼

展開語法和 Object.assign() 行為一致, 執行的都是淺拷貝(即只遍歷一層)。

var a = [[1], [2], [3]];
var b = [...a];
b.shift().shift(); // 1
// [[], [2], [3]]
複製程式碼

這裡 a 是多層陣列,b 只拷貝了第一層,對於第二層依舊和 a 持有同一個地址,所以對 b 的修改會影響到 a。

2、展開語法之字面量物件

這是 ES2018 才有的語法,將已有物件的所有可列舉屬性拷貝到新構造的物件中,類似於 Object.assign() 方法。

var obj1 = { foo: 'bar', x: 42 };
var obj2 = { foo: 'baz', y: 13 };

var clonedObj = { ...obj1 };
// { foo: "bar", x: 42 }

var mergedObj = { ...obj1, ...obj2 };
// { foo: "baz", x: 42, y: 13 }
複製程式碼

Object.assign() 函式會觸發 setters,而展開語法不會。有時候不能替換或者模擬 Object.assign() 函式,因為會得到意想不到的結果,如下所示。

var obj1 = { foo: 'bar', x: 42 };
var obj2 = { foo: 'baz', y: 13 };
const merge = ( ...objects ) => ( { ...objects } );

var mergedObj = merge ( obj1, obj2);
// { 0: { foo: 'bar', x: 42 }, 1: { foo: 'baz', y: 13 } }

var mergedObj = merge ( {}, obj1, obj2);
// { 0: {}, 1: { foo: 'bar', x: 42 }, 2: { foo: 'baz', y: 13 } }
複製程式碼

這裡實際上是將多個解構變為剩餘引數( rest ),然後再將剩餘引數展開為字面量物件.

第五步:破解遞迴爆棧

上面四步使用的都是遞迴方法,但是有一個問題在於會爆棧,錯誤提示如下。

// RangeError: Maximum call stack size exceeded
複製程式碼

那應該如何解決呢?其實我們使用迴圈就可以了,程式碼如下。

function cloneDeep5(x) {
    const root = {};

    // 棧
    const loopList = [
        {
            parent: root,
            key: undefined,
            data: x,
        }
    ];

    while(loopList.length) {
        // 廣度優先
        const node = loopList.pop();
        const parent = node.parent;
        const key = node.key;
        const data = node.data;

        // 初始化賦值目標,key為undefined則拷貝到父元素,否則拷貝到子元素
        let res = parent;
        if (typeof key !== 'undefined') {
            res = parent[key] = {};
        }

        for(let k in data) {
            if (data.hasOwnProperty(k)) {
                if (typeof data[k] === 'object') {
                    // 下一次迴圈
                    loopList.push({
                        parent: res,
                        key: k,
                        data: data[k],
                    });
                } else {
                    res[k] = data[k];
                }
            }
        }
    }

    return root;
}
複製程式碼

由於篇幅問題就不過多介紹了,詳情請參考下面這篇文章。

深拷貝的終極探索(99%的人都不知道)

本期思考題

如何用 JS 實現 JSON.parse?

參考

深入剖析 JavaScript 的深複製

深拷貝的終極探索(99%的人都不知道)

深入 js 深拷貝物件

MDN 之展開語法

MDN 之 Symbol

進階系列目錄

  • 【進階1期】 呼叫堆疊
  • 【進階2期】 作用域閉包
  • 【進階3期】 this全面解析
  • 【進階4期】 深淺拷貝原理
  • 【進階5期】 原型Prototype
  • 【進階6期】 高階函式
  • 【進階7期】 事件機制
  • 【進階8期】 Event Loop原理
  • 【進階9期】 Promise原理
  • 【進階10期】Async/Await原理
  • 【進階11期】防抖/節流原理
  • 【進階12期】模組化詳解
  • 【進階13期】ES6重難點
  • 【進階14期】計算機網路概述
  • 【進階15期】瀏覽器渲染原理
  • 【進階16期】webpack配置
  • 【進階17期】webpack原理
  • 【進階18期】前端監控
  • 【進階19期】跨域和安全
  • 【進階20期】效能優化
  • 【進階21期】VirtualDom原理
  • 【進階22期】Diff演算法
  • 【進階23期】MVVM雙向繫結
  • 【進階24期】Vuex原理
  • 【進階25期】Redux原理
  • 【進階26期】路由原理
  • 【進階27期】VueRouter原始碼解析
  • 【進階28期】ReactRouter原始碼解析

交流

進階系列文章彙總如下,內有優質前端資料,覺得不錯點個star。

github.com/yygmind/blo…

我是木易楊,公眾號「高階前端進階」作者,跟著我每週重點攻克一個前端面試重難點。接下來讓我帶你走進高階前端的世界,在進階的路上,共勉!

【進階4-3期】面試題之如何實現一個深拷貝

相關文章