1. 程式人生 > >深入剖析 JavaScript 的深複製

深入剖析 JavaScript 的深複製

第三方庫的實現

講一句唯心主義的話,放之四海而皆準的方法是不存在的,不同的深複製實現方法和實現粒度有各自的優劣以及各自適合的應用場景,所以本文並不是在教大家改如何實現深複製,而是將一些在 JavaScript 中實現深複製所需要考慮的問題呈獻給大家。我們首先從較為簡單的 Underscore 開始:

Underscore —— _.clone()

在 Underscore 中有這樣一個方法:_.clone(),這個方法實際上是一種淺複製 (shallow-copy),所有巢狀的物件和陣列都是直接複製引用而並沒有進行深複製。來看一下例子應該會更加直觀:

var x = {
    a:
1, b: { z: 0 } }; var y = _.clone(x); y === x // false y.b === x.b // true x.b.z = 100; y.b.z // 100

讓我們來看一下 Underscore 的原始碼

// Create a (shallow-cloned) duplicate of an object.
_.clone = function(obj) {
  if (!_.isObject(obj)) return obj;
  return _.isArray(obj) ? obj.slice() : _.extend
({}, obj); };

如果目標物件是一個數組,則直接呼叫陣列的slice()方法,否則就是用_.extend()方法。想必大家對extend()方法不會陌生,它的作用主要是將從第二個引數開始的所有物件,按鍵值逐個賦給第一個物件。而在 jQuery 中也有類似的方法。關於 Underscore 中的 _.extend() 方法的實現可以參考 underscore.js #L1006

Underscore 的 clone() 不能算作深複製,但它至少比直接賦值來得“深”一些,它建立了一個新的物件。另外,你也可以通過以下比較 tricky 的方法來完成單層巢狀的深複製:

var _ =
require('underscore'); var a = [{f: 1}, {f:5}, {f:10}]; var b = _.map(a, _.clone); // <---- b[1].f = 55; console.log(JSON.stringify(a)); // [{"f":1},{"f":5},{"f":10}]

jQuery —— $.clone() / $.extend()

在 jQuery 中也有這麼一個叫 $.clone() 的方法,可是它並不是用於一般的 JS 物件的深複製,而是用於 DOM 物件。這不是這篇文章的重點,所以感興趣的同學可以參考jQuery的文件。與 Underscore 類似,我們也是可以通過 $.extend() 方法來完成深複製。值得慶幸的是,我們在 jQuery 中可以通過新增一個引數來實現遞迴extend。呼叫$.extend(true, {}, ...)就可以實現深複製啦,參考下面的例子:

var x = {
    a: 1,
    b: { f: { g: 1 } },
    c: [ 1, 2, 3 ]
};

var y = $.extend({}, x),          //shallow copy
    z = $.extend(true, {}, x);    //deep copy

y.b.f === x.b.f       // true
z.b.f === x.b.f       // false

在 jQuery的原始碼 - src/core.js #L121 檔案中我們可以找到$.extend()的實現,也是實現得比較簡潔,而且不太依賴於 jQuery 的內建函式,稍作修改就能拿出來單獨使用。

lodash —— _.clone() / _.cloneDeep()

在lodash中關於複製的方法有兩個,分別是_.clone()_.cloneDeep()。其中_.clone(obj, true)等價於_.cloneDeep(obj)。使用上,lodash和前兩者並沒有太大的區別,但看了原始碼會發現,Underscore 的實現只有30行左右,而 jQuery 也不過60多行。可 lodash 中與深複製相關的程式碼卻有上百行,這是什麼道理呢?

var $ = require("jquery"),
    _ = require("lodash");

var arr = new Int16Array(5),
    obj = { a: arr },
    obj2;
arr[0] = 5;
arr[1] = 6;

// 1. jQuery
obj2 = $.extend(true, {}, obj);
console.log(obj2.a);                            // [5, 6, 0, 0, 0]
Object.prototype.toString.call(obj2);           // [object Int16Array]
obj2.a[0] = 100;
console.log(obj);                               // [100, 6, 0, 0, 0]

//此處jQuery不能正確處理Int16Array的深複製!!!

// 2. lodash
obj2 = _.cloneDeep(obj);                       
console.log(obj2.a);                            // [5, 6, 0, 0, 0]
Object.prototype.toString.call(arr2);           // [object Int16Array]
obj2.a[0] = 100;
console.log(obj);                               // [5, 6, 0, 0, 0]

