「JavaScript进阶」一文吃透深浅拷贝

2,407 阅读11分钟

前言

文章里的每个案例都是我亲自编写并验证的,建议阅读文章时,可以在浏览器执行案例,会更有利于帮助理解。

JavaScript 系列文章:JavaScript进阶

变量存储类型

要理解深浅拷贝,先要熟悉变量存储类型,分为基本数据类型(值类型)和引用数据类型(复杂数据类型)。基本数据类型的值是直接存在栈内存的,而引用数据类型的栈内存保存的是内存地址,值保存在堆内存中。

变量存储类型地址值例子
基本数据类型存储在string、bool、number、undefined、null、symbol(ES6新增)
引用数据类型存储在存储在数组、对象、函数、正则

基本数据类型

直接把值存在 中的数据。如string、bool、number、undefined、null、symbol(ES6新增)

  • 1、使用typeof()判断null,打印的是object,但是 null 是基本数据类型。基本数据类型存储的是值,是没有函数可以调用的,比如调用null.toString()就会报错

  • 2、nullundefined 的区别

    • null:js 的关键字,是一个特殊的对象值,表示空值,typeof运算是object
    • undefined:预定义的全局变量,表示未定义,用typeof运算是undefined
  • 3、关于 Symbol,可以去看文章 Symbol 是不是构造函数?

引用数据类型

对象的引用地址存在 中,对象的数据存在 中。如数组、对象、函数、正则

引用类型用typeof运算后,会输出object

检测数据类型的方法

  • 1.typeof
typeof是检测数据类型的运算符,输出的字符串就是对应的类型。有以下局限性
1)typeOf(null)输出的是object,但 null 是基本数据类型
2)无法区分对象具体是什么类型,比如typeof([1])typeof({1})输出的都是object
   Object.prototype.toString.call(1) // "[object Number]" 
   Object.prototype.toString.call('hi') // "[object String]" 
   Object.prototype.toString.call({a:'hi'}) // "[object Object]" 
   Object.prototype.toString.call([1,'a']) // "[object Array]" 
   Object.prototype.toString.call(true) // "[object Boolean]" 
   Object.prototype.toString.call(() => {}) // "[object Function]" 
   Object.prototype.toString.call(null) // "[object Null]" 
   Object.prototype.toString.call(undefined) // "[object Undefined]" 
   Object.prototype.toString.call(Symbol(1)) // "[object Symbol]"
  • 3.constructor

    获取当前实例的构造器,详见 constructor 属性是否只读?

  • 4.instanceOf

    通过判断对象的原型链中是不是能找到类型的 prototype,详见:instanceOf 实现原理

    缺点:instanceof 只能用来判断对象类型(包含自定义对象),原始类型不可以。并且所有对象类型 instanceof Object 都是 true。

  []  instanceof Array; // true
  1 instanceof Number; // false,只能用来判断对象类型,原始类型不可以
  []  instanceof Object; // true,所有对象类型 instanceof Object 都是 true
  {}  instanceof Object; // true,所有对象类型 instanceof Object 都是 true

赋值、浅拷贝、深拷贝的区别

从生成的新对象与原数据是否指向同一对象,以及改变新对象是否会导致原数据发生改变行比较(根据对象的第一层属性是 基本数据类型 和 引用数据类型分类)。

操作类型与原数据指向同一对象第一层属性是基本类型第一层属性是引用类型
赋值改变导致原数据改变改变导致原数据改变
浅拷贝改变不会导致原数据改变改变导致原数据改变
深拷贝改变不会导致原数据改变改变不会导致原数据改变

赋值

符号=就是赋值操作。分为对基本数据类型引用数据类型赋值两种情况

  • 1.基本数据类型赋值

基本数据类型的赋值操作是值引用,相互之间没有影响

var a = '谷底飞龙';
var b = a; // 将 a 赋值给 b
a = '天下无敌'; // 修改 a 的值为 '天下无敌'
console.log(b); // 打印 b 的值,仍为 '谷底飞龙'
  • 2.引用数据类型赋值

引用数据类型的赋值是地址引用,两个变量指向同一个地址,相互之间有影响

var a = {
  name: '谷底飞龙' 
};
var b = a; // 将 a 赋值给 b
a.name = '天下无敌'; // 修改 a.name 的值为 '天下无敌'
console.log(b.name); // 打印 b.name 的值,变为 '天下无敌'

在开发过程中,我们有时候并不希望引用数据类型的赋值操作中的两个变量相互影响,这就需要浅拷贝深拷贝

浅拷贝

浅拷贝只拷贝原对象的第一层属性。即拷贝A对象里面的数据,但是不拷贝A对象里面的子对象。

如果属性是基本数据类型,拷贝的就是基本类型的值。 如果属性是引用数据类型,拷贝的是引用类型的内存地址

如何实现一个浅拷贝?

为了更直观的理解浅拷贝及其特点,我们来手动实现一个浅拷贝函数

