深刻剖析 JavaScript 的深複製

本文最初發佈於個人我的博客:咀嚼之味javascript

一年前我曾寫過一篇 Javascript 中的一種深複製實現,當時寫這篇文章的時候還比較稚嫩,有不少地方沒有考慮仔細。爲了避免誤人子弟,我決定結合 Underscore、lodash 和 jQuery 這些主流的第三方庫來從新談一談這個問題。前端

第三方庫的實現

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

Underscore —— _.clone()

在 Underscore 中有這樣一個方法:_.clone(),這個方法其實是一種淺複製 (shallow-copy),全部嵌套的對象和數組都是直接複製引用而並無進行深複製。來看一下例子應該會更加直觀:jquery

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 的源碼git

// 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 #L1006github

Underscore 的 clone() 不能算做深複製,但它至少比直接賦值來得「深」一些,它建立了一個新的對象。另外,你也能夠經過如下比較 tricky 的方法來完成單層嵌套的深複製:json

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, {}, ...)就能夠實現深複製啦,參考下面的例子:api

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 全局對象的 parsestringify 方法來實現深複製也算是一個簡單討巧的方法。然而使用這種方法會有一些隱藏的坑,它能正確處理的對象只有 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) {
                obj[keys[i]] = prop;
            }
            else if (!prop.$isFunction()) {
                if (prop.$isPlainObject()) {
                    index = srcStack.lastIndexOf(prop);
                    if (index > 0) {
                        obj[keys[i]] = dstStack[index];
                        continue;
                    }
                }
                obj[keys[i]] = prop.$clone(srcStack, dstStack);
            }
        }
        return obj;
    }
});

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

defineMethods([ Array.prototype ], {
    '$clone': function (srcStack, dstStack) {
        var thisArr = this.valueOf(),
            newArr = [],
            keys = Object.keys(thisArr),
            index,
            element;

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

        for (var i = 0; i < keys.length; i++) {
            element = thisArr[keys[i]];
            if (element === undefined || element === null) {
                newArr[keys[i]] = element;
            } else if (!element.$isFunction()) {
                if (element.$isPlainObject()) {
                    index = srcStack.lastIndexOf(element);
                    if (index > 0) {
                        newArr[keys[i]] = dstStack[index];
                        continue;
                    }
                }
            }
            newArr[keys[i]] = element.$clone(srcStack, dstStack);
        }
        return newArr;
    }
});

接下來要針對 Date 和 RegExp 對象的深複製進行一些特殊處理:

/*=====================================*
 * Date.prototype
 * - $clone
 *=====================================*/

defineMethods([ Date.prototype ], {
    '$clone': function() { return new Date(this.valueOf()); }
});

/*=====================================*
 * RegExp.prototype
 * - $clone
 *=====================================*/

defineMethods([ RegExp.prototype ], {
    '$clone': function () {
        var pattern = this.valueOf();
        var flags = '';
        flags += pattern.global ? 'g' : '';
        flags += pattern.ignoreCase ? 'i' : '';
        flags += pattern.multiline ? 'm' : '';
        return new RegExp(pattern.source, flags);
    }
});

接下來就是 Number, Boolean 和 String 的 $clone 方法,雖然很簡單,但這也是必不可少的。這樣就能防止像單個字符串這樣的對象錯誤地去調用 Object.prototype.$clone

/*=====================================*
 * Number / Boolean / String.prototype
 * - $clone()
 *=====================================*/

defineMethods([
    Number.prototype,
    Boolean.prototype,
    String.prototype
], {
    '$clone': function() { return this.valueOf(); }
});

比較各個深複製方法

特性 jQuery lodash JSON.parse 所謂「擁抱將來的深複製實現」
瀏覽器兼容性 IE6+ (1.x) & IE9+ (2.x) IE6+ IE8+ IE9+
可以深複製存在環的對象 拋出異常 RangeError: Maximum call stack size exceeded 支持 拋出異常 TypeError: Converting circular structure to JSON 支持
對 Date, RegExp 的深複製支持 × 支持 × 支持
對 ES6 新引入的標準對象的深複製支持 × 支持 × ×
複製數組的屬性 × 僅支持RegExp#exec返回的數組結果 × 支持
是否保留非源生對象的類型 × × × 支持
複製不可枚舉元素 × × × ×
複製函數 × × × ×

執行效率

爲了測試各類深複製方法的執行效率,我使用了以下的測試用例:

var x = {};
for (var i = 0; i < 1000; i++) {
    x[i] = {};
    for (var j = 0; j < 1000; j++) {
        x[i][j] = Math.random();
    }
}

var start = Date.now();
var y = clone(x);
console.log(Date.now() - start);

下面來看看各個實現方法的具體效率如何,我所使用的瀏覽器是 Mac 上的 Chrome 43.0.2357.81 (64-bit) 版本,能夠看出來在3次的實驗中,我所實現的方法比 lodash 稍遜一籌,但比jQuery的效率也會高一些。但願這篇文章對大家有幫助~

深複製方法 jQuery lodash JSON.parse 所謂「擁抱將來的深複製實現」
Test 1 475 341 630 320
Test 2 505 270 690 345
Test 3 456 268 650 332
Average 478.7 293 656.7 332.3

參考資料

相關文章
相關標籤/搜索