JavaScript的深拷贝和浅拷贝

来源:https://segmentfault.com/a/1190000017469386

一直想梳理下工作中经常会用到的深拷贝的内容,然而遍览了许多的文章,却发现对深拷贝并没有一个通用的完美实现方式。因为对深拷贝的定义不同,实现时的edge case过多,在深拷贝的时候会出现循环引用等问题,导致JS内部并没有实现深拷贝,但是我们可以来探究一下深拷贝到底有多复杂,各种实现方式的优缺点,同时参考下常用库对其的实现。

引用类型

之所以会出现深浅拷贝的问题,实质上是由于JS对基本类型和引用类型的处理不同。基本类型指的是简单的数据段,而引用类型指的是一个对象,而JS不允许我们直接操作内存中的地址,也就是不能操作对象的内存空间,所以,我们对对象的操作都只是在操作它的引用而已。

在复制时也是一样,如果我们复制一个基本类型的值时,会创建一个新值,并把它保存在新的变量的位置上。而如果我们复制一个引用类型时,同样会把变量中的值复制一份放到新的变量空间里,但此时复制的东西并不是对象本身,而是指向该对象的指针。所以我们复制引用类型后,两个变量其实指向同一个对象,改变其中一个对象,会影响到另外一个。

var num = 10;
var obj = {
    name: 'Nicholas'
}

var num2 = num;
var obj2 = obj;

obj.name = 'Lee';
obj2.name; // 'Lee'

 

浅拷贝

如果我们要复制对象的所有属性都不是引用类型时,就可以使用浅拷贝,实现方式就是遍历并复制,最后返回新的对象。

function shallowCopy(obj) {
    var copy = {};
    // 只复制可遍历的属性
    for (key in obj) {
        // 只复制本身拥有的属性
        if (obj.hasOwnProperty(key)) {
            copy[key] = obj[key];
        }
    }
    return copy;
}

如上面所说,我们使用浅拷贝会复制所有引用对象的指针,而不是具体的值,所以使用时一定要明确自己的需求,同时,浅拷贝的实现也是最简单的。

JS内部实现了浅拷贝,如Object.assign(),其中第一个参数是我们最终复制的目标对象,后面的所有参数是我们的即将复制的源对象,支持对象或数组,一般调用的方式为

var newObj = Object.assign({}, originObj);

深拷贝

如果我们需要复制一个拥有所有属性和方法的新对象,就要用到深拷贝,JS并没有内置深拷贝方法,主要是因为:

  1. 深拷贝怎么定义?我们怎么处理原型?怎么区分可拷贝的对象?原生DOM/BOM对象怎么拷贝?函数是新建还是引用?这些edge case太多导致我们无法统一概念,造出大家都满意的深拷贝方法来。
  2. 内部循环引用怎么处理,是不是保存每个遍历过的对象列表,每次进行对比,然后再造一个循环引用来?这样带来的性能消耗可以接受吗。

解释一些常见的问题概念,防止有些同学不明白我们在讲什么。比如循环引用:

var obj = {};
obj.b = obj;

这样当我们深拷贝obj对象时,就会循环的遍历b属性,直到栈溢出。
我们的解决方案为建立一个集合[],每次遍历对象进行比较,如果[]中已存在,则证明出现了循环引用或者相同引用,我们直接返回该对象已复制的引用即可:

let hasObj = [];
function referCopy(obj) {
    let copy = {};
    hasObj.push(obj);
    for (let i in obj) {
        if (typeof obj[i] === 'object') {
            let index = hasObj.indexOf(obj[i]);
            if (index > -1) {
                console.log('存在循环引用或属性引用了相同对象');
                // 如果已存在,证明引用了相同对象,那么无论是循环引用还是重复引用,我们返回引用就可以了
                copy[i] = hasObj[index];
            } else {
                copy[i] = referCopy(obj[i]);
            }
        } else {
            copy[i] = obj[i];
        }
    }
    return copy;
}

处理原型和区分可拷贝的对象:我们一般使用function.prototype指代原型,使用obj.__proto__指代原型链,使用enumerable属性表示是否可以被for ... in等遍历,使用hasOwnProperty来查询是否是本身元素。在原型链和可遍历属性和自身属性之间存在交集,但都不相等,我们应该如何判断哪些属性应该被复制呢?

函数的处理:函数拥有一些内在属性,但我们一般不修改这些属性,所以函数一般直接引用其地址即可。但是拥有一些存取器属性的函数我们怎么处理?是复制值还是复制存取描述符?

var obj = {
    age: 10,
    get age() {
        return this.age;
    },
    set age(age) {
        this.age = age;
    }
};
var obj2 = $.extend(true, {}, obj);

