如何寫出一個驚豔面試官的深複製?

ConardLi發表於2019-09-02

導讀

最近經常看到很多JavaScript手寫程式碼的文章總結,裡面提供了很多JavaScript Api的手寫實現。

裡面的題目實現大多類似,而且說實話很多程式碼在我看來是非常簡陋的,如果我作為面試官,看到這樣的程式碼,在我心裡是不會合格的,本篇文章我拿最簡單的深複製來講一講。

看本文之前先問自己三個問題:

  • 你真的理解什麼是深複製嗎?
  • 在面試官眼裡,什麼樣的深複製才算合格?
  • 什麼樣的深複製能讓面試官感到驚豔?

本文由淺入深,帶你一步一步實現一個驚豔面試官的深複製。

本文測試程式碼:https://github.com/ConardLi/C...

例如:程式碼clone到本地後,執行 node clone1.test.js檢視測試結果。

建議結合測試程式碼一起閱讀效果更佳。

深複製和淺複製的定義

深複製已經是一個老生常談的話題了,也是現在前端面試的高頻題目,但是令我吃驚的是有很多同學還沒有搞懂深複製和淺複製的區別和定義。例如前幾天給我提issue的同學:

很明顯這位同學把複製和賦值搞混了,如果你還對賦值、物件在記憶體中的儲存、變數和型別等等有什麼疑問,可以看看我這篇文章:https://juejin.im/post/5cec1b...

你只要少搞明白複製賦值的區別。

我們來明確一下深複製和淺複製的定義:

淺複製:

建立一個新物件,這個物件有著原始物件屬性值的一份精確複製。如果屬性是基本型別,複製的就是基本型別的值,如果屬性是引用型別,複製的就是記憶體地址 ,所以如果其中一個物件改變了這個地址,就會影響到另一個物件。

深複製:

將一個物件從記憶體中完整的複製一份出來,從堆記憶體中開闢一個新的區域存放新物件,且修改新物件不會影響原物件

話不多說,淺複製就不再多說,下面我們直入正題:

乞丐版

在不使用第三方庫的情況下,我們想要深複製一個物件,用的最多的就是下面這個方法。

JSON.parse(JSON.stringify());

這種寫法非常簡單,而且可以應對大部分的應用場景,但是它還是有很大缺陷的,比如複製其他引用型別、複製函式、迴圈引用等情況。

顯然,面試時你只說出這樣的方法是一定不會合格的。

接下來,我們一起來手動實現一個深複製方法。

基礎版本

如果是淺複製的話,我們可以很容易寫出下面的程式碼:

function clone(target) {
    let cloneTarget = {};
    for (const key in target) {
        cloneTarget[key] = target[key];
    }
    return cloneTarget;
};

建立一個新的物件,遍歷需要克隆的物件,將需要克隆物件的屬性依次新增到新物件上,返回。

如果是深複製的話,考慮到我們要複製的物件是不知道有多少層深度的,我們可以用遞迴來解決問題,稍微改寫上面的程式碼:

  • 如果是原始型別,無需繼續複製,直接返回
  • 如果是引用型別,建立一個新的物件,遍歷需要克隆的物件,將需要克隆物件的屬性執行深複製後依次新增到新物件上。

很容易理解,如果有更深層次的物件可以繼續遞迴直到屬性為原始型別,這樣我們就完成了一個最簡單的深複製:

function clone(target) {
    if (typeof target === 'object') {
        let cloneTarget = {};
        for (const key in target) {
            cloneTarget[key] = clone(target[key]);
        }
        return cloneTarget;
    } else {
        return target;
    }
};

我們可以開啟測試程式碼中的clone1.test.js對下面的測試用例進行測試:

const target = {
    field1: 1,
    field2: undefined,
    field3: 'ConardLi',
    field4: {
        child: 'child',
        child2: {
            child2: 'child2'
        }
    }
};

執行結果:

這是一個最基礎版本的深複製,這段程式碼可以讓你向面試官展示你可以用遞迴解決問題,但是顯然,他還有非常多的缺陷,比如,還沒有考慮陣列。

考慮陣列

在上面的版本中,我們的初始化結果只考慮了普通的object,下面我們只需要把初始化程式碼稍微一變,就可以相容陣列了:

