手写系列之超详细的 JavaScript 深拷贝实现
一、为什么需要拷贝(复制)?
在 JavaScript 中,分为基本数据类型和引用数据类型。
基本数据类型(Primitives,原始类型)
Undefined
Null
Boolean
String
Number
Symbol
BigInt
引用数据类型(Objects,引用类型)
Object(包括基于 Object 构造器的派生对象,如 Object、Array、Function、Date、Map 等等一系列的内置对象)
请注意,明确几点!
- 原始类型的值称为原始值,比如 Boolean 类型的原始值只有 true 和 false。同理,引用类型的值被称为“引用值”。
- 所有原始值(Primitive Value)都是不可变的(immutable),而且不含任何的属性和方法。
看示例:
// 情况一:只是将一个新值 true 赋予变量 foo,而不是 20 被修改了。 let foo = 20 foo = true // 情况二:变量 foo 的值,始终没有改变。 let foo = 'foo' foo.toUpperCase() // "FOO" foo // "foo" // 情况三:可以说明将“原始值” num 传入函数 fn,只是将 num 的值拷贝了一份,然后赋予形参 n。并未影响到全局的变量 num。(注意这里指的是原始值,而非引用值) let num = 1 function fn(n) { n++ console.log(n) } fn(num) // 2 num // 1 // 情况四:只是 JS 内部自动隐式类型转换了,当一个原始值访问属性或方法时, // 会将其转换为对应的引用数据类型,再访问该引用值的属性或方法。(就是常说的包装类) let foo = 'foo' foo.length // 3,相当于 Object(foo).length 或 new String(foo).length foo.toUpperCase() // "FOO"
以上都发生了“拷贝”,只是原始值的拷贝是另外创建一个副本,而原本的值与副本是完全独立,互不干扰的。但如果是“引用值”,那就麻烦了。
const foo = { name: 'Frankie' } const bar = foo bar.name = 'Mandy' foo.name // "Mandy",修改变量 bar 的时候,foo 的值也发生改变了。 foo === bar // true,由始至终变量 foo 和 bar 都指向同一个地址。
这其实也是拷贝,只不过引用值拷贝的是“地址”(也有人说成“指针”,不重要)。因此,通常我们说的“拷贝”是指引用值的拷贝(或称为复制)。
浅拷贝
Array.prototype.slice()
Array.prototype.concat()
Object.assign()
- 利用
...
扩展运算符 - 自实现
深拷贝
JSON.stringify()
和JSON.parse()
结合$.extend()
、$.clone()
(JQuery 库)_.cloneDeep()
(Lodash 库)
区别不说了,你们都知道的。
二、如何选择深拷贝的方式?
优先级应由上至下:
JSON.stringify()
和JSON.parse()
可以处理 99.9% 的业务场景,优先选它们。内置方法处理不了的情况,应根据实际场景而定:
- 若要实现很完善的深拷贝,应选择 Lodash 的 _.cloneDeep() 方法,更可靠。
- 若不需要 Lodash 那么完善的深拷贝,应该自己手写一个,性能可能会更好。
面向面试之深拷贝,看本文。
JSON.stringify() 的缺陷
利用 JavaScript 内置的 JSON 处理函数,可以实现简易的深拷贝:
const obj = { // ... } JSON.parse(JSON.stringify(obj)) // 序列化与反序列化
这个方法,其实能适用于 99.9% 以上的应用场景。毕竟多数项目下,很少会去拷贝一个函数什么的。
但不得不说,这里面有“坑”,这些“坑”是 JSON.stringify()
方法本身实现逻辑产生的:
JSON.stringify(value[, replacer[, space]])
该方法有以下特点:
- 布尔值、数值、字符串对应的包装对象,在序列化过程会自动转换成其原始值。
undefined
、任意函数
、Symbol 值
,在序列化过程有两种不同的情况。若出现在非数组对象的属性值中,会被忽略;若出现在数组中,会转换成null
。任意函数
、undefined
被单独转换时,会返回undefined
。- 所有
以 Symbol 为属性键的属性
都会被完全忽略,即便在该方法第二个参数replacer
中指定了该属性。 Date 日期
调用了其内置的toJSON()
方法转换成字符串,因此会被当初字符串处理。NaN
和Infinity
的数值及null
都会当做null
。- 这些对象
Map
、Set
、WeakMap
、WeakSet
仅会序列化可枚举的属性。 - 被转换值如果含有
toJSON()
方法,该方法定义什么值将被序列化。 - 对包含
循环引用
的对象进行序列化,会抛出错误。
深拷贝的边界
其实,针对以上两个内置的全局方法,还有这么多情况不能处理,是不是很气人。其实不然,我猜测 JSON.parse()
和 JSON.stringify()
只是让我们更方便地操作符合 JSON 格式的 JavaScript 对象或符合 JSON 格式的字符串。
至于上面提到的“坑”,很明显是不符合作为跨平台数据交换的格式要求的。在 JSON 中,它有 null
,可没有 undefined
、Symbol
类型、函数等。
JSON 是一种数据格式,也可以说是一种规范。JSON 是用于跨平台数据交流的,独立于语言和平台。而 JavaScript 对象是一个实例,存在于内存中。JavaScript 对象是没办法传输的,只有在被序列化为 JSON 字符串后才能传输。
此前写过一篇文章,介绍了 JSON 和 JavaScript 的关系以及上述两个方法的一些细节。可看:详谈 JSON 与 JavaScript。
如果自己实现一个深拷贝的方法,其实是有很多边界问题要处理的,至于这些种种的边界 Case,要不要处理最好从实际情况出发。
常见的边界 Case 有什么呢?
主要有循环引用、包装对象、函数、原型链、不可枚举属性、Map/WeakMap、Set/WeakSet、RegExp、Symbol、Date、ArrayBuffer、原生 DOM/BOM 对象等。
就目前而言,第三方最完善的深拷贝方法是 Lodash 库的 _.cloneDeep()
方法了。在实际项目中,如需处理 JSON.stringify()
无法解决的 Case,我会推荐使用它。否则请使用内置 JSON 方法即可,没必要复杂化。
但如果为了学习深拷贝,那应该要每种情况都要去尝试实现一下,我想这也是你在看这篇文章的原意。这样,无论是实现特殊要求的深拷贝,还是面试,都可以从容应对。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论