obj2; // {age: 10}

这个是我们想要的结果吗?大部分场景下不是吧,比如我要复制一个已有的Vue对象。当然我们也有解决方案:

function copy(obj) {
    var copy = {};
    for (var i in obj) {
        let desc = Object.getOwnPropertyDescriptor(obj, i);
        // 检测是否为存取描述符
        if (desc.set || desc.get) {
            Object.defineProperty(copy, i, {
                get: desc.get,
                set: desc.set,
                configuarable: desc.configuarable,
                enumerable: true
            });
        // 否则为数据描述符,则复用下面的深拷贝方法,此处简写
        } else {
            copy[i] = obj[i];
        }
    }
    return copy;
}

虽然边界条件很多,但是不同的框架和库都对该方法进行了实现,只不过定义不同,实现方式也不同,如jQuery.extend()只复制可枚举的属性,不继承原型链,函数复制引用,内部循环引用不处理。而lodash实现的就更为优秀,它实现了结构化克隆算法
该算法的优点是:

  1. 可以复制 RegExp 对象。
  2. 可以复制 Blob、File 以及 FileList 对象。
  3. 可以复制 ImageData 对象。CanvasPixelArray 的克隆粒度将会跟原始对象相同,并且复制出来相同的像素数据。
  4. 可以正确的复制有循环引用的对象

依然存在的缺陷是:

  1. Error 以及 Function 对象是不能被结构化克隆算法复制的;如果你尝试这样子去做,这会导致抛出 DATA_CLONE_ERR 的异常。
  2. 企图去克隆 DOM 节点同样会抛出 DATA_CLONE_ERROR 异常。
  3. 对象的某些特定参数也不会被保留
    • RegExp 对象的 lastIndex 字段不会被保留
    • 属性描述符,setters 以及 getters(以及其他类似元数据的功能)同样不会被复制。例如,如果一个对象用属性描述符标记为 read-only,它将会被复制为 read-write,因为这是默认的情况下。
    • 原形链上的属性也不会被追踪以及复制。

我们先来看看常规的深拷贝,它跟浅拷贝的区别在于,当我们发现对象的属性是引用类型时,进行递归遍历复制,直到遍历完所有属性:

var deepClone = function(currobj){
    if(typeof currobj !== 'object'){
        return currobj;
    }
    if(currobj instanceof Array){
        var newobj = [];
    }else{
        var newobj = {}
    }
    for(var key in currobj){
        if(typeof currobj[key] !== 'object'){
            // 不是引用类型,则复制值
            newobj[key] = currobj[key];
        }else{
            // 引用类型,则递归遍历复制对象
            newobj[key] = deepClone(currobj[key])    
        }
    }
    return newobj
}

这个的主要问题就是不处理循环引用,不处理对象原型,函数依然是引用类型。上面描述过的复杂问题依然存在,可以说是最简陋但是日常工作够用的深拷贝方式。

另外还有一种方式是使用JSON序列化,巧妙但是限制更多:

// 调用JSON内置方法先序列化为字符串再解析还原成对象
newObj = JSON.parse(JSON.stringify(obj));

JSON是一种表示结构化数据的格式,只支持简单值、对象和数组三种类型,不支持变量、函数或对象实例。所以我们工作中可以使用它解决常见问题,但也要注意其短板:函数会丢失,原型链会丢失,以及上面说到的所有缺陷。

库实现

上面的两种方式可以满足大部分场景的需求,如果有更复杂的需求,可以自己实现。现在我们可以看一些框架和库的解决方案,下面拿经典的jQuery和lodash的源码看下,它们的优缺点上面都说过了:

jQuery.extend()