module.exports = function clone(target) {
    if (typeof target === 'object') {
        let cloneTarget = Array.isArray(target) ? [] : {};
        for (const key in target) {
            cloneTarget[key] = clone(target[key]);
        }
        return cloneTarget;
    } else {
        return target;
    }
};

clone2.test.js中執行下面的測試用例:

const target = {
    field1: 1,
    field2: undefined,
    field3: {
        child: 'child'
    },
    field4: [2, 4, 8]
};

執行結果:

OK,沒有問題,你的程式碼又向合格邁進了一小步。

迴圈引用

我們執行下面這樣一個測試用例:

const target = {
    field1: 1,
    field2: undefined,
    field3: {
        child: 'child'
    },
    field4: [2, 4, 8]
};
target.target = target;

可以看到下面的結果:

很明顯,因為遞迴進入死迴圈導致棧記憶體溢位了。

原因就是上面的物件存在迴圈引用的情況,即物件的屬性間接或直接的引用了自身的情況:

解決迴圈引用問題,我們可以額外開闢一個儲存空間,來儲存當前物件和複製物件的對應關係,當需要複製當前物件時,先去儲存空間中找,有沒有複製過這個物件,如果有的話直接返回,如果沒有的話繼續複製,這樣就巧妙化解的迴圈引用的問題。

這個儲存空間,需要可以儲存key-value形式的資料,且key可以是一個引用型別,我們可以選擇Map這種資料結構:

  • 檢查map中有無克隆過的物件
  • 有 - 直接返回
  • 沒有 - 將當前物件作為key,克隆物件作為value進行儲存
  • 繼續克隆
function clone(target, map = new Map()) {
    if (typeof target === 'object') {
        let cloneTarget = Array.isArray(target) ? [] : {};
        if (map.get(target)) {
            return map.get(target);
        }
        map.set(target, cloneTarget);
        for (const key in target) {
            cloneTarget[key] = clone(target[key], map);
        }
        return cloneTarget;
    } else {
        return target;
    }
};

再來執行上面的測試用例:

可以看到,執行沒有報錯,且target屬性,變為了一個Circular型別,即迴圈應用的意思。

接下來,我們可以使用,WeakMap提代Map來使程式碼達到畫龍點睛的作用。

function clone(target, map = new WeakMap()) {
    // ...
};

為什麼要這樣做呢?,先來看看WeakMap的作用:

WeakMap 物件是一組鍵/值對的集合,其中的鍵是弱引用的。其鍵必須是物件,而值可以是任意的。

什麼是弱引用呢?

在計算機程式設計中,弱引用與強引用相對,是指不能確保其引用的物件不會被垃圾回收器回收的引用。 一個物件若只被弱引用所引用,則被認為是不可訪問(或弱可訪問)的,並因此可能在任何時刻被回收。

我們預設建立一個物件:const obj = {},就預設建立了一個強引用的物件,我們只有手動將obj = null,它才會被垃圾回收機制進行回收,如果是弱引用物件,垃圾回收機制會自動幫我們回收。

舉個例子:

如果我們使用Map的話,那麼物件間是存在強引用關係的:

let obj = { name : 'ConardLi'}
const target = new Map();
target.set(obj,'code秘密花園');
obj = null;

雖然我們手動將obj,進行釋放,然是target依然對obj存在強引用關係,所以這部分記憶體依然無法被釋放。

再來看WeakMap

let obj = { name : 'ConardLi'}
const target = new WeakMap();
target.set(obj,'code秘密花園');
obj = null;

如果是WeakMap的話,targetobj存在的就是弱引用關係,當下一次垃圾回收機制執行時,這塊記憶體就會被釋放掉。

設想一下,如果我們要複製的物件非常龐大時,使用Map會對記憶體造成非常大的額外消耗,而且我們需要手動清除Map的屬性才能釋放這塊記憶體,而WeakMap會幫我們巧妙化解這個問題。

我也經常在某些程式碼中看到有人使用WeakMap來解決迴圈引用問題,但是解釋都是模稜兩可的,當你不太瞭解WeakMap的真正作用時。我建議你也不要在面試中寫這樣的程式碼,結果只能是給自己挖坑,即使是準備面試,你寫的每一行程式碼也都是需要經過深思熟慮並且非常明白的。