// 对 obj 进行浅拷贝
function shallowCopy(obj) {
  if (typeof obj === 'object' && obj !== null) {
    let copy = Array.isArray(obj) ? [] : {};
    // 遍历原对象 obj,将第一层属性赋值给新对象
    for (var p in obj) {
      copy[p] = obj[p]
    }
    // 返回的新对象就是浅拷贝后的对象
    return copy
  } else {
    // 如果是基本类型,直接返回
    return obj
  }
}

我们可以看到,浅拷贝只拷贝了原数据的第一层属性。现在来验证下前面表格中的内容:

  • 如果第一层属性是基本数据类型,改变新对象不会导致原数据发生改变;
  • 如果第一层属性是引用数据类型,改变新对象导致原数据发生改变
// 原数据是对象
var obj = {
  color: 'red',
  person: {
    name: '谷底飞龙',
    age: 28,
  },
}
// 浅拷贝
var copy = shallowCopy(obj);

// 改变原数据第一层属性 color (基本数据类型)
copy.color = 'yellow';
// 改变原数据第一层属性 person(引用数据类型)
copy.person.name = '天下无敌';
// 原数据中 color 仍为 “red“,但是 name 会被修改为 “天下无敌”
console.log(obj);

执行后的结果如下: image.png

如果把原数据换成数组,效果也是一样的,如下

// 原数据是数组
var obj = [
  'red',
  {
    name: '谷底飞龙',
    age: 28,
  },
]
// 浅拷贝
var copy = shallowCopy(obj);

// 改变原数据第一层属性 color (基本数据类型)
copy[0] = 'yellow';
// 改变原数据第一层属性 person(引用数据类型)
copy[1].name = '天下无敌';
// 原数据中 color 仍为 “red“,但是 name 会被修改为 “天下无敌”
console.log(obj);

执行后的结果如下: image.png

如果原数据是基本数据类型呢?

  • 之前讲赋值的时候,提到浅拷贝深拷贝是为了解决引用数据类型(复杂数据类型)赋值存在的问题才引入的。所以如果原数据是基本数据类型,一般只需要赋值操作就行。如果用上面实现的浅拷贝函数 shallowCopy()来拷贝基本数据类型,直接返回原数据。

有哪些常用的浅拷贝?

在实际开发中,我们很少需要自己去写一个浅拷贝函数,这里例举几个常用的浅拷贝

1.对象 Object.assign()

在 ES6 提供了Object.assign(target, ...sources) 来实现浅拷贝,第一个参数 target是目标对象,后面的参数...sources是原对象。我们现在来改造下前面的案例

// 原数据是对象
var obj = {
  color: 'red',
  person: {
    name: '谷底飞龙',
    age: 28,
  },
}

// 改用 Object.assign() 实现浅拷贝
var copy = Object.assign({},obj);

// 改变原数据第一层属性 color (基本数据类型)
copy.color = 'yellow';
// 改变原数据第一层属性 person(引用数据类型)
copy.person.name = '天下无敌';
// 原数据中 color 仍为 “red“,但是 name 会被修改为 “天下无敌”
console.log(obj);

执行结果与shallowCopy()效果一样

注: Object.assign() 一般用于对象的浅拷贝,用来处理数组时,会把数组视为对象。

// Object.assign 把数组视为属性名为 0、1、2 的对象
// 源数组的 0 号属性 7 覆盖了目标数组的 0 号属性1,以此类推
Object.assign([1,2,3], [7,8]); // [7,8,3]
Object.assign([1,2], [6,7,8]); // [6,7,8]

2.数组 Array.concat()

对数组的浅拷贝,可以使用Array.concat(),这也是合并数组比较常用的方式。我们把之前的案例修改下

// 原数据是数组
var obj = [
  'red',
  {
    name: '谷底飞龙',
    age: 28,
  },
]

// 改用 [].concat() 实现浅拷贝
var copy = [].concat(obj);

// 改变原数据第一层属性 color (基本数据类型)
copy[0] = 'yellow';
// 改变原数据第一层属性 person(引用数据类型)
copy[1].name = '天下无敌';
// 原数据中 color 仍为 “red“,但是 name 会被修改为 “天下无敌”
console.log(obj);

执行结果与shallowCopy()效果一样

3.扩展运算符 { ...obj }

使用扩展运算符{ ...obj }可以对数组和对象进行浅拷贝,我们这里用原数据是对象的情况来做案例,你也可以自己把原数据换成数组试试。

// 原数据是对象
var obj = {
  color: 'red',
  person: {
    name: '谷底飞龙',
    age: 28,
  },
}

// 改用扩展运算符 {...obj} 实现浅拷贝
var copy = {...obj};

// 改变原数据第一层属性 color (基本数据类型)
copy.color = 'yellow';
// 改变原数据第一层属性 person(引用数据类型)
copy.person.name = '天下无敌';
// 原数据中 color 仍为 “red“,但是 name 会被修改为 “天下无敌”
console.log(obj);

执行效果与其它浅拷贝方式的是一样的

深拷贝

