深入剖析 JavaScript 的深复制

1,943 阅读7分钟
原文链接: jerryzou.com

一年前我曾写过一篇 Javascript 中的一种深复制实现,当时写这篇文章的时候还比较稚嫩,有很多地方没有考虑仔细。为了不误人子弟,我决定结合 Underscore、lodash 和 jQuery 这些主流的第三方库来重新谈一谈这个问题。

第三方库的实现

讲一句唯心主义的话,放之四海而皆准的方法是不存在的,不同的深复制实现方法和实现粒度有各自的优劣以及各自适合的应用场景,所以本文并不是在教大家改如何实现深复制,而是将一些在 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 全局对象的 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+ (Compatibility) & IE9+ (Modern) 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

参考资料

本文的版权归作者 邹润阳 所有,采用 Attribution-NonCommercial 3.0 License。任何人可以进行转载、分享,但不可在未经允许的情况下用于商业用途;转载请注明出处。感谢配合!