深刻理解 JavaScript 对象和数组拷贝

前言

本文要解决的问题:javascript

  • 为何会有深拷贝(deep clone)和浅拷贝(shallow clone)的存在
  • 理解 JavaScript 中深拷贝和浅拷贝的区别
  • JavaScript 拷贝对象的注意事项
  • JavaScript 拷贝对象和数组的实现方法

部分代码可在这里找到:Github。若是发现错误,欢迎指出。java

一, 理解问题缘由所在

JavaScript 中的数据类型能够分为两种:基本类型值(Number, Boolean, String, NULL, Undefined)和引用类型值(Array, Object, Date, RegExp, Function)。 基本类型值指的是简单的数据段,而引用类型值指那些可能由多个值构成的对象。git

基本数据类型是按值访问的,由于能够直接操做保存在变量中的实际的值。引用类型的值是保存在内存中的对象,与其余语言不一样,JavaScript 不容许直接访问内存中的位置,也就是说不能直接操做对象的内存空间。在操做对象时,其实是在操做对象的引用而不是实际的对象。 为此,引用类型的值是按引用访问的。github

除了保存的方式不一样以外,在从一个变量向另外一个变量复制基本类型值和引用类型值时,也存在不一样:segmentfault

  • 若是从一个变量向另外一个变量复制基本类型的值,会在变量对象上建立一个新值,而后把该值复制到为新变量分配的位置上。
  • 当从一个变量向另外一个变量复制引用类型的值时,一样也会将存储在变量对象中的值复制一份放到为新变量分配的空间中。不一样的是,这个值的副本其实是一个指针,而这个指针指向存储在堆中的一个对象。复制操做结束后,两个变量实际上将引用同一个对象。所以,改变其中一个变量,就会影响另外一个变量。

看下面的代码:数组

// 基本类型值复制
var string1 = 'base type';
var string2 = string1;

// 引用类型值复制
var object1 = {a: 1};
var object2 = object1;
复制代码

下图能够表示两种类型的变量的复制结果:bash

至此,咱们应该理解:在 JavaScript 中直接复制对象其实是对引用的复制,会致使两个变量引用同一个对象,对任一变量的修改都会反映到另外一个变量上,这是一切问题的缘由所在。闭包

二, 深拷贝和浅拷贝的区别

理解了 JavaScript 中拷贝对象的问题后,咱们就能够讲讲深拷贝和浅拷贝的区别了。考虑这种状况,你须要复制一个对象,这个对象的某个属性仍是一个对象,好比这样:函数

var object1 = {
  a: 1,
  obj: {
    b: 'string'
  }
}
复制代码

浅拷贝

浅拷贝存在两种状况:性能

  • 直接拷贝对象,也就是拷贝引用,两个变量object1object2 之间仍是会相互影响。
  • 只是简单的拷贝对象的第一层属性,基本类型值再也不相互影响,可是对其内部的引用类型值,拷贝的任然是是其引用,内部的引用类型值仍是会相互影响。
// 最简单的浅拷贝
var object2 = object1;

// 拷贝第一层属性
function shallowClone(source) {
    if (!source || typeof source !== 'object') {
        return;
    }
    var targetObj = source.constructor === Array ? [] : {};
    for (var keys in source) {
        if (source.hasOwnProperty(keys)) {
            // 简单的拷贝属性
            targetObj[keys] = source[keys];
        }
    }
    return targetObj;
}

var object3 = shallowClone(object1);
// 改变原对象的属性
object1.a = 2;
object1.obj.b = 'newString';
// 比较
console.log(object2.a); // 2
console.log(object2.obj.b); // 'newString'
console.log(object3.a); // 1
console.log(object3.obj.b); // 'newString'
复制代码

浅拷贝存在许多问题,须要咱们注意:

  • 只能拷贝可枚举的属性。
  • 所生成的拷贝对象的原型与原对象的原型不一样,拷贝对象只是 Object 的一个实例。
  • 原对象从它的原型继承的属性也会被拷贝到新对象中,就像是原对象的属性同样,没法区分。
  • 属性的描述符(descriptor)没法被复制,一个只读的属性在拷贝对象中可能会是可写的。
  • 若是属性是对象的话,原对象的属性会与拷贝对象的属性会指向一个对象,会彼此影响。