通過上面這個例子可以初見端倪,jQuery 無法正確深複製 JSON 物件以外的物件,而我們可以從下面這段程式碼片段可以看出 lodash 花了大量的程式碼來實現 ES6 引入的大量新的標準物件。更厲害的是,lodash 針對存在環的物件的處理也是非常出色的。因此相較而言,lodash 在深複製上的行為反饋比前兩個庫好很多,是更擁抱未來的一個第三方庫。

/** `Object#toString` result references. */
var argsTag = '[object Arguments]',
    arrayTag = '[object Array]',
    boolTag = '[object Boolean]',
    dateTag = '[object Date]',
    errorTag = '[object Error]',
    funcTag = '[object Function]',
    mapTag = '[object Map]',
    numberTag = '[object Number]',
    objectTag = '[object Object]',
    regexpTag = '[object RegExp]',
    setTag = '[object Set]',
    stringTag = '[object String]',
    weakMapTag = '[object WeakMap]';

var arrayBufferTag = '[object ArrayBuffer]',
    float32Tag = '[object Float32Array]',
    float64Tag = '[object Float64Array]',
    int8Tag = '[object Int8Array]',
    int16Tag = '[object Int16Array]',
    int32Tag = '[object Int32Array]',
    uint8Tag = '[object Uint8Array]',
    uint8ClampedTag = '[object Uint8ClampedArray]',
    uint16Tag = '[object Uint16Array]',
    uint32Tag = '[object Uint32Array]';

藉助 JSON 全域性物件

相比於上面介紹的三個庫的做法,針對純 JSON 資料物件的深複製,使用 JSON 全域性物件的 parse 和 stringify 方法來實現深複製也算是一個簡單討巧的方法。然而使用這種方法會有一些隱藏的坑,它能正確處理的物件只有 Number, String, Boolean, Array, 扁平物件,即那些能夠被 json 直接表示的資料結構。

function jsonClone(obj) {
    return JSON.parse(JSON.stringify(obj));
}
var clone = jsonClone({ a:1 });

擁抱未來的深複製方法

我自己實現了一個深複製的方法,因為用到了Object.createObject.isPrototypeOf等比較新的方法,所以基本只能在 IE9+ 中使用。而且,我的實現是直接定義在 prototype 上的,很有可能引起大多數的前端同行們的不適。(關於這個我還曾在知乎上提問過:為什麼不要直接在Object.prototype上定義方法?)只是實驗性質的,大家參考一下就好,改成非 prototype 版本也是很容易的,不過就是要不斷地去判斷物件的型別了。~

這個實現方法具體可以看我寫的一個小玩意兒——Cherry.js,使用方法大概是這樣的:

function X() {
    this.x = 5;
    this.arr = [1,2,3];
}
var obj = { d: new Date(), r: /abc/ig, x: new X(), arr: [1,2,3] },
    obj2,
    clone;

obj.x.xx = new X();
obj.arr.testProp = "test";
clone = obj.$clone();                  //<----

首先定義一個輔助函式,用於在預定義物件的 Prototype 上定義方法:

function defineMethods(protoArray, nameToFunc) {
    protoArray.forEach(function(proto) {
        var names = Object.keys(nameToFunc),
            i = 0;

        for (; i < names.length; i++) {
            Object.defineProperty(proto, names[i], {
                enumerable: false,
                configurable: true,
                writable: true,
                value: nameToFunc[names[i]]
            });
        }
    });
}

為了避免和源生方法衝突,我在方法名前加了一個 $ 符號。而這個方法的具體實現很簡單,就是遞迴深複製。其中我需要解釋一下兩個引數:srcStackdstStack。它們的主要用途是對存在環的物件進行深複製。比如源物件中的子物件srcStack[7]在深複製以後,對應於dstStack[7]。該實現方法參考了 lodash 的實現。關於遞迴最重要的就是 Object 和 Array 物件:

/*=====================================*
 * Object.prototype
 * - $clone()
*=====================================*/

defineMethods([ Object.prototype ], {
    '$clone': function (srcStack, dstStack) {
        var obj = Object.create(Object.getPrototypeOf(this)),
            keys = Object.keys(this),
            index,
            prop;

        srcStack = srcStack || [];
        dstStack = dstStack || [];
        srcStack.push(this);
        dstStack.push(obj);

        for (var i = 0; i < keys.length; i++) {
            prop = this[keys[i]];
            if (prop === null || prop === undefined) {