// 进行深度复制,如果第一个参数为true则深度复制,如果目标对象不合法,则抛弃并重构为{}空对象,如果只有一个参数则功能为扩展jQuery对象
jQuery.extend = jQuery.fn.extend = function() {
    var options, name, src, copy, copyIsArray, clone,
        target = arguments[ 0 ] || {},
        i = 1,
        length = arguments.length,
        deep = false;

    // Handle a deep copy situation
    // 第一个参数可以为true来确定进行深度复制
    if ( typeof target === "boolean" ) {
        deep = target;

        // Skip the boolean and the target
        target = arguments[ i ] || {};
        i++;
    }

    // Handle case when target is a string or something (possible in deep copy)
    // 如果目标对象不合法,则强行重构为{}空对象,抛弃原有的
    if ( typeof target !== "object" && !jQuery.isFunction( target ) ) {
        target = {};
    }

    // Extend jQuery itself if only one argument is passed
    // 如果只有一个参数,扩展jQuery对象
    if ( i === length ) {
        target = this;
        i--;
    }

    for ( ; i < length; i++ ) {

        // Only deal with non-null/undefined values
        // 只处理有值的对象
        if ( ( options = arguments[ i ] ) != null ) {

            // Extend the base object
            for ( name in options ) {
                src = target[ name ];
                copy = options[ name ];

                // Prevent never-ending loop
                // 阻止最简单形式的循环引用
                // var obj={}, obj2={a:obj}; $.extend(true, obj, obj2); 就会形成复制的对象循环引用obj
                if ( target === copy ) {
                    continue;
                }
                // 如果为深度复制,则新建[]和{}空数组或空对象,递归本函数进行复制
                // Recurse if we're merging plain objects or arrays
                if ( deep && copy && ( jQuery.isPlainObject( copy ) ||
                    ( copyIsArray = Array.isArray( copy ) ) ) ) {

                    if ( copyIsArray ) {
                        copyIsArray = false;
                        clone = src && Array.isArray( src ) ? src : [];

                    } else {
                        clone = src && jQuery.isPlainObject( src ) ? src : {};
                    }

                    // Never move original objects, clone them
                    target[ name ] = jQuery.extend( deep, clone, copy );

                // Don't bring in undefined values
                } else if ( copy !== undefined ) {
                    target[ name ] = copy;
                }
            }
        }
    }

    // Return the modified object
    return target;
};

lodash _.baseClone()

/**
     * The base implementation of `_.clone` and `_.cloneDeep` which tracks
     * traversed objects.
     *
     * @private
     * @param {*} value The value to clone.
     * @param {boolean} bitmask The bitmask flags.
     *  1 - Deep clone
     *  2 - Flatten inherited properties
     *  4 - Clone symbols
     * @param {Function} [customizer] The function to customize cloning.
     * @param {string} [key] The key of `value`.
     * @param {Object} [object] The parent object of `value`.
     * @param {Object} [stack] Tracks traversed objects and their clone counterparts.
     * @returns {*} Returns the cloned value.
     */
    function baseClone(value, bitmask, customizer, key, object, stack) {
      var result,
          isDeep = bitmask & CLONE_DEEP_FLAG,
          isFlat = bitmask & CLONE_FLAT_FLAG,
          isFull = bitmask & CLONE_SYMBOLS_FLAG;

      if (customizer) {
        result = object ? customizer(value, key, object, stack) : customizer(value);
      }
      if (result !== undefined) {
        return result;
      }
      if (!isObject(value)) {
        return value;
      }
      var isArr = isArray(value);
      if (isArr) {
        result = initCloneArray(value);
        if (!isDeep) {
          return copyArray(value, result);
        }
      } else {
        var tag = getTag(value),
            isFunc = tag == funcTag || tag == genTag;

        if (isBuffer(value)) {
          return cloneBuffer(value, isDeep);
        }
        if (tag == objectTag || tag == argsTag || (isFunc && !object)) {
          result = (isFlat || isFunc) ? {} : initCloneObject(value);
          if (!isDeep) {
            return isFlat
              ? copySymbolsIn(value, baseAssignIn(result, value))
              : copySymbols(value, baseAssign(result, value));
          }
        } else {
          if (!cloneableTags[tag]) {
            return object ? value : {};
          }
          result = initCloneByTag(value, tag, baseClone, isDeep);
        }
      }
      // Check for circular references and return its corresponding clone.
      stack || (stack = new Stack);
      var stacked = stack.get(value);
      if (stacked) {
        return stacked;
      }
      stack.set(value, result);

      var keysFunc = isFull
        ? (isFlat ? getAllKeysIn : getAllKeys)
        : (isFlat ? keysIn : keys);

      var props = isArr ? undefined : keysFunc(value);
      arrayEach(props || value, function(subValue, key) {
        if (props) {
          key = subValue;
          subValue = value[key];
        }
        // Recursively populate clone (susceptible to call stack limits).
        assignValue(result, key, baseClone(subValue, bitmask, customizer, key, value, stack));
      });
      return result;
    }

参考资料

  1. 知乎 JS的深拷贝和浅拷贝: https://www.zhihu.com/questio...
  2. Javascript之深拷贝: https://aepkill.github.io/201...
  3. js对象克隆之谜:http://b-sirius.me/2017/08/26...
  4. 知乎 JS如何完整实现深度Clone对象:https://www.zhihu.com/questio...
  5. github lodash源码:https://github.com/lodash/lod...
  6. MDN 结构化克隆算法:https://developer.mozilla.org...
  7. jQuery v3.2.1 源码
  8. JavaScript高级程序设计 第4章(变量、作用域和内存问题)、第20章(JSON)

 

THE END