不能理解这些概念?能够看看下面的代码:

function Parent() {
  this.name = 'parent';
  this.a = 1;
}
function Child() {
  this.name = 'child';
  this.b = 2;
}

Child.prototype = new Parent();
var child1 = new Child();
// 更改 child1 的 name 属性的描述符
Object.defineProperty(child1, 'name', {
  writable: false,
  value: 'Mike'
});
// 拷贝对象
var child2 = shallowClone(child1);

// Object {value: "Nicholas", writable: false, enumerable: true, configurable: true}
console.log(Object.getOwnPropertyDescriptor(child1, 'name')); 

// 这里新对象的 name 属性的描述符已经发生了变化
// Object {value: "Nicholas", writable: true, enumerable: true, configurable: true}
console.log(Object.getOwnPropertyDescriptor(child2, 'name')); 

child1.name = 'newName'; // 严格模式下报错
child2.name = 'newName'; // 能够赋值
console.log(child1.name); // Mike
console.log(child2.name); // newName
复制代码

上面的代码经过构造函数 Child 构造一个对象 child1,这个对象的原型是 Parent。而且修改了 child1name 属性的描述符,设置 writablefalse,也就是这个属性不能再被修改。若是要直接给 child1.name 赋值,在严格模式下会报错,在非严格模式则会赋值失败(但不会报错)。

咱们调用前面提到的浅拷贝函数 shallowClone 来拷贝 child1 对象,生成了新的对象 child2,输出 child2name 属性的描述符,咱们能够发现 child2name 属性的描述符与 child1 已经不同了(变成了可写的)。在 VSCode 中开启调试模式,查看 child1child2 的原型,咱们也会发现它们的原型也是不一样的:

child1 的原型是 Parent,而 child2 的原型则是 Object

经过上面的例子和简短的说明,咱们能够大体理解浅拷贝存在的一些问题,在实际使用过程当中也能有本身的判断。

深拷贝

深拷贝就是将对象的属性递归的拷贝到一个新的对象上,两个对象有不一样的地址,不一样的引用,也包括对象里的对象属性(如 object1 中的 obj 属性),两个变量之间彻底独立。

没有银弹 - 根据实际需求

既然浅拷贝有那么多问题,咱们为何还要说浅拷贝?一来是深拷贝的完美实现不那么容易(甚至不存在),并且可能存在性能问题,二来是有些时候的确不须要深拷贝,那么咱们也就不必纠结于与深拷贝和浅拷贝了,没有必要跟本身过不去不是?

一句话:根据本身的实际需选择不一样的方法。

三, 实现对象和数组浅拷贝

对象浅拷贝

前面已经介绍了对象的两种浅拷贝方式,这里就不作说明了。下面介绍其余的几种方式

1. 使用 Object.assign 方法

Object.assign() 用于将一个或多个源对象中的全部可枚举的属性值复制到目标对象。Object.assign() 只是浅拷贝,相似上文提到的 shallowClone 方法。

var object1 = {
  a: 1,
  obj: {
    b: 'string'
  }
};

// 浅拷贝
var copy = Object.assign({}, object1);
// 改变原对象属性
object1.a = 2;
object1.obj.b = 'newString';

console.log(copy.a); // 1
console.log(copy.obj.b); // `newString`
复制代码

2. 使用 Object.getOwnPropertyNames 拷贝不可枚举的属性

Object.getOwnPropertyNames() 返回由对象属性组成的一个数组,包括不可枚举的属性(除了使用 Symbol 的属性)。

function shallowCopyOwnProperties( source ) {
    var target = {} ;
    var keys = Object.getOwnPropertyNames( original ) ;
    for ( var i = 0 ; i < keys.length ; i ++ ) {
        target[ keys[ i ] ] = source[ keys[ i ] ] ;
    }
    return target ;
}
复制代码

3. 使用 Object.getPrototypeOf 和 Object.getOwnPropertyDescriptor 拷贝原型与描述符

若是咱们须要拷贝原对象的原型和描述符,咱们可使用 Object.getPrototypeOfObject.getOwnPropertyDescriptor 方法分别获取原对象的原型和描述符,而后使用 Object.createObject.defineProperty 方法,根据原型和属性的描述符建立新的对象和对象的属性。

