手写系列之超详细的 JavaScript 深拷贝实现

发布于 2023-05-05 21:06:42 字数 5119 浏览 62 评论 0

配图源自 Feepik

一、为什么需要拷贝(复制)?

在 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() 方法转换成字符串,因此会被当初字符串处理。
  • NaNInfinity 的数值及 null 都会当做 null
  • 这些对象 MapSetWeakMapWeakSet 仅会序列化可枚举的属性。
  • 被转换值如果含有 toJSON() 方法,该方法定义什么值将被序列化。
  • 对包含 循环引用 的对象进行序列化,会抛出错误。

深拷贝的边界

其实,针对以上两个内置的全局方法,还有这么多情况不能处理,是不是很气人。其实不然,我猜测 JSON.parse()JSON.stringify() 只是让我们更方便地操作符合 JSON 格式的 JavaScript 对象或符合 JSON 格式的字符串。

至于上面提到的“坑”,很明显是不符合作为跨平台数据交换的格式要求的。在 JSON 中,它有 null,可没有 undefinedSymbol 类型、函数等。

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 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

野鹿林

暂无简介

0 文章
0 评论
22 人气
更多

推荐作者

懂王

文章 0 评论 0

清秋悲枫

文章 0 评论 0

niceone-tech

文章 0 评论 0

小伙你站住

文章 0 评论 0

刘涛

文章 0 评论 0

南街九尾狐

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文