以下内容仅为个人的理解,不一定完全正确喔 !

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let foo = 'Hello';

let bar = foo;

bar = 'Hello World!';

console.log(foo); // 'Hello'

console.log(bar); // 'Hello World!'

let obj1 = {
a: 1,
b: {
c: 2
}
};

let obj2 = obj1;

obj2.a = 2;
console.log(obj1.a); // 2

这是我大一上学期遇到的一个“怪现象”,之所以说是“怪现象”,是因为当时不懂这里面的原因,只因为它没有按照我的预想结果那样。

这个结果不像上面的字符串 String 那样,所以当时的我感到很困惑。

终于,立志不再只当个 API Caller,开始去了解底层原理的我,终于在今天了解到产生这个的问题的原因。

所以,还是很有必要记录一下我的对这个问题的理解。

根本原因

这个问题的根本原因就是深拷贝和浅拷贝其在内存中的储存类型不同。

栈与堆

首先要理解一个概念:栈与堆。

关于这个问题,有篇知乎可以让我们很好的理解这两者的关系以及区别:

什么是堆?什么是栈?他们之间有什么区别和联系?

栈区(stack):系统自动分配的内存空间,有系统自动释放。

堆区(heap):动态分配的内存空间,大小不确定,也不会自动释放。

JavaScript 数据类型

当我们理解这堆和栈概念后,再复习一下一个很简单的入门概念:JavaScript 数据类型

我们都知道,ECMAScript 标准定义了 7 种数据类型,其中 6 种为基本类型(原始类型),1 种为引用类型:

6 种为基本类型:

  • Boolean

  • Null

  • Undefined

  • Number

  • String

  • Symbol(ES6 新加入,表示独一无二的值)

以及 1 种引用类型:

  • Object

基本类型与引用类型的区别

分清楚两者的关系后,我们来看看它们之间的区别。

基本类型

基本类型的变量是存放在栈内存(Stack)里

具体表现如下:

  • 基本数据类型的值是按值访问的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    let x, y;

    x = 'foo';

    y = x;

    console.log(x); // "foo"

    console.log(y); // "foo"

    y = 'bar'; // 这里给 y 重新赋值。

    console.log(x); // "foo"

    console.log(y); // "bar"
  • 基本类型的值是不可变的。

    1
    2
    3
    4
    5
    let example = 'Hello World!';

    example.toUpperCase(); // HELLO WORLD!

    console.log(example); // Hello World!
  • 基本类型的比较是它们的值的比较。

    1
    2
    3
    4
    5
    6
    7
    let foo = 1;

    let bar = true;

    console.log(foo == bar); // true 运算符`==`表示只进行值的比较,所有这里在比较前进行了 隐式转换。

    console.log(foo === bar); // false 运算符`===`则表示不仅进行值的比较,还会进行类型的比较,(Numebr 与 Boolean)。

引用类型

引用类型的值则是存放在堆内存(Heap)里

具体表现如下:

  • 引用数据类型的值是按指针访问的。

变量实际上是一个存放在栈内存的指针,这个指针指向堆内存中的地址。

  • 引用类型的值是可变的。

    1
    2
    3
    4
    5
    let example = [1, 2, 3];

    example[0] = 6;

    console.log(example[0]); // 6 Array example 的值可以被修改。
  • 引用类型的比较是引用的比较。

    1
    2
    3
    4
    5
    let x = [1, 2, 3];

    let y = [1, 2, 3];

    console.log(x === y); // false 因为两者在内存中的位置不同。

传值与传址

在了解完上面的概念后,我们大致明白了基本类型与引用类型的区别。

所以也不难理解这两个概念:传值传址

显然,根据以上的例子,我们可以得出结论:

  • 基本数据类型在赋值操作时是 传值

在赋值操作时,基本数据类型的赋值是在内存里开辟了一段新的栈内存,然后再把值赋值到新开辟的栈内存中。

  • 引用数据类型在赋值操作时是 传址

在赋值操作时,引用类型的赋值只是改变了指针的指向,在给变量赋值时,是把对象保存在栈内存中的地址赋值给变量,结果是两个变量指向同个栈内存地址。

可能这么看起来有点绕,Show the Code:

1
2
3
4
5
6
7
8
9
10
11
// 基本数据类型的赋值操作:传值。

let x = 6;

let y = x; // 新开辟栈内存,再赋值到该栈内存中。

x += 2;

console.log(a); // 8

console.log(b); // 6
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 引用数据类型的赋值操作:传址。

let x = {}; // x 保存了一个空对象的实例。

let y = x; // x 和 y 都指向了这个空对象 (改变了 y 指针指向,指向了与 x 同个地址)。

x.name = 'jovi';

console.log(x.name); // 'jovi'

console.log(x.name); // 'jovi'

b.age = 21;

console.log(b.age); // 21

console.log(a.age); // 21

console.log(a == b); // true 因为两者在内存中的位置相同。

浅拷贝

上面铺垫了这么长,为的就是让大家更好的了解其中的原理。

那么为了实现文章一开头我们想要的效果,因为上面的传址操作无法满足我们的需求,所以我们可以通过浅拷贝去解决。

通过扩展运算符...实现浅拷贝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let obj1 = {
a: 1,
b: {
c: 2
}
};

let { ...obj2 } = obj1;

obj2.a = 6;

console.log(obj1.a); // 1

console.log(obj2.a); // 6

需要注意的是:在解构赋值拷贝中,一个键的值是复合类型的值(数组、对象、函数)、那么解构赋值拷贝的是这个值的引用,而不是这个值的副本,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 通过解构赋值进行浅拷贝:因为键b(?)的值是对象,所以解构赋值拷贝的是这个值的引用。
let obj1 = {
a: 1,
b: {
c: 2
}
};

let { ...obj2 } = obj1;

obj2.b.c = 6;

console.log(obj1.b.c); // 6

console.log(obj2.b.c); // 6

通过Object.assign()实现浅拷贝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 浅拷贝
let obj1 = {
a: 1,
b: {
c: 2
}
};

let obj2 = obj1;

let obj3 = Object.assign({}, obj1);

obj3.a = 2;

obj2.a = 3;

console.log(obj1.a); // 3

console.log(obj2.a); // 3

console.log(obj3.a); // 2

我们通过Object.assign()即可实现对象的浅拷贝,如上所示。

Object.assign() 方法用于将所有可枚举属性的值从一个或多个源对象复制到目标对象。它将返回目标对象。

Object.assign(target, ...sources)接受的第一个参数target为目标对象,后面的参数都是源对象。

需要注意的是:拷贝的属性是有限制的,只拷贝源对象的自身属性(不拷贝继承属性),也不拷贝不可枚举的属性(enumerable: false)。

同样还有一点就是:如果源对象某个属性的值是对象,那么目标对象拷贝得到的是这个对象的引用,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 浅拷贝,且源对象obj1.b为对象,则obj3拷贝得到的是这个对象的引用。
let obj1 = {
a: 1,
b: {
c: 2
}
};

let obj3 = Object.assign({}, obj1);

obj3.b.c = 6;

console.log(obj1.b.c); // 6

console.log(obj3.b.c); // 6

这就意味着,我们还需要进行“更进一步”的拷贝。

深拷贝

通过LodashcloneDeepAPI 实现深拷贝(推荐)

Lodash是一个一致性、模块化、高性能的 JavaScript 实用工具库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let _ = require('lodash');

let obj1 = {
a: 1,
b: {
c: 2
}
};

let obj2 = _.cloneDeep(obj1);

obj2.b.c = 6;

console.log(obj1.b.c); // 2

console.log(obj2.b.c); // 6

通过JSON.parse(JSON.stringify())进行深拷贝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 通过JSON.parse(JSON.stringify())进行深拷贝
let obj1 = {
a: 1,
b: {
c: 2
}
};

let obj2 = JSON.parse(JSON.stringify(obj1));

obj2.b.c = 6;

console.log(obj1.b.c); // 2

console.log(obj2.b.c); // 6

需要注意的是,这个方法具有局限性:

  • 拷贝时会忽略undefined
  • 拷贝时会忽略symbol
  • 不能序列化函数
  • 不能解决循环引用的对象

最后

终于写完了,也终于解决了很久之前遇到的问题!

希望这篇文章能够让你更好的了解深拷贝浅拷贝