function shallowCopy( source ) {
    // 用 source 的原型建立一个对象
    var target = Object.create( Object.getPrototypeOf( source )) ;
    // 获取对象的全部属性
    var keys = Object.getOwnPropertyNames( source ) ;
    // 循环拷贝对象的全部属性
    for ( var i = 0 ; i < keys.length ; i ++ ) {
        // 用原属性的描述符建立新的属性
        Object.defineProperty( target , keys[ i ] , Object.getOwnPropertyDescriptor( source , keys[ i ])) ;
    }
    return target ;
}
复制代码

数组浅拷贝

同上,数组也能够直接复制或者遍历数组的元素直接复制达到浅拷贝的目的:

var array = [1, 'string', {a: 1,b: 2, obj: {c: 3}}];
// 直接复制
var array1 = array;
// 遍历直接复制
var array2 = [];
for(var key in array) {
  array2[key] = array[key];
}
// 改变原数组元素
array[1] = 'newString';
array[2].c = 4;

console.log(array1[1]); // newString
console.log(array1[2].c); // 4
console.log(array2[1]); // string
console.log(array2[2].c); // 4
复制代码

这没有什么须要特别说明的,咱们说些其余方法

使用 slice 和 concat 方法

slice() 方法将一个数组被选择的部分(默认状况下是所有元素)浅拷贝到一个新数组对象,并返回这个数组对象,原始数组不会被修改。 concat() 方法用于合并两个或多个数组。此方法不会更改现有数组,而是返回一个新数组。

这两个方法均可以达到拷贝数组的目的,而且是浅拷贝,数组中的对象只是复制了引用:

var array = [1, 'string', {a: 1,b: 2, obj: {c: 3}}];
// slice()
var array1 = array.slice();
// concat()
var array2 = array.concat();
// 改变原数组元素
array[1] = 'newString';
array[2].c = 4;

console.log(array1[1]); // string
console.log(array1[2].c); // 4
console.log(array2[1]); // string
console.log(array2[2].c); // 4
复制代码

四, 实现对象和数组深拷贝

实现深拷贝的方法大体有两种:

  • 利用 JSON.stringifyJSON.parse 方法
  • 遍历对象的属性(或数组的元素),分别拷贝

下面就两种方法详细说说

1. 使用 JSON.stringify 和 JSON.parse 方法

JSON.stringifyJSON.parse 是 JavaScript 内置对象 JSON 的两个方法,主要是用来将 JavaScript 对象序列化为 JSON 字符串和把 JSON 字符串解析为原生 JavaScript 值。这里被用来实现对象的拷贝也算是一种黑魔法吧:

var obj = { a: 1, b: { c: 2 }};
// 深拷贝
var newObj = JSON.parse(JSON.stringify(obj));
// 改变原对象的属性
obj.b.c = 20;

console.log(obj); // { a: 1, b: { c: 20 } }
console.log(newObj); // { a: 1, b: { c: 2 } }
复制代码

可是这种方式有必定的局限性,就是对象必须听从JSON的格式,当遇到层级较深,且序列化对象不彻底符合JSON格式时,使用JSON的方式进行深拷贝就会出现问题。

在序列化 JavaScript 对象时,全部函数及原型成员都会被有意忽略,不体如今结果中,也就是说这种方法不能拷贝对象中的函数。此外,值为 undefined 的任何属性也都会被跳过。结果中最终都是值为有效 JSON 数据类型的实例属性。

2. 使用递归

递归是一种常见的解决这种问题的方法:咱们能够定义一个函数,遍历对象的属性,当对象的属性是基本类型值得时候,直接拷贝;当属性是引用类型值的时候,再次调用这个函数进行递归拷贝。这是基本的思想,下面看具体的实现(不考虑原型,描述符,不可枚举属性等,便于理解):

