深入理解 JavaScript 对象和数组拷贝(转载)

本文要解决的问题:

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

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

一, 理解问题原因所在

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

基本数据类型是按值访问的,因为可以直接操作保存在变量中的实际的值。引用类型的值是保存在内存中的对象,与其他语言不同,JavaScript 不允许直接访问内存中的位置,也就是说不能直接操作对象的内存空间。在操作对象时,实际上是在操作对象的引用而不是实际的对象。 为此,引用类型的值是按引用访问的。

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

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

看下面的代码:

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

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

下图可以表示两种类型的变量的复制结果:

<figure style="display: block; margin: 2.7rem auto; text-align: center; color: rgb(51, 51, 51); font-family: -apple-system, "PingFang SC", "Hiragino Sans GB", Arial, "Microsoft YaHei", "Helvetica Neue", sans-serif; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: normal; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial;">
image

<figcaption style="display: block; text-align: center; font-size: 1rem; line-height: 2.7rem; color: rgb(144, 144, 144);"></figcaption>

</figure>

至此,我们应该理解:在 JavaScript 中直接复制对象实际上是对引用的复制,会导致两个变量引用同一个对象,对任一变量的修改都会反映到另一个变量上,这是一切问题的原因所在。

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

理解了 JavaScript 中拷贝对象的问题后,我们就可以讲讲深拷贝和浅拷贝的区别了。考虑这种情况,你需要复制一个对象,这个对象的某个属性还是一个对象,比如这样:

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

浅拷贝

浅拷贝存在两种情况:

  • 直接拷贝对象,也就是拷贝引用,两个变量object1

    object2

    之间还是会相互影响。

  • 只是简单的拷贝对象的第一层属性,基本类型值不再相互影响,但是对其内部的引用类型值,拷贝的任然是是其引用,内部的引用类型值还是会相互影响。

// 最简单的浅拷贝
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。并且修改了

child1

name

属性的描述符,设置

writable

false,也就是这个属性不能再被修改。如果要直接给

child1.name

赋值,在严格模式下会报错,在非严格模式则会赋值失败(但不会报错)。

我们调用前面提到的浅拷贝函数

shallowClone

来拷贝

child1

对象,生成了新的对象

child2,输出

child2

name

属性的描述符,我们可以发现

child2

name

属性的描述符与

child1

已经不一样了(变成了可写的)。在 VSCode 中开启调试模式,查看

child1

child2

的原型,我们也会发现它们的原型也是不同的:

<figure style="display: block; margin: 2.7rem auto; text-align: center; color: rgb(51, 51, 51); font-family: -apple-system, "PingFang SC", "Hiragino Sans GB", Arial, "Microsoft YaHei", "Helvetica Neue", sans-serif; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: normal; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial;">
image

<figcaption style="display: block; text-align: center; font-size: 1rem; line-height: 2.7rem; color: rgb(144, 144, 144);"></figcaption>

</figure>

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.getPrototypeOf

Object.getOwnPropertyDescriptor

方法分别获取原对象的原型和描述符,然后使用

Object.create

Object.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.stringify

    JSON.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}}

转载自 https://juejin.im/post/5a00226b5188255695390a74

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容