能考慮到迴圈引用的問題,你已經向面試官展示了你考慮問題的全面性,如果還能用WeakMap解決問題,並很明確的向面試官解釋這樣做的目的,那麼你的程式碼在面試官眼裡應該算是合格了。

效能最佳化

在上面的程式碼中,我們遍歷陣列和物件都使用了for in這種方式,實際上for in在遍歷時效率是非常低的,我們來對比下常見的三種迴圈for、while、for in的執行效率:

可以看到,while的效率是最好的,所以,我們可以想辦法把for in遍歷改變為while遍歷。

我們先使用while來實現一個通用的forEach遍歷,iteratee是遍歷的回掉函式,他可以接收每次遍歷的valueindex兩個引數:

function forEach(array, iteratee) {
    let index = -1;
    const length = array.length;
    while (++index < length) {
        iteratee(array[index], index);
    }
    return array;
}

下面對我們的cloen函式進行改寫:當遍歷陣列時,直接使用forEach進行遍歷,當遍歷物件時,使用Object.keys取出所有的key進行遍歷,然後在遍歷時把forEach會調函式的value當作key使用:

function clone(target, map = new WeakMap()) {
    if (typeof target === 'object') {
        const isArray = Array.isArray(target);
        let cloneTarget = isArray ? [] : {};

        if (map.get(target)) {
            return map.get(target);
        }
        map.set(target, cloneTarget);

        const keys = isArray ? undefined : Object.keys(target);
        forEach(keys || target, (value, key) => {
            if (keys) {
                key = value;
            }
            cloneTarget[key] = clone2(target[key], map);
        });

        return cloneTarget;
    } else {
        return target;
    }
}

下面,我們執行clone4.test.js分別對上一個克隆函式和改寫後的克隆函式進行測試:

const target = {
    field1: 1,
    field2: undefined,
    field3: {
        child: 'child'
    },
    field4: [2, 4, 8],
    f: { f: { f: { f: { f: { f: { f: { f: { f: { f: { f: { f: {} } } } } } } } } } } },
};

target.target = target;

console.time();
const result = clone1(target);
console.timeEnd();

console.time();
const result2 = clone2(target);
console.timeEnd();

執行結果:

很明顯,我們的效能最佳化是有效的。

到這裡,你已經向面試官展示了,在寫程式碼的時候你會考慮程式的執行效率,並且你具有通用函式的抽象能力。

其他資料型別

在上面的程式碼中,我們其實只考慮了普通的objectarray兩種資料型別,實際上所有的引用型別遠遠不止這兩個,還有很多,下面我們先嚐試獲取物件準確的型別。

合理的判斷引用型別

首先,判斷是否為引用型別,我們還需要考慮functionnull兩種特殊的資料型別:

function isObject(target) {
    const type = typeof target;
    return target !== null && (type === 'object' || type === 'function');
}
    if (!isObject(target)) {
        return target;
    }
    // ...

獲取資料型別

我們可以使用toString來獲取準確的引用型別:

每一個引用型別都有toString方法,預設情況下,toString()方法被每個Object物件繼承。如果此方法在自定義物件中未被覆蓋,toString() 返回 "[object type]",其中type是物件的型別。

注意,上面提到了如果此方法在自定義物件中未被覆蓋,toString才會達到預想的效果,事實上,大部分引用型別比如Array、Date、RegExp等都重寫了toString方法。

我們可以直接呼叫Object原型上未被覆蓋的toString()方法,使用call來改變this指向來達到我們想要的效果。

function getType(target) {
    return Object.prototype.toString.call(target);
}

下面我們抽離出一些常用的資料型別以便後面使用:

const mapTag = '[object Map]';
const setTag = '[object Set]';
const arrayTag = '[object Array]';
const objectTag = '[object Object]';

const boolTag = '[object Boolean]';
const dateTag = '[object Date]';
const errorTag = '[object Error]';
const numberTag = '[object Number]';
const regexpTag = '[object RegExp]';
const stringTag = '[object String]';
const symbolTag = '[object Symbol]';