function deepClone(source) {
  // 递归终止条件
  if (!source || typeof source !== 'object') {
    return source;
  }
  var targetObj = source.constructor === Array ? [] : {};
  for (var key in source) {
    if (Object.prototype.hasOwnProperty.call(source, key) {
      if (source[key] && typeof source[key] === 'object') {
        targetObj[key] = deepClone(source[key]);
      } else {
        targetObj[key] = source[key];
      }
    }
  }
  return targetObj;
}

var object1 = {arr: [1, 2, 3], obj: {key: 'value' }, func: function(){return 1;}};

// 深拷贝
var newObj= deepClone(object1);
// 改变原对象属性
object1.arr.push(4);

console.log(object1.arr); // [1, 2, 3, 4]
console.log(newObj.arr); // [1, 2, 3]
复制代码

对于 Function 类型,这里是直接复制的,任然是共享一个内存地址。由于函数更多的是完成某些功能,对函数的更改可能就是直接从新赋值,通常状况下不考虑深拷贝。

上面的深拷贝只是比较简单的实现,没有考虑很复杂的状况,好比:

  • 其余引用类型:Function,Date,RegExp 的拷贝
  • 对象中存在循环引用(Circular references)会致使调用栈溢出
  • 经过闭包做用域来实现私有成员的这类对象不能真正的被拷贝

什么是闭包做用域

function myConstructor() {
    var myPrivateVar = 'secret' ;
    return {
        myPublicVar: 'public!' ,
        getMyPrivateVar: function() {
            return myPrivateVar ;
        } ,
        setMyPrivateVar( value ) {
            myPrivateVar = value.toString() ;
        }
    };
}
var o = myContructor() ;
复制代码

上面的代码中,对象 o 有三个属性,一个是字符串,另外两个是方法。方法中用到一个变量 myPrivateVar,存在于 myConstructor() 的函数做用域中,当 myConstructor 构造函数调用时,就建立了这个变量 myPrivateVar,然而这个变量并非经过构造函数建立的对象 o 的属性,可是它任然能够被这两个方法使用。

所以,若是尝试深拷贝对象 o,那么拷贝对象 clone 和被拷贝对象 original 中的方法都是引用相同的 myPrivateVar 变量。

可是,因为并无方式改变闭包的做用域,因此这种模式建立的对象不能正常深拷贝是能够接受的。

3. 使用队列

递归的作法虽然简单,容易理解,可是存在必定的性能问题,对拷贝比较大的对象来讲不是很好的选择。

理论上来讲,递归是能够转化成循环的,咱们能够尝试着将深拷贝中的递归转化成循环。咱们须要遍历对象的属性,若是属性是基本类型,直接复制,若是属性是引用类型(对象或数组),须要再遍历这个对象,对他的属性进行相同的操做。那么咱们须要一个容器来存放须要进行遍历的对象,每次从容器中拿出一个对象进行拷贝处理,若是处理过程当中遇到新的对象,那么再把它放到这个容器中准备进行下一轮的处理,当把容器中全部的对象都处理完成后,也就完成了对象的拷贝。

思想大体是这样的,下面看具体的实现:

// 利用队列的思想优化递归
function deepClone(source) {
  if (!source || typeof source !== 'object') {
    return source;
  }
  var current;
  var target = source.constructor === Array ? [] : {};
  // 用数组做为容器
  // 记录被拷贝的原对象和目标
  var cloneQueue = [{
    source,
    target
  }];
  // 先进先出,更接近于递归
  while (current = cloneQueue.shift()) {
    for (var key in current.source) {
      if (Object.prototype.hasOwnProperty.call(current.source, key)) {
        if (current.source[key] && typeof current.source[key] === 'object') {
          current.target[key] = current.source[key].constructor === Array ? [] : {};
          cloneQueue.push({
            source: current.source[key],
            target: current.target[key]
          });
        } else {
          current.target[key] = current.source[key];
        }
      }
    }
  }
  return target;
}

var object1 = {a: 1, b: {c: 2, d: 3}};
var object2 = deepClone(object1);

console.log(object2); // {a: 1, b: {c: 2, d: 3}}
复制代码

(完)

参考

  1. 《JavaScript 高级程序设计》
  2. JavaScript中的浅拷贝和深拷贝
  3. 探究 JS 中的浅拷贝和深拷贝
  4. Understanding Object Cloning in Javascript - Part. I
  5. Understanding Object Cloning in Javascript - Part. II
相关文章
相关标签/搜索