告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(上篇)当我们浏览其他同学在网上分享的面经时,我们会发现,
往期回顾
(如果您正巧因为首页推荐的功能点进此文章,由衷地建议您先回顾往期内容,这将有助您接下来的阅读体验。)
前言
做题
实现深拷贝
描述
期望你能够实现一个深拷贝函数
myDeepClone
,该函数的用法如下:
const obj1 = {
a: 1,
b: { c: 2 }
};
const obj2 = myDeepClone(obj1);
console.log(obj1);
// 期望输出:{ a: 1, b: { c: 2 } }
console.log(obj2);
// 期望输出:{ a: 1, b: { c: 2 } }
obj2.b = 1;
console.log(obj1);
// 期望输出:{ a: 1, b: { c: 2 } }
console.log(obj2);
//期望输出:{ a: 1, b: 1 }
思路
要实现一个具备深拷贝功能的函数,我们首先得明确什么是深拷贝,什么是浅拷贝,只有明确了深拷贝和浅拷贝的定义后,我们才能确保在实现深拷贝功能时编写的代码是正确的。
不过在深入探讨深拷贝与浅拷贝的定义之前,我们首先需要了解JavaScript
中的数据存储机制。
知识点01
在JavaScript
中,数据类型分为两大类:基本数据类型(也称为原始数据类型)和引用数据类型。这两类数据在内存中的存储方式有所不同。
基本数据类型 包括:Undefined
、Null
、Boolean
、Number
、String
和 Symbol
(ES6新增)、BigInt
(ES10新增)。基本数据类型的值是直接存储在栈(Stack)内存中的,也就是说,这些类型的变量直接存储了值本身。基本数据类型的特点是:
-
值不可变:基本数据类型的值(即原始值)是不可变的,一旦创建,其值就不能改变。重要的是不要将原始值本身与分配了原始值的变量混淆。变量可能会被重新赋予一个新值,但存在的值不能像数组、对象以及函数那样被修改。语言不提供改变原始值的工具方法。
- 举个例子:
let currentStr = "hello world"; console.log(currentStr.toUpperCase()); console.log(currentStr);
可以看到,
currentStr
的值并没有被改变,事实上,String的实例方法:slice
、replace
、trim
、toUpperCase
等等都是会返回一个新的字符串,并不会修改原始字符串。👇我们还可以再通过一张图片来展示上方演示代码执行的过程👇:
-
按值访问:基本数据类型的访问是按值访问的,即操作的是变量实际的值。
引用数据类型 主要是指对象类型,包括 Object
、Array
、Function
、Date
、RegExp
等。引用数据类型的值是存储在堆(Heap)内存中的,而变量实际上存储的是一个指向堆内存中该对象的引用地址。引用数据类型的特点是:
-
值可变:引用数据类型的值是可变的,即可以通过引用地址来修改堆内存中的对象。
-
按引用访问:当操作引用类型的变量时,实际上是通过引用地址来间接操作堆内存中的对象。
- 举个例子:
// 声明一个数字 const num = 1 // 声明一个数组 const nums = [1, 2, 3, 4, 5]; // 声明一个对象 const obj = { a: "hello" };
👇我们也可以通过一张图片体现一下引用访问👇:
了解这些概念后,我们才能够更好地理解深拷贝与浅拷贝的区别。
知识点02
浅拷贝
对象的浅拷贝是属性与拷贝的源对象属性共享相同的引用(指向相同的底层值)的副本。因此,当你更改源对象或副本时,也可能导致另一个对象发生更改。与之相比,在深拷贝中,源对象和副本是完全独立的。
——MDN
深拷贝
对象的深拷贝是指其属性与其拷贝的源对象的属性不共享相同的引用(指向相同的底层值)的副本。因此,当你更改源或副本时,可以确保不会导致其他对象也发生更改;也就是说,你不会无意中对源或副本造成意料之外的更改。这种行为与浅拷贝的行为形成对比,在浅拷贝中,对源或副本的更改可能也会导致其他对象的更改(因为两个对象共享相同的引用)。
——MDN
-
浅拷贝:只拷贝对象的第一层属性,如果属性值是基本数据类型,则拷贝的是值本身;如果属性值是引用数据类型,则拷贝的是引用地址,也就是说,原对象和拷贝对象会共享同一块堆内存中的引用数据类型。
- 👇同样的,我们通过一张图感受一下👇:
- 当我们修改
nums_copy
,比如设置nums_copy[0] = 100
,那么nums
也会发生同样的改变:
const nums_copy = nums; nums_copy[0] = 100; console.log("nums", nums); console.log("nums_copy", nums_copy);
- 👇同样的,我们通过一张图感受一下👇:
-
深拷贝:不仅拷贝对象的第一层属性,而且递归拷贝所有层级属性,对于每一层级的引用数据类型,都会在堆内存中创建一个新的对象,从而实现原对象和拷贝对象在内存中的完全独立。
- 👇同样的,我们通过一张图感受一下👇:
- 当我们修改
nums_copy
,比如设置nums_copy[0] = 100
,则nums
并不会发生同样的改变:
const nums_copy = _.cloneDeep(nums); nums_copy[0] = 100; console.log("nums", nums); console.log("nums_copy", nums_copy);
综上所述,当我们谈论深拷贝和浅拷贝时,我们实际上是在讨论如何在不同数据类型的存储方式下,实现对象或数据的拷贝,以确保在修改拷贝后的数据时不会影响到原始数据。
学习完知识点之后,我们可以将视线重新聚焦在题目上。不难想到,我们的myDeepClone
函数会处理一下几种情况:
- 被用于拷贝基本数据类型的变量
- 被用于拷贝引用数据类型的变量
- 被用于拷贝引用数据类型的变量,且此变量中又包含了两种类型的数据,如:
const target = { a: 123, b: { c: [1, 2, 3] }, d: { e: { f: 123 } }, };
因此,我们的myDeepClone
函数需要加上区分数据类型的逻辑,针对不同的数据类型,做不同的处理。
👇我们可以先大致写一个版本👇:
function myDeepClone(obj) {
// 判断是否是基本数据类型
if (obj === null || typeof obj !== "object") {
return obj;
}
}
知识点03
可以看到,在上方我们刚刚写好的初版代码中,通过typeof obj !== 'object'
来区分原始值和引用数据类型,那么,为什么还要在代码里加上obj === null
呢(没记错的话,null
是原始值呀)?
因为typeof null === 'object'
的结果是true
“这不对啊,这不是乱套了吗?”
“是的,这确实不对,这是JS的历史遗留问题。”
在JavaScript
第一个版本中(即ECMAScript 1(1997年发布)),所有值都存储在 32 位的单元中,每个单元包含一个小的 类型标签(1-3 bits) 以及当前要存储值的真实数据。类型标签存储在每个单元的低位中:
000: object - 表示当前存储的数据指向一个对象。
null
的值是机器码 NULL 指针(即00000000000000000000000000000000
),根据上述内容,低位上的000
表示当前存储的数据指向一个对象,而null
因为值全是0
,
因此低位上的类型标签也是000
,和 Object
的类型标签一样,所以会被typeof
判定为Object
。
我们在最新的ECMAScript规范中,也可以看到这一点已经被作为规范本身写入了,大家记忆一下即可。
说完了null
,其实我们这里还需要再区分一个引用类型的数据——function
,在实际的业务上,我们几乎遇不到“拷贝函数”的情况。当我们调用lodash.cloneDeep
尝试去深拷贝一个函数时,
const _ = require("lodash");
function fn1() {
let a = 1;
}
const fn2 = _.cloneDeep(fn1);
console.log("fn2", fn2);
它会返回一个空对象,
因此我们也就不考虑去实现拷贝函数了。
于是我们再修改一下初版代码:
function myDeepClone(obj) {
// 判断是否是函数
if (typeof obj === "function") {
return {};
}
// 判断是否是基本数据类型
if (obj === null || typeof obj !== "object") {
return obj;
}
}
目前为止,我们算是搞定了原始值的拷贝,接下来就要去写引用类型数据的拷贝逻辑了。
知识点04
很快啊,问题就来了。
在上一小节里,我们避开了关于function
类型的拷贝,那除了object
和function
之外,JS中的引用数据类型还有哪些呢?
(换句话说,JS中的内置对象有哪些)?
再次请出我们的老朋友,ECMAScript规范
(已经不知道是第几次登场了)
Well-known intrinsics are built-in objects that are explicitly referenced by the algorithms of this specification and which usually have realm-specific identities. Unless otherwise specified each intrinsic object actually corresponds to a set of similar objects, one per realm.
Within this specification a reference such as %name% means the intrinsic object, associated with the current realm, corresponding to the name. A reference such as %name.a.b% means, as if the "b" property of the value of the "a" property of the intrinsic object %name% was accessed prior to any ECMAScript code being evaluated. Determination of the current realm and its intrinsics is described in 9.4. The well-known intrinsics are listed in Table 6.
翻译:
🤖:广为人知的内部对象是内置对象,它们被本规范的算法明确引用,通常具有特定领域的标识。除非另外指定,每个内部对象实际上对应于一组相似的对象,每个领域对应一个。
在本规范中,诸如%name%的引用意味着与当前领域关联的内部对象,对应于该名称。诸如%name.a.b%的引用,就好像在执行任何ECMAScript代码之前,访问了内部对象%name%的"a"属性的值中的"b"属性。确定当前领域及其内部对象的过程在9.4节中描述。广为人知的内部对象列表在6号表格中给出。
从表格中可以看到,内置对象的种类非常多,我们挑选一些常用的类型去实现深拷贝的逻辑。
接下来就是分类讨论的工作了,假如我们要实现上述类型的深拷贝,我们该如何确定传入myDeepClone
函数的obj
参数的类型呢?
知识点05
可以使用 instanceof 实现。
instanceof
运算符用于检测构造函数的prototype
属性是否出现在某个实例对象的原型链上。——MDN
让我们看一个代码的例子:
const a = new Date();
console.log(a instanceof Date);
// true
如果我们了解原型和原型链的话,其实也可以将instanceof
看成下方代码的“语法糖”:
const a = new Date();
console.log(a instanceof Date);
// true
console.log(a.__proto__ === Date.prototype);
// true
而更符合EMCAScript规范的写法是这样:
const a = new Date();
console.log(a instanceof Date);
// true
console.log(Object.getPrototypeOf(a) === Date.prototype);
// true
建议使用
Object.getPrototypeOf()
来替代.__proto__
再说回instanceof
,虽然我们刚刚写了一个看似等效的判断方式,但是instanceof
还会沿着原型链向上查找,我们再举个例子:
const a = new Date();
console.log(a instanceof Object);
// true
console.log(Object.getPrototypeOf(a) === Object.prototype);
// false
可以看到,两种方式的输出结果就不同了哦。
不过只要我们将Object
的判断放在最下方,那么也就不会干扰instanceof
的结果了,因此我们还是使用instanceof
来帮我们进行判断(毕竟这样的话,要写的代码就少了),我们可以改造一下代码:
function myDeepClone(obj) {
// 判断是否是函数
if (typeof obj === "function") {
return {};
}
// 判断是否是基本数据类型
if (obj === null || typeof obj !== "object") {
return obj;
}
// 判断是否是 Date
if (obj instanceof Date) {
}
// 判断是否是 RegExp
if (obj instanceof RegExp) {
}
// 判断是否是 Error
if (obj instanceof Error) {
}
// 判断是否是 Map
if (obj instanceof Map) {
}
// 判断是否是 Set
if (obj instanceof Set) {
}
// 判断是否是 Array
if (obj instanceof Array) {
}
// 判断是否是 Object
if (obj instanceof Object) {
}
}
分类讨论完毕后,接下来就是处理的逻辑了,针对这7种类型,我们要做个分类,按照是否可继续遍历的标准,将其分为以下2组:
- 不可继续遍历:Date、RegExp、Error
- 可继续遍历:Map、Set、Array、Object
针对第1组中的Date和Error,我们可以直接使用new + 构造函数
的形式,利用obj本身的数值返回一个全新的对象即可。
// 判断是否是 Date
if (obj instanceof Date) {
return new Date(obj);
}
// 判断是否是 RegExp
if (obj instanceof RegExp) {
}
// 判断是否是 Error
if (obj instanceof Error) {
return new Error(obj);
}
让我们加上测试用例看看效果:
const error1 = new Error("hello");
console.log("error1", error1);
const error2 = myDeepClone(error1);
console.log("error2", error2);
// 只给error2设置name属性
error2.name = "new copy of error1";
console.log("error1-name", error1.name);
console.log("error2-name", error2.name);
针对Date,也使用相同的测试用例试试:
const date1 = new Date("1999");
console.log("date1", date1);
const date2 = myDeepClone(date1);
console.log("date2", date2);
// 只修改date2的年份
date2.setYear(2008);
console.log("date1", date1);
console.log("date2", date2);
可以看到没有问题,那么接下来我们来讨论如何实现RegExp对象的深拷贝。
👇我们去看一下MDN上有关RegExp的介绍👇:
从上图可以看到,pattern
和flag
是创建一个RegExp对象所需要的参数。当我们需要深拷贝一个RegExp对象时,我们要做的就是拿到被拷贝对象的pattern
和flag
,然后利用这两份信息,通过构造函数,创建一个全新的RegExp对象即可。
我们可以通过下方2个实例属性获取到我们上面聊到的所需要的2个参数:
- RegExp.prototype.source:
source
属性返回一个值为当前正则表达式对象的模式文本的字符串,该字符串不会包含正则字面量两边的斜杠以及任何的标志字符。const regex = /testReg/g; console.log(regex.source); // "testReg",不包含 /.../ 和 "g"。
- RegExp.prototype.flags:
flags
属性返回当前正则表达式的标志。const regex = /testReg/g; console.log(regex.flags); // "g",不包含 /.../ 和 "testReg"。
于是,我们就可以继续修改我们作为本题答案的代码:
function myDeepClone(obj) {
// 判断是否是函数
if (typeof obj === "function") {
return {};
}
// 判断是否是基本数据类型
if (obj === null || typeof obj !== "object") {
return obj;
}
// 判断是否是 Date
if (obj instanceof Date) {
return new Date(obj);
}
// 判断是否是 RegExp
if (obj instanceof RegExp) {
const { source, flags } = obj;
return new RegExp(source, flags);
}
// 判断是否是 Error
if (obj instanceof Error) {
return new Error(obj);
}
// 判断是否是 Map
if (obj instanceof Map) {
}
// 判断是否是 Set
if (obj instanceof Set) {
}
// 判断是否是 Array
if (obj instanceof Array) {
}
// 判断是否是 Object
if (obj instanceof Object) {
}
}
同样的,我们使用测试用例跑一跑我们改版后的函数
const str = "hello823";
const regex = new RegExp("[0-9]", "g");
const match = str.match(regex);
console.log(match);
const regex_copy = myDeepClone(regex);
const match_copy = str.match(regex_copy);
console.log(match_copy);
运行后的结果如下:
小结
写到这里,本文的字符数已经接近20000字,正文字数4000字,因此,为了更好的阅读体验,做个小结。
在本篇文章中,我们从一道经典的手写题入手(实现一个深拷贝函数),回顾了5个JS相关的知识点,大家可以先消化一下。
在这个信息爆炸的时代,长篇文章往往能够带给读者更为深入的学习体验,但同时也对读者的耐心和专注力提出了更高的要求。
考虑到我们还剩一半的内容没有写(处理可以继续遍历的引用类型数据,如Map
、Set
、Array
、Object
),本文总正文字数可能会超过10000字,因此还是决定将【EP02】分为上、下两篇。
我希望通过【EP02】的丰富内容,能够切实地为大家的知识库添砖加瓦,让大家在阅读的过程中收获满满。
期待与你在【EP02】(下篇)相遇!
转载自:https://juejin.cn/post/7402922513888362548