在上面的集中型別中,我們簡單將他們分為兩類:

  • 可以繼續遍歷的型別
  • 不可以繼續遍歷的型別

我們分別為它們做不同的複製。

可繼續遍歷的型別

上面我們已經考慮的objectarray都屬於可以繼續遍歷的型別,因為它們記憶體都還可以儲存其他資料型別的資料,另外還有MapSet等都是可以繼續遍歷的型別,這裡我們只考慮這四種,如果你有興趣可以繼續探索其他型別。

有序這幾種型別還需要繼續進行遞迴,我們首先需要獲取它們的初始化資料,例如上面的[]{},我們可以透過拿到constructor的方式來通用的獲取。

例如:const target = {}就是const target = new Object()的語法糖。另外這種方法還有一個好處:因為我們還使用了原物件的構造方法,所以它可以保留物件原型上的資料,如果直接使用普通的{},那麼原型必然是丟失了的。

function getInit(target) {
    const Ctor = target.constructor;
    return new Ctor();
}

下面,我們改寫clone函式,對可繼續遍歷的資料型別進行處理:

function clone(target, map = new WeakMap()) {

    // 克隆原始型別
    if (!isObject(target)) {
        return target;
    }

    // 初始化
    const type = getType(target);
    let cloneTarget;
    if (deepTag.includes(type)) {
        cloneTarget = getInit(target, type);
    }

    // 防止迴圈引用
    if (map.get(target)) {
        return map.get(target);
    }
    map.set(target, cloneTarget);

    // 克隆set
    if (type === setTag) {
        target.forEach(value => {
            cloneTarget.add(clone(value,map));
        });
        return cloneTarget;
    }

    // 克隆map
    if (type === mapTag) {
        target.forEach((value, key) => {
            cloneTarget.set(key, clone(value,map));
        });
        return cloneTarget;
    }

    // 克隆物件和陣列
    const keys = type === arrayTag ? undefined : Object.keys(target);
    forEach(keys || target, (value, key) => {
        if (keys) {
            key = value;
        }
        cloneTarget[key] = clone(target[key], map);
    });

    return cloneTarget;
}

我們執行clone5.test.js對下面的測試用例進行測試:

const target = {
    field1: 1,
    field2: undefined,
    field3: {
        child: 'child'
    },
    field4: [2, 4, 8],
    empty: null,
    map,
    set,
};

執行結果:

沒有問題,裡大功告成又進一步,下面我們繼續處理其他型別:

不可繼續遍歷的型別

其他剩餘的型別我們把它們統一歸類成不可處理的資料型別,我們依次進行處理:

BoolNumberStringStringDateError這幾種型別我們都可以直接用建構函式和原始資料建立一個新物件:

function cloneOtherType(targe, type) {
    const Ctor = targe.constructor;
    switch (type) {
        case boolTag:
        case numberTag:
        case stringTag:
        case errorTag:
        case dateTag:
            return new Ctor(targe);
        case regexpTag:
            return cloneReg(targe);
        case symbolTag:
            return cloneSymbol(targe);
        default:
            return null;
    }
}

克隆Symbol型別:

function cloneSymbol(targe) {
    return Object(Symbol.prototype.valueOf.call(targe));
}

克隆正則:

function cloneReg(targe) {
    const reFlags = /\w*$/;
    const result = new targe.constructor(targe.source, reFlags.exec(targe));
    result.lastIndex = targe.lastIndex;
    return result;
}

實際上還有很多資料型別我這裡沒有寫到,有興趣的話可以繼續探索實現一下。

能寫到這裡,面試官已經看到了你考慮問題的嚴謹性,你對變數和型別的理解,對JS API的熟練程度,相信面試官已經開始對你刮目相看了。

克隆函式

最後,我把克隆函式單獨拎出來了,實際上克隆函式是沒有實際應用場景的,兩個物件使用一個在記憶體中處於同一個地址的函式也是沒有任何問題的,我特意看了下lodash對函式的處理:

 const isFunc = typeof value == 'function'
 if (isFunc || !cloneableTags[tag]) {
        return object ? value : {}
 }

可見這裡如果發現是函式的話就會直接返回了,沒有做特殊的處理,但是我發現不少面試官還是熱衷於問這個問題的,而且據我瞭解能寫出來的少之又少。。。