浅拷贝只对第一层属性进行拷贝,会存在一个问题:如果第一层属性是引用类型,拷贝的是地址,浅拷贝会导致原数据被修改。那怎么解决这个问题呢,这就需要深拷贝了。

深拷贝是从内存中完整的拷贝一份出来,在堆内存中开一个新的内存空间,与原对象完全独立。修改新对象不会影响原对象。

如何实现一个深拷贝?

1.浅拷贝+递归

浅拷贝只对第一层属性进行拷贝,深拷贝需要拷贝到最后一层(直到属性是基本类型为止)

// 递归浅拷贝
function recursiveShallowCopy(obj) {
  var copy = Array.isArray(obj) ? [] : {};
  for (let p in obj) {
    if (typeof obj[p] === 'object') {
      // 对象类型,继续递归浅拷贝
      copy[p] = recursiveShallowCopy(obj[p]);
    } else {
      copy[p] = obj[p];
    }
  }
  return copy;
}

// 深拷贝
function deepCopy(obj) {
  if (typeof obj === 'object' && obj !== null) {
    // 如果是引用类型,进行递归浅拷贝
    return recursiveShallowCopy(obj);
  } else {
    // 如果是基本类型,直接返回
    return obj;
  }
}

我们现在用上面创建的深拷贝函数 deepCopy()来验证下前面表格总结的内容:

  • 深拷贝时,无论第一层属性是基本类型还是引用类型,修改新对象都不会影响原数据。
// 原数据是对象
var obj = {
  color: 'red',
  person: {
    name: '谷底飞龙',
  },
}

// 改用 deepCopy() 实现深拷贝
var copy = deepCopy(obj);

// 改变原数据第一层属性 color (基本数据类型)
copy.color = 'yellow';
// 改变原数据第一层属性 person(引用数据类型)
copy.person.name = '天下无敌';
// 原数据中 color 仍为 “red“,name 仍为 “谷底飞龙”
console.log(obj);

执行上面案例,打印结果如下: image.png

对数组的操作结果也是一样的,这里就不重复写了。

2.JSON.parse(JSON.stringify())

先通过JSON.stringify()把原对象序列化成一个 JSON 字符串,再通过JSON.parse()生成一个新对象。

这是目前比较常用的深拷贝方式。我们把之前的案例修改为用 JSON.parse(JSON.stringify())来实现深拷贝验证下:

// 原数据是对象
var obj = {
  color: 'red',
  person: {
    name: '谷底飞龙',
  },
}

// 改用 JSON.parse(JSON.stringify()) 实现深拷贝
var copy = JSON.parse(JSON.stringify(obj));

// 改变原数据第一层属性 color (基本数据类型)
copy.color = 'yellow';
// 改变原数据第一层属性 person(引用数据类型)
copy.person.name = '天下无敌';
// 原数据中 color 仍为 “red“,name 仍为 “谷底飞龙”
console.log(obj);

执行结果与用递归+浅拷贝实现的deepCopy()效果是一样的

深拷贝的注意事项

使用JSON.parse(JSON.stringify()实现深拷贝有一些缺陷:

1.如果原对象中有undefined、Symbol、函数时,会导致该键值被丢失

2.如果原对象中有正则,会被转换为空对象{}

3.如果原对象中有Date,会被转换成字符串

4.会抛弃原对象的constructor,都会被转换成Object

5.如果对象中存在循环引用的情况,无法正确处理

先来验证下前 3 个缺陷:

// 原数据是对象
var obj = {
  color: 'red',
  person: {
    name: null,
    age: function(){}, // 被丢失
    country: undefined, // 被丢失
    love: Symbol(), // 被丢失
    time: new Date(),// 转换成 字符串
    height: /[1-9][0-9]?/, // 转换成空对象 {}
  },
}

// 使用 JSON.parse(JSON.stringify() 实现深拷贝
var copy = JSON.parse(JSON.stringify(obj));
console.log(copy)

执行后,会发现原对象中的属性 age(函数)、country(undefined)、love(Symbol)被丢失time(Date)被转换成字符串height(正则)被转换为空对象 {}。打印结果如下 image.png

我们再来验证下使用JSON.parse(JSON.stringify())实现深拷贝,会抛弃原对象的constructor,都会被转换成Object

function Person() {
  this.name = '谷底飞龙'
}

var obj = {
  person: new Person() 
}
console.log('原对象:')
console.log(obj)

var copy = JSON.parse(JSON.stringify(obj))
console.log('新对象:')
console.log(copy)

深拷贝之后,不管这个对象原来的构造函数是什么,在深拷贝之后都会变成 Object。执行结果如下:

image.png

  • 注:使用JSON.parse(JSON.stringify()实现深拷贝的前 4 个缺陷,可以通过递归+浅拷贝的深拷贝方式解决,但递归+浅拷贝不能解决第 5 点循环引用的问题。后面写一篇温江

参考


结语

写文章不易,花了我足足两天时间写的,如果你喜欢这篇文章,可以帮忙点个

也可以关注我的公众号 「谷底飞龙」~