JavaScript 复制对象与Object.assign方法无法实现深复制

2018-11-02 21 最骚的就是你

在JavaScript这门语言中,数据类型分为两大类:基本数据类型和复杂数据类型。基本数据类型包括Number、Boolean、String、Null、String、Symbol(ES6 新增),而复杂数据类型包括Object,而所有其他引用类型(Array、Date、RegExp、Function、基本包装类型(Boolean、String、Number)、Math等)都是Object类型的实例对象,因此都可以继承Object原型对象的一些属性和方法。

而对于基本数据类型来说,复制一个变量值,本质上就是copy了这个变量。一个变量值的修改,不会影响到另外一个变量。看一个简单的例子。

let val = 123;
let copy = val;
console.log(copy); //123
val = 456;     //修改val的值对copy的值不产生影响
console.log(copy); //123

而对于复杂数据类型来说,同基本数据类型实现的不太相同。对于复杂数据类型的复制,要注意的是,变量名只是指向这个对象的指针。当我们将保存对象的一个变量赋值给另一个变量时,实际上复制的是这个指针,而两个变量都指向都一个对象。因此,一个对象的修改,会影响到另外一个对象。

// obj只是指向对象的指针
let obj = {
  character: 'peaceful'
};
//copy变量复制了这个指针,指向同一个对象
let copy = obj;
console.log(copy);     //{character: 'peaceful'}
obj.character = 'lovely';
console.log(copy);     //{character: 'lovely'}

有一副很形象的图描述了复杂数据类型复制的原理

JavaScript 复制对象与Object.assign方法无法实现深复制

同理,在复制一个数组时,变量名只是指向这个数组对象的指针;在复制一个函数时,函数名只是指向这个函数对象的指针

let arr = [1, 2, 3];
let copy = arr;
console.log(copy); // [1, 2, 3]
arr[0] = 'keith';
console.log(copy); // 数组对象被改变: ['keith', 2, 3]
arr = null;
console.log(copy); // ['keith', 2, 3] 即使arr=null,也不会影响copy。因此此时的arr变量只是一个指向数组对象的指针

function foo () {
  return 'hello world';
};
let bar = foo;
console.log(foo());
foo = null;   //foo只是指向函数对象的指针
console.log(bar());

因此,我们应该如何实现对象的深浅复制?

复制对象

在JavaScript中,复制对象分为两种方式,浅复制和深复制。

浅复制没有办法去真正的去复制一个对象,而只是保存了对该对象的引用;而深复制可以实现真正的复制一个对象。

浅复制

在ES6中,Object对象新增了一个assign方法,可以实现对象的浅复制。这里谈谈Object.assign方法的具体用法,因为稍后会分析jQuery的extend方法,实现的原理同Object.assign方法差不多

Object.assign的第一个参数是目标对象,可以跟一或多个源对象作为参数,将源对象的所有可枚举([[emuerable]] === true)复制到目标对象。这种复制属于浅复制,复制对象时只是包含对该对象的引用。Object.assign(target, [source1, source2, ...])

  • 如果目标对象与源对象有同名属性,则后面的属性会覆盖前面的属性
  • 如果只有一个参数,则直接返回该参数。即Object.assign(obj) === obj
  • 如果第一个参数不是对象,而是基本数据类型(Null、Undefined除外),则会调用对应的基本包装类型
  • 如果第一个参数是Null和Undefined,则会报错;如果Null和Undefined不是位于第一个参数,则会略过该参数的复制

要实现对象的浅复制,可以使用Object.assign方法

let target = {a: 123};
let source1 = {b: 456};
let source2 = {c: 789};
let obj = Object.assign(target, source1, source2);
console.log(obj);

不过对于深复制来说,Object.assign方法无法实现

let target = {a: 123};
let source1 = {b: 456};
let source2 = {c: 789, d: {e: 'lovely'}};
let obj = Object.assign(target, source1, source2);
source2.d.e = 'peaceful';
console.log(obj);  // {a: 123, b: 456, c: 789, d: {e: 'peaceful'}}

