深刻剖析 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

参考资料

相关文章
相关标签/搜索