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

引言

上篇文章詳細介紹了淺拷貝 Object.assign,並對其進行了模擬實現,在實現的過程當中,介紹了不少基礎知識。今天這篇文章咱們來看看一道必會面試題,即如何實現一個深拷貝。本文會詳細介紹對象、數組、循環引用、引用丟失、Symbol 和遞歸爆棧等狀況下的深拷貝實踐,歡迎閱讀。javascript

第一步:簡單實現

其實深拷貝能夠拆分紅 2 步,淺拷貝 + 遞歸,淺拷貝時判斷屬性值是不是對象,若是是對象就進行遞歸操做,兩個一結合就實現了深拷貝。前端

根據上篇文章內容,咱們能夠寫出簡單淺拷貝代碼以下。java

// 木易楊
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
// }

上面代碼是淺拷貝實現,只要稍微改動下,加上是不是對象的判斷並在相應的位置使用遞歸就能夠實現簡單深拷貝。node

// 木易楊
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
// }

一個簡單的深拷貝就完成了,可是這個實現還存在不少問題。webpack

  • 一、沒有對傳入參數進行校驗,傳入 null 時應該返回 null 而不是 {}
  • 二、對於對象的判斷邏輯不嚴謹,由於 typeof null === 'object'
  • 三、沒有考慮數組的兼容

第二步:拷貝數組

咱們來看下對於對象的判斷,以前在【進階3-3期】有過介紹,判斷方案以下。git

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

可是用在這裏並不合適,由於咱們要保留數組這種狀況,因此這裏使用 typeof 來處理。github

// 木易楊
typeof null //"object"
typeof {} //"object"
typeof [] //"object"
typeof function foo(){} //"function" (特殊狀況)

改動事後的 isObject 判斷邏輯以下。web

// 木易楊
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

一、使用哈希表

解決方案很簡單,其實就是循環檢測,咱們設置一個數組或者哈希表存儲已拷貝過的對象,當檢測到當前對象已存在於哈希表中時,取出該值並返回便可。

// 木易楊
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, …}
// }

完美!

二、使用數組

這裏使用了 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)

一、展開語法之字面量數組

這是 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。

二、展開語法之字面量對象

這是 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。

https://github.com/yygmind/blog

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

相關文章
相關標籤/搜索