anjia / blog

博客,积累与沉淀
107 stars 4 forks source link

浅拷贝和深拷贝 #97

Open anjia opened 2 years ago

anjia commented 2 years ago

浅拷贝

对象的浅拷贝,就是对象的属性会和源对象的属性共享相同的引用。

我们知道,在 JavaScript 里,有两种数据类型:原始数据类型和对象,所以对象的属性要么是原始类型,要么就是对象。而原始类型是直接对应底层的原始值,所以上面提到的“共享相同的引用”指的就是“对象”。也就是说,对象的浅拷贝,就是对象里的“对象属性”会彼此共享相同的引用。

原理

看个例子感受下。student 对象:

let student = {
    "name": "Lee",
    "age": 18,
    "hobbies": ["drawing", "painting"],
    "scores": {
        "DS": 100,
        "CS": 99
    }
};

在对 student 对象进行浅拷贝的时候,首先遍历 student 对象的第一层属性,并将各个键值对依次赋值给浅拷贝对象 student_shallow_copy,即 student_shallow_copy[key] = student[key]。此时 student_shallow_copy 对象和 student 对象是两个独立的地址,但它们拥有相同的键值对。当键 key 的值是原始值时,= 相当于是直接重新赋值了,比如 "name" 属性和 "age" 属性;当键 key 的值是对象时,= 相当于是赋值了个引用,比如 "hobbies" 属性和 "scores" 属性。如下图:

接下来,用代码对上面的逻辑做个验证。

let student_shallow_copy = Object.assign({}, student); // 浅拷贝

// 1. 地址不同:两个对象本身不相等
console.log(student == student_shallow_copy); // false
console.log(student === student_shallow_copy); // false
// 2. 内容相同:两个对象的属性名和属性值是相等的
console.log(JSON.stringify(student) === JSON.stringify(student_shallow_copy)); // true
for (let k in student) {
    console.log(student[k] === student_shallow_copy[k]); // 均是 true
}

在此基础上,我们思考:当更改一个对象的时候,另一个对象会如何改变?

1. 修改对象本身

包括:删除对象、给对象重新赋值。

因为两个对象本身是相互独立的,所以当“修改对象本身”时,互不影响。代码如下:

let student_shallow_copy = Object.assign({}, student); // 浅拷贝
student_shallow_copy = {
    "name": "An",
    "age": 20
};
console.log('student: ', student); // 还是原状
console.log('student_shallow_copy: ', student_shallow_copy); // 是新赋的值

执行结果如下:

浅拷贝1

2. 修改对象的属性

包括:新增属性、删除属性、修改属性。

因为两个对象的地址是不一样的,所以当“修改对象的(一级)属性”时,也互不影响。代码如下:

let student_shallow_copy = Object.assign({}, student); // 浅拷贝

const log = (formula, expected) => {
    console.group(`执行 ${formula}`);
    expected && console.log(`预期:${expected}`);
    console.log('student: \t \t \t ', student);
    console.log('student_shallow_copy: ', student_shallow_copy);
    console.groupEnd();
}

// 1. 增加属性
student_shallow_copy['sex'] = 'female';
log("student_shallow_copy['sex'] = 'female'", '互不影响');
// 2. 删除属性
delete student['scores'];
log("delete student['scores']", '互不影响');
// 3. 修改属性
student_shallow_copy['name'] = 'An'; // 修改普通属性
student_shallow_copy['hobbies'] = ['jogging']; // 修改对象属性
log("student_shallow_copy['name'] = 'An' 和 student_shallow_copy['hobbies'] = ['jogging']", '互不影响');

执行结果如下:

值得一提的是,当执行完 student_shallow_copy['hobbies'] = ['jogging'] 之后,两个对象的示意图如下:

3. 修改属性的内部值

只有当一级属性是对象时,才会出现这种情况。

根据浅拷贝的拷贝原理,我们知道,对象里的“对象属性”是会彼此共享相同引用的,所以当“修改属性的内部值”时,是会同时生效的。代码如下:

let student_shallow_copy = Object.assign({}, student); // 浅拷贝

const log = (formula, expected) => {
    console.group(`执行 ${formula}`);
    expected && console.log(`预期:${expected}`);
    console.log('student: \t \t \t ', student);
    console.log('student_shallow_copy: ', student_shallow_copy);
    console.groupEnd();
}

// 1. 修改 hobbies 属性的
student.hobbies[0] = 'reading';
student_shallow_copy.hobbies.push('jogging');
log("student.hobbies[0] = 'reading' 和 student_shallow_copy.hobbies.push('jogging')", '同时生效');
// 2. 修改 scores 属性的
student.scores['CS'] = 99;
student_shallow_copy.scores['Math'] = 100;
log("student.scores['CS'] = 99 和 student_shallow_copy.scores['Math'] = 100", '同时生效');

执行结果如下:

写在最后

最后,再看个数组对象的例子,来进一步理解浅拷贝的逻辑。

let arr = [0, '1', true, [1, 2, 3], { 'list': ['1', '2'] }];
let arr_shallow_copy = [...arr];

// 两个对象本身不相等
console.log(arr == arr_shallow_copy); // false
console.log(arr === arr_shallow_copy); // false
// 但它们的属性是相等的
for (let i of arr) {
    console.log(arr[i] === arr_shallow_copy[i]); // 均是 true
}
console.log(JSON.stringify(arr) === JSON.stringify(arr_shallow_copy)); // true

此时,arr 对象和 arr_shallow_copy 对象的示意图如下:

以下操作的结果,预期是什么?

// 先自己思考下,再打开控制台看看运行结果
arr.push(100);
log();

arr[0] = 100;
arr_shallow_copy[1] = '200';
arr[2] = false;
arr_shallow_copy[3] = [100, 200, 300];
arr[9] = 999;
log();

arr[3].push(4);
arr_shallow_copy[3].push(5);
log();

arr[4].list[0] = '100';
arr_shallow_copy[4].name = 'add name';
log();

arr_shallow_copy = null;
log();

function log() {
    console.group();
    console.log('arr: \t\t\t ', arr);
    console.log('arr_shallow_copy:', arr_shallow_copy);
    console.groupEnd();
}

总结

对象的浅拷贝,就是对象里的“对象属性”会彼此共享相同的引用。在这样的拷贝前提下,当我们更改源对象或者拷贝对象的时候,就有可能导致另一个对象也发生更改。对于浅拷贝,理解“修改对象的属性”和“修改对象的属性内部”之间的区别是非常重要的。

与浅拷贝对应的是深拷贝。在深拷贝中,源对象和拷贝对象是完全独立的,就不会出现这种联动的更改行为。

在 JavaScript 中,所有标准的内置的对象复制操作都是创建的浅拷贝,而不是深拷贝。诸如:

主要参考

https://developer.mozilla.org/en-US/docs/Glossary/Shallow_copy

anjia commented 2 years ago

深拷贝

对象的深拷贝和源对象是完全独立的,其属性不会和源对象共享任何相同的引用。

有两种方法,可以对对象进行深拷贝。

1. 序列化和反序列化

序列化就是把一个对象或者数据结构转换成适合在网络中传输或者适合存储(数据库或硬盘)的格式,即将复杂对象转成位序列。当根据序列化格式重新生成数据的时候,就能创建和原始对象的语义相同的克隆,这个过程称为反序列化。常见的序列化格式有 XML、JSON、YAML 等,更多格式可查阅序列化格式。序列化的缺点是打破了抽象数据类型的不透明性,因为它不同程度地暴露了私有的实现细节。

如果一个 JavaScript 对象是可序列化的,那就可以使用 JSON.stringify()JSON.parse() 对其进行深拷贝,方法就是:

示例代码如下:

let student = {
  name: "Lee",
  age: 18,
  hobbies: ["drawing", "painting"],
  scores: {
    DS: 100,
    CS: 99
  }
};
let student_deep_copy = JSON.parse(JSON.stringify(student));
// 分别修改这两个对象
student.hobbies.push("jogging"); // 源对象
student_deep_copy.scores.CS = 100; // 深拷贝对象
// 输出查看,两个对象彼此互不影响
console.log(student);
console.log(student_deep_copy);

因为源对象和深拷贝对象是完全独立的,所以当分别修改它们的时候,是各自生效而彼此互不影响的。执行结果如下:

从 ECMAScript 5.1 开始,JavaScript 就包含了内置的 JSON 对象及其方法 JSON.stringify()JSON.parse()

2. 结构化克隆

我们还可以使用 structuredClone() 去创建深拷贝,它不仅仅是克隆,还会把源对象中的可转移对象转移到新对象中。

可转移对象是拥有可以从一个上下文转移到另一个上下文的资源的对象,它能确保资源一次只在一个上下文中可用。转移之后,源对象不再可用,任何读取或写入它的操作都会抛出异常。可转移对象通常用于共享一次只能安全地暴露给单个 JavaScript 线程的资源,比如 ArrayBuffer, ImageBitmap, OffscreenCanvas, AudioData, VideoFrame, ReadableStream, WritableStream, TransformStream, MessagePort, RTCDataChannel 等对象。

需要注意的是,structuredClone() 不是 JavaScript 语言本身的特性,而是浏览器或其它 JavaScript 运行时环境提供的全局对象的特性,比如 window.structuredClone()

window.structuredClone() 内部使用结构化克隆算法,也支持循环引用——在递归的同时维护了一个已访问引用的映射。带循环引用的深拷贝,代码如下:

let family = {
  father: {
    name: "Zhang"
  },
  mother: {
    name: "Li"
  },
  daughter: {
    name: "Wang"
  }
};
family.father.family = family; // 循环引用
// 序列化会报错,因为有循环引用
JSON.stringify(family);
// structuredClone() 可正常工作
let family_deep_copy = window.structuredClone(family);
console.log("family_deep_copy: ", family_deep_copy);

执行结果如下:

补充说明:window.structuredClone(value, transferables) 的两个参数

总结

对象的深拷贝和源对象是完全独立的,其属性不会和源对象共享相同的引用。所以在分别修改源对象和深拷贝对象的时候,它们是各自生效的,彼此互不影响。

对于可序列化的 JavaScript 对象,可以使用 JSON.stringify()JSON.parse() 对其进行深拷贝。然而,还有很多 JavaScript 对象根本就不能序列化,比如闭包函数、Symbol、HTML DOM API 中表示 HTML 元素的对象、递归数据,以及其它情况。对于这些无法序列化的对象,是没有办法对其进行深拷贝的。

我们还可以使用 structuredClone() 去创建深拷贝,它的优点是支持将源对象中的可转移对象转移到新对象中去,同时还支持循环引用。但它也不是支持所有的 JavaScript 对象。

在 JavaScript 中,所有标准的内置的对象复制操作创建的都不是深拷贝,而是浅拷贝。有关浅拷贝的内容,可查阅之前的文章《浅拷贝》。

主要参考