實際上這個方法並沒有什麼難度,主要就是考察你對基礎的掌握紮實不紮實。

首先,我們可以透過prototype來區分下箭頭函式和普通函式,箭頭函式是沒有prototype的。

我們可以直接使用eval和函式字串來重新生成一個箭頭函式,注意這種方法是不適用於普通函式的。

我們可以使用正則來處理普通函式:

分別使用正則取出函式體和函式引數,然後使用new Function ([arg1[, arg2[, ...argN]],] functionBody)建構函式重新構造一個新的函式:

function cloneFunction(func) {
    const bodyReg = /(?<={)(.|\n)+(?=})/m;
    const paramReg = /(?<=\().+(?=\)\s+{)/;
    const funcString = func.toString();
    if (func.prototype) {
        console.log('普通函式');
        const param = paramReg.exec(funcString);
        const body = bodyReg.exec(funcString);
        if (body) {
            console.log('匹配到函式體:', body[0]);
            if (param) {
                const paramArr = param[0].split(',');
                console.log('匹配到引數:', paramArr);
                return new Function(...paramArr, body[0]);
            } else {
                return new Function(body[0]);
            }
        } else {
            return null;
        }
    } else {
        return eval(funcString);
    }
}

最後,我們再來執行clone6.test.js對下面的測試用例進行測試:

const map = new Map();
map.set('key', 'value');
map.set('ConardLi', 'code秘密花園');

const set = new Set();
set.add('ConardLi');
set.add('code秘密花園');

const target = {
    field1: 1,
    field2: undefined,
    field3: {
        child: 'child'
    },
    field4: [2, 4, 8],
    empty: null,
    map,
    set,
    bool: new Boolean(true),
    num: new Number(2),
    str: new String(2),
    symbol: Object(Symbol(1)),
    date: new Date(),
    reg: /\d+/,
    error: new Error(),
    func1: () => {
        console.log('code秘密花園');
    },
    func2: function (a, b) {
        return a + b;
    }
};

執行結果:

最後

為了更好的閱讀,我們用一張圖來展示上面所有的程式碼:

完整程式碼:https://github.com/ConardLi/C...

可見,一個小小的深複製還是隱藏了很多的知識點的。

千萬不要以最低的要求來要求自己,如果你只是為了應付面試中的一個題目,那麼你可能只會去準備上面最簡陋的深複製的方法。

但是面試官考察你的目的是全方位的考察你的思維能力,如果你寫出上面的程式碼,可以體現你多方位的能力:

  • 基本實現

    • 遞迴能力
  • 迴圈引用

    • 考慮問題的全面性
    • 理解weakmap的真正意義
  • 多種型別

    • 考慮問題的嚴謹性
    • 建立各種引用型別的方法,JS API的熟練程度
    • 準確的判斷資料型別,對資料型別的理解程度
  • 通用遍歷:

    • 寫程式碼可以考慮效能最佳化
    • 瞭解集中遍歷的效率
    • 程式碼抽象能力
  • 複製函式:

    • 箭頭函式和普通函式的區別
    • 正規表示式熟練程度

看吧,一個小小的深複製能考察你這麼多的能力,如果面試官看到這樣的程式碼,怎麼能夠不驚豔呢?

其實面試官出的所有題目你都可以用這樣的思路去考慮。不要為了應付面試而去背一些程式碼,這樣在有經驗的面試官面前會都會暴露出來。你寫的每一段程式碼都要經過深思熟慮,為什麼要這樣用,還能怎麼最佳化...這樣才能給面試官展現一個最好的你。

參考

小結

希望看完本篇文章能對你有如下幫助:

  • 理解深淺複製的真正意義
  • 能整我深複製的各個要點,對問題進行深入分析
  • 可以手寫一個比較完整的深複製

文中如有錯誤,歡迎在評論區指正,如果這篇文章幫助到了你,歡迎點贊和關注。

想閱讀更多優質文章、可關注我的github部落格,你的star✨、點贊和關注是我持續創作的動力!

推薦關注我的微信公眾號【code秘密花園】,每天推送高質量文章,我們一起交流成長。

圖片描述

相關文章