从上面代码中可以看出,source2对象中e属性的改变,仍然会影响到obj对象

深复制

在实际的开发项目中,前后端进行数据传输,主要是通过JSON实现的。JSON全称:JavaScript Object Notation,JavaScript对象表示法。

JSON对象下有两个方法,一是将JS对象转换成字符串对象的JSON.stringify方法;一个是将字符串对象转换成JS对象的JSON.parse方法。

这两个方法结合使用可以实现对象的深复制。也就是说,当我们需要复制一个obj对象时,可以先调用JSON.stringify(obj),将其转换为字符串对象,然后再调用JSON.parse方法,将其转换为JS对象。就可以轻松的实现对象的深复制

let obj = {
  a: 123,
  b: {
    c: 456,
    d: {
      e: 789
    }
  }
};
let copy = JSON.parse(JSON.stringify(obj));
// 对obj对象无论怎么修改,都不会影响到copy对象
obj.b.c = 'hello';
obj.b.d.e = 'world';
console.log(copy); // {a: 123, b: {c: 456, d: {e: 789}}}

当然,使用这种方式实现深复制有一个缺点就是必须给JSON.parse方法传入的字符串必须是合法的JSON,否则会抛出错误

jQuery.extend || jQuery.fn.extend

jQuery.extend对象,对使用jQuery超过一定时间的朋友来说并不默认。这个$.extend方法可以用来扩展jQuery的全局对象,而$.fn.extend方法可以用来扩展实例对象。fn实际上是prototype对象的别名,所以,扩展实例对象的方法实际上就是在jQuery原型对象上添加一些方法。

$.extend方法不仅可以用来写jQuery插件,同样的,它可以用来实现对象的深浅复制。(使用$.extend与$.fn.extend实现深浅复制都可以,唯一的差别就是this的指向性不同)

在具体分析源代码之前,我在源码中看到的$.extend方法的一些特点

  • 当不接受任何参数时,直接返回一个空对象
  • 当只有一个参数时(这个参数可以任何数据类型(Null、Undefined、Boolean、String、Number、Object)),会返回this对象,这里会分为两种情况。如果用$.extend,会返回jQuery对象;如果用$.fn.extend,会返回jQuery的原型对象。
  • 当接收两个参数时,并且第一个参数是Boolean值时,也会返回一个空对象。如果第一个参数不是Boolean值,那么会将源对象复制到目标对象
  • 当接收三个参数以上时,可以分为两种情况。如果第一个参数是Boolean值表示深浅复制,那么目标对象会移动到第二个参数,源对象会移动到第三个参数。(目标对象、源对象和Object.assign方法中的相同)。如果第一个参数不是Boolean值,那么用法与Object.assign方法常规的复制相同。
  • 在循环源对象的过程中,任何数据类型为Null、Undefined或者源对象是一个空对象时,在复制的过程中都会被忽略。
  • 如果源对象和目标对象具有同名的属性,则源对象的属性会覆盖掉目标对象中的属性。如果同名属性是一个对象的话,则会在deep=true等其他条件下向目标对象的该同名对象添加属性

下面贴出jQuery-2.1.4中jQuery.extend实现方式的源代码

jQuery.extend = jQuery.fn.extend = function() {
  var options, name, src, copy, copyIsArray, clone,
    target = arguments[0] || {},
    // 使用||运算符,排除隐式强制类型转换为false的数据类型
    // 如'', 0, undefined, null, false等
    // 如果target为以上的值,则设置target = {}
    i = 1,
    length = arguments.length,
    deep = false;

  // 当typeof target === 'boolean'时
  // 则将deep设置为target的值
  // 然后将target移动到第二个参数,
  if (typeof target === "boolean") {
    deep = target;
    // 使用||运算符,排除隐式强制类型转换为false的数据类型
    // 如'', 0, undefined, null, false等
    // 如果target为以上的值,则设置target = {}
    target = arguments[i] || {};
    i++;
  }

  // 如果target不是一个对象或数组或函数,
  // 则设置target = {}
  // 这里与Object.assign的处理方法不同,
  // assign方法会将Boolean、String、Number方法转换为对应的基本包装类型
  // 然后再返回,
  // 而extend方法直接将typeof不为object或function的数据类型
  // 全部转换为一个空对象
  if (typeof target !== "object" && !jQuery.isFunction(target)) {
    target = {};
  }

  // 如果arguments.length === 1 或
  // typeof arguments[0] === 'boolean', 且存在arguments[1],
  // 这时候目标对象会指向this
  // this的指向哪个对象需要看是使用$.fn.extend还是$.extend
  if (i === length) {
    target = this;
    // i-- 表示不进入for循环
    i--;
  }

  // 循环arguments类数组对象,从源对象开始
  for (; i < length; i++) {
    // 针对下面if判断
    // 有一点需要注意的是
    // 这里有一个隐式强制类型转换 undefined == null 为 true
    // 而undefined === null 为 false
    // 所以如果源对象中数据类型为Undefined或Null
    // 那么就会跳过本次循环,接着循环下一个源对象
    if ((options = arguments[i]) != null) {
      // 遍历所有[[emuerable]] === true的源对象
      // 包括Object, Array, String
      // 如果遇到源对象的数据类型为Boolean, Number
      // for in循环会被跳过,不执行for in循环
      for (name in options) {
        // src用于判断target对象是否存在name属性
        src = target[name];

        // 需要复制的属性
        // 当前源对象的name属性
        copy = options[name];

        // 这种情况暂时未遇到..
        // 按照我的理解,
        // 即使copy是同target是一样的对象
        // 两个对象也不可能相等的..
        if (target === copy) {
          continue;
        }

        // if判断主要用途:
        // 如果是深复制且copy是一个对象或数组
        // 则需要递归jQuery.extend(),
        // 直到copy成为一个基本数据类型为止
        if (deep && copy && (jQuery.isPlainObject(copy) || (copyIsArray = jQuery.isArray(copy)))) {
          // 深复制
          if (copyIsArray) {
            // 如果是copy是一个数组
            // 将copyIsArray重置为默认值
            copyIsArray = false;
            // 如果目标对象存在name属性且是一个数组
            // 则使用目标对象的name属性,否则重新创建一个数组,用于复制
            clone = src && jQuery.isArray(src) ? src : [];

          } else {
            // 如果目标对象存在name属性且是一个对象
            // 则使用目标对象的name属性,否则重新创建一个对象,用于复制
            clone = src && jQuery.isPlainObject(src) ? src : {};
          }

          // 因为深复制,所以递归调用jQuery.extend方法
          // 返回值为target对象,即clone对象
          // copy是一个源对象
          target[name] = jQuery.extend(deep, clone, copy);

        } else if (copy !== undefined) {
          // 浅复制
          // 如果copy不是一个对象或数组
          // 那么执行elseif分支
          // 在elseif判断中如果copy是一个对象或数组,
          // 但是都为空的话,排除这种情况
          // 因为获取空对象的属性会返回undefined
          target[name] = copy;
        }
      }
    }
  }

  // 当源对象全部循环完毕之后,返回目标对象
  return target;
};

因此,可以针对分析过后的源码,给出一些例子

let obj1 = $.extend();
console.log(obj1); // 返回一个空对象 {}

let obj2 = $.extend(undefined);
console.log(obj2); //返回jQuery对象,Object.assign传入undefined会报错

let obj3 = $.extend('123');
console.log(obj3); // 返回jQuery对象,Object.assign传入'123'会返回字符串的String对象

let target = {
  a: 123,
  b: 234
};

let source1 = {
  b: 456,
  d: ['keith', 'peaceful', 'lovely']
};

let source2 = {c: 789};
let source3 = {};

let obj4 = $.extend(target, source1, source2);
// let obj4 = $.extend(false, target, source1, source2);
console.log(obj4); // {a: 123, b: 456, d: Array(3), c: 789}
// 默认情况下,复制方式都是浅复制
// 如果只需要浅复制,不传入deep参数也可以
// 浅复制时,obj4对象中的d属性只是指向数组对象的指针

let obj5 = $.extend(target, undefined, source2);
let obj6 = $.extend(target, source3, source2);
console.log(obj5, obj6);
// {a: 123, b: 234, c: 789}, {a: 123, b: 234, c: 789}
// 会略过空对象或Undefined、Null值

let obj7 = $.extend(true, target, source1, source2);
console.log(obj7); // {a: 123, b: 456, d: Array(3), c: 789}
// 这里target对象有b属性,源对象source1也有b属性
// 此时源对象的b属性会覆盖目标对象的b属性
// 这里deep=true,属于深复制
// 当name=d时,会递归调用$.extend, 直到它的属性对应的属性值全部为基本数据类型
// 源对象的改变不会影响到obj7对象

JavaScript 复制对象

因此,可以根据$.extend方法,写出一个通用的实现对象深浅复制的函数,copyObject函数唯一的不同就是当i === arguments.length属性时,copyObject函数直接返回了target对象

function copyObject () {
  let i = 1,
    target = arguments[0] || {},
    deep = false,
    length = arguments.length,
    name, options, src, copy,
    copyIsArray, clone;

  // 如果第一个参数的数据类型是Boolean类型
  // target往后取第二个参数
  if (typeof target === 'boolean') {
    deep = target;
    // 使用||运算符,排除隐式强制类型转换为false的数据类型
    // 如'', 0, undefined, null, false等
    // 如果target为以上的值,则设置target = {}
    target = arguments[1] || {};
    i++;
  }

  // 如果target不是一个对象或数组或函数
  if (typeof target !== 'object' && !(typeof target === 'function')) {
    target = {};
  }

  // 如果arguments.length === 1 或
  // typeof arguments[0] === 'boolean',
  // 且存在arguments[1],则直接返回target对象
  if (i === length) {
    return target;
  }

  // 循环每个源对象
  for (; i < length; i++) {
    // 如果传入的源对象是null或undefined
    // 则循环下一个源对象
    if (typeof (options = arguments[i]) != null) {
      // 遍历所有[[emuerable]] === true的源对象
      // 包括Object, Array, String
      // 如果遇到源对象的数据类型为Boolean, Number
      // for in循环会被跳过,不执行for in循环
      for (name in options) {
        // src用于判断target对象是否存在name属性
        src = target[name];
        // copy用于复制
        copy = options[name];
        // 判断copy是否是数组
        copyIsArray = Array.isArray(copy);
        if (deep && copy && (typeof copy === 'object' || copyIsArray)) {
          if (copyIsArray) {
            copyIsArray = false;
            // 如果目标对象存在name属性且是一个数组
            // 则使用目标对象的name属性,否则重新创建一个数组,用于复制
            clone = src && Array.isArray(src) ? src : [];
          } else {
            // 如果目标对象存在name属性且是一个对象
            // 则使用目标对象的name属性,否则重新创建一个对象,用于复制
            clone = src && typeof src === 'object' ? src : {};
          }
          // 深复制,所以递归调用copyObject函数
          // 返回值为target对象,即clone对象
          // copy是一个源对象
          target[name] = copyObject(deep, clone, copy);
        } else if (copy !== undefined){
          // 浅复制,直接复制到target对象上
          target[name] = copy;
        }
      }
    }
  }
  // 返回目标对象
  return target;   
}

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

展开阅读全文

更多Javascript文章

比较全面的event对像在IE与FF中的区别 推荐
Sep 21 20
JS中批量给元素绑定事件过程中的相关问题使用闭包解决
Apr 15 15
基于jquery实现复选框全选,反选,全不选等功能
Oct 16 22
jQuery Masonry瀑布流插件使用方法详解
Jan 18 30
AngularJS中使用ngModal模态框实例
May 27 27
Webpack如何引入bootstrap的方法
Jun 17 23
JS获取当前时间的年月日时分秒及时间的格式化的方法
Dec 18 26
手机访问当前页面