likes
comments
collection
share

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

作者站长头像
站长
· 阅读数 21

往期回顾

(如果您正巧因为首页推荐的功能点进此文章,由衷地建议您先回顾往期内容,这将有助您接下来的阅读体验。)

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

前言

在上一篇文章里,我们分析并编写了深拷贝函数一半的代码,在本篇文章中,我们将彻底完成深拷贝函数,并对整个过程里遇到的知识点做一个总结。

话不多说,开冲!

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

思路

当我们要拷贝一个可以继续遍历的引用类型数据时,比如下方给出的示例中的a:

const a = {
  b: 1,
  c: "hello",
  d: null,
};

我们要怎么做?

不难想到,我们需要创建一个新的对象({}),然后把a中的键值对添加进这个新对象即可。

如何获取一个对象中的键值对信息呢?

我们只有拿到a中的键,才能再拿到对应的值,最终将映射关系构建在新的对象中。

知识点06

获取一个对象中的键,有哪些方式?

利用表达式语句:for...in

表达式语句来考虑的话,自然是最最最“古老”的for...in语句了

for...in:迭代一个对象的所有可枚举字符串属性(除 Symbol 以外),包括继承的可枚举属性。——MDN

在上面的定义里,我们重点关注2个词语:

  • 【可枚举属性】
  • 【继承的】

什么是可枚举属性?

拿刚刚我们创建的a对象来说,通过属性初始化方式a对象中声明的bcd都是a的对象的可枚举属性。

我们可以使用 propertyIsEnumerable 方法来验证这个结论:

const a = {
  b: 1,
  c: "hello",
  d: null,
};
console.log("is b enumerable?", a.propertyIsEnumerable("b"));
console.log("is c enumerable?", a.propertyIsEnumerable("c"));
console.log("is d enumerable?", a.propertyIsEnumerable("d"));

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

可以看到,我们的结论被证实了,没问题。

那么,不知道你是否会有这样一个疑问,如果通过赋值语句给a对象再创建一个新的属性值,结果又会如何?

a.e = "new props";
console.log("is e enumerable?", a.propertyIsEnumerable("e"));

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

显然,也没问题。这样动手实践之后,我们再去看MDN上关于“可枚举字符串属性”的定义:

可枚举属性是指那些内部“可枚举”标志设置为 true 的属性,对于通过直接的赋值属性初始化的属性,该标识值默认为即为 true,对于通过 Object.defineProperty 等定义的属性,该标识值默认为 false

那么什么属性是通过Object.defineProperty等定义的属性呢?

举个例子,比如a.toString()toString()

console.log(a.toString());
console.log("is toString enumerable?", a.propertyIsEnumerable("toString"));

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

再比如Array:length,也是不可枚举属性。

const nums = [1, 2, 3];

console.log("is length enumerable?", nums.propertyIsEnumerable("length"));

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

聊完了“可枚举字符串属性”后,接下来我们聊一聊“继承的”是怎么一回事。

知识点07

这里其实就涉及到原型原型链的知识了。出于篇幅考虑,我这里只简单提一下和本文相关的内容。

让我们直接通过代码例子来掌握相关的知识点吧。我们创建一个Foo作为类,并给这个Foo设置几个属性方法。

class Foo {
  constructor(name) {
    this.name = name;
    this.say = () => {
      console.log("My name is ", this.name);
    };
  }

  say() {
    console.log("My default name is ", "Foo");
  }
}

Foo.prototype.name = "default name";
Foo.prototype.age = 100;
Foo.prototype.dance = () => {
  console.log("I'm dancing");
};

这里有两个say()函数,一个写在了constructor里面,一个写在了外面,区别是什么?

  • 前者:表示每一个被创建的实例,都带有say()方法。
  • 后者:表示Foo的prototype(原型)上有一个say()方法,同理,dance()方法以及nameage等属性也都是保存在了Foo的prototype(原型)上。
const foo1 = new Foo("foo1");

console.log(foo1.say());

console.log(Foo.prototype.say());

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

此时,当我们用for...in语句去遍历foo1实例对象时,会遍历到Foo的prototype(原型)上的属性,这就是所谓的“继承的

for (const key in foo1) {
  if (typeof foo1[key] === "function") {
    console.log(key + ": ", foo1[key].toString());
  } else {
    console.log(key + ": ", foo1[key]);
  }
}

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

不知道你是否发现一个问题?为什么同样是Foo.prototype上的方法,for...in语句遍历到了dance(),却没有遍历到say()?

回想一下上一章节我们提到的【可枚举字符串属性】,为什么for...in语句无法遍历到say(),是因为定义在class内部的函数方法,默认是不可枚举的

console.log(
  "is Foo.prototype.say enumerable?",
  Foo.prototype.propertyIsEnumerable("say")
);

console.log(
  "is Foo.prototype.dance enumerable?",
  Foo.prototype.propertyIsEnumerable("dance")
);

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

很显然,当我们要深拷贝一个对象时,我们没有必要把此对象原型链上的属性逐级拷贝。因此如果我们硬要使用for...in语句,我们需要借助hasOwnProperty方法,如下:

for (const key in foo1) {
  if (foo1.hasOwnProperty(key)) {
    if (typeof foo1[key] === "function") {
      console.log(key + ": ", foo1[key].toString());
    } else {
      console.log(key + ": ", foo1[key]);
    }
  }
}

此方法能够判断当前遍历到的key是否是实例对象foo1身上的属性。 告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

这样好麻烦对不对,何必要多此一举呢?

没错,于是我们回到知识点6,继续讲另一种可以遍历对象键值的方式。

利用Object的静态方法:Object.keys()

Object.keys()  静态方法返回一个由给定对象自身的可枚举的字符串键属性名组成的数组。——MDN

让我们修改一下刚刚的代码,试试

Object.keys(foo1).forEach((key) => {
  if (typeof foo1[key] === "function") {
    console.log(key + ": ", foo1[key].toString());
  } else {
    console.log(key + ": ", foo1[key]);
  }
});

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

很好,现在我们就不担心会遍历到原型链上去了。

但是目前还是有一个问题,就是使用Object.keys()这种方式,我们还是无法遍历对象上不可枚举的的属性。

const mySymbol = Symbol("456");
const testObj = { mySymbol: 456 };
Object.defineProperty(testObj, "prop1", { value: 123 });
console.log(
  "is testObj.prop1 enumerable?",
  testObj.propertyIsEnumerable("prop1")
);

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

从上图的输出结果可以看到,不可枚举的属性prop1并没有被遍历到。

我们期望深拷贝一个对象时,能够复制它全部的自有属性,包括不可枚举的属性,这该怎么办呢?

利用内置对象Reflect的静态方法:Reflect.ownKeys()

静态方法 Reflect.ownKeys()  返回一个由目标对象自身的属性键组成的数组。

让我们修改一下代码,直接看看效果:

Reflect.ownKeys(testObj).forEach((key) => {
  console.log("Reflect.ownKeys:" + key + ": ", testObj[key]);
});

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

从上图的输出结果可以看到,不可枚举的属性prop1也被遍历到了。

ok,在上面的内容中,我们讨论了遍历对象键值对的三种方式,比较优劣之后,最终确定了使用Reflect.ownKeys()去实现。于是我们可以修改一下MyDeepClone的代码:

  // 判断是否是 Object
  if (obj instanceof Object) {
    const cloneObj = {};
    Reflect.ownKeys(obj).forEach((key) => {
      // 如果属性不可枚举
      if (!obj.propertyIsEnumerable(key)) {
        Object.defineProperty(cloneObj, key, { value: myDeepClone(obj[key]) });
      } else {
        cloneObj[key] = myDeepClone(obj[key]);
      }
    });

    return cloneObj;
  }

然后让我们过一下测试用例

const obj = {
  a: 123,
  b: { c: 456 },
  d: "hello",
  e: { f: { g: "bye" } },
};
Object.defineProperty(obj, "prop1", { value: "can you copy" });
const obj_copy = myDeepClone(obj);

console.log("obj_copy", obj_copy);
obj_copy.a = 1;
console.log("obj", obj);
console.log("is obj.prop1 enumerable?", obj.propertyIsEnumerable("prop1"));
console.log("obj_copy", obj_copy);
console.log(
  "is obj_copy.prop1 enumerable?",
  obj_copy.propertyIsEnumerable("prop1")
);

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

可以看到,成功完成了深拷贝,并且在新的对象里,目标对象中的不可枚举的属性也依旧保持着不可枚举。

知识点08

注意到我们的函数是个递归函数,即myDeepClone会在函数主体中调用自身。那么是否会存在栈溢出的情况呢?

比如:

function foo() {
  return foo();
}

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

结论是,存在这种风险!!!

为什么?因为对象可能出现循环引用的情况。

const obj = {};

obj.a = obj;

const obj_copy = myDeepClone(obj);

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

从上图可以看到,当期望被拷贝的目标对象发生循环引用时,我们之前写的拷贝逻辑就会陷入死循环,从而导致栈爆了,哈哈......有点尴尬。

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

那么如何避免循环引用呢?

我们可以使用一个Map,来确保唯一性,当遍历到的值已经存在于Map,则直接return Map.get(obj),从而结束递归,避免爆栈。

我们再来改造一下代码:

function myDeepClone(obj, hash = new Map()) {
  // 判断是否是函数
  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) {
    // 如果obj已经被拷贝过了
    // 则return
    if (hash.has(obj)) {
      return hash.get(obj);
    }
    const cloneObj = {};
    hash.set(obj, cloneObj);
    Reflect.ownKeys(obj).forEach((key) => {
      // 如果属性不可枚举
      if (!obj.propertyIsEnumerable(key)) {
        Object.defineProperty(cloneObj, key, {
          value: myDeepClone(obj[key], hash),
        });
      } else {
        cloneObj[key] = myDeepClone(obj[key], hash);
      }
    });

    return cloneObj;
  }
}

修改完毕后,再让我们执行一下测试用例:

const obj = {};

obj.a = obj;

const obj_copy = myDeepClone(obj);
console.log("obj", obj);

console.log("obj_copy", obj_copy);

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

深拷贝成功,并且不会再发生爆栈的情况了。

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

👀 我是说,有没有一种可能,还能够再优化一下?

啊这......

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

不行,必须学,都看到这里了,怎么能不再进一步呢!!🤯

知识点09

使用 WeakMap 替代 Map

WeakMap 是一种键值对的集合,其中的键必须是对象或非全局注册的符号,且值可以是任意的 JavaScript 类型,并且不会创建对它的键的强引用。换句话说,一个对象作为 WeakMap 的键存在,不会阻止该对象被垃圾回收。一旦一个对象作为键被回收,那么在 WeakMap 中相应的值便成为了进行垃圾回收的候选对象,只要它们没有其他的引用存在。唯一可以作为 WeakMap 的键的原始类型是非全局注册的符号,因为非全局注册的符号是保证唯一的,并且不能被重新创建。——MDN

什么意思呢,我们可以通过2个代码例子给大家展示一下强引用和弱引用对于垃圾回收机制的区别:

let obj2 = { a: 1 };
const map = new Map();
map.set(obj2, "some value");

obj2 = null;

// 在浏览器的任务队列空闲时,执行以下函数检查对象是否被回收
setTimeout(() => {
  if (!map.size) {
    console.log("对象已经被垃圾回收");
  } else {
    console.log("对象尚未被垃圾回收");
  }
}, 1000);

我们将这段代码放在浏览器中执行:

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

可以看到map中仍然保存着obj2

而当我们使用WeakMap时:

let obj = { a: 1 };
const weakMap = new WeakMap();
weakMap.set(obj, "some value");

obj = null;

// 在浏览器的任务队列空闲时,执行以下函数检查对象是否被回收
setTimeout(() => {
  if (!weakMap.size) {
    console.log("对象已经被垃圾回收");
  } else {
    console.log("对象尚未被垃圾回收");
  }
}, 1000);

我们也将这段代码放在浏览器中执行:

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

可以看到,当obj被置为null之后,weakMap也会清空。

试想一下,假如我们深拷贝的对象obj非常非常非常多的键值对时,那么在我们调用完myDeepClone方法后,内存中的Map会存储异常多的键值数据,这些数据无法被垃圾回收释放掉,从而造成我们不期望的性能消耗。

因此,我们需要将Map替换为WeakMap,修改后的代码如下:

function myDeepClone(obj, hash = new WeakMap()) {
  // 判断是否是函数
  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) {
    if (hash.has(obj)) {
      return hash.get(obj);
    }
    const cloneObj = {};
    hash.set(obj, cloneObj);
    Reflect.ownKeys(obj).forEach((key) => {
      // 如果属性不可枚举
      if (!obj.propertyIsEnumerable(key)) {
        Object.defineProperty(cloneObj, key, {
          value: myDeepClone(obj[key], hash),
        });
      } else {
        cloneObj[key] = myDeepClone(obj[key], hash);
      }
    });

    return cloneObj;
  }
}

我们使用 WeakMap 去替换 Map 是因为JS的垃圾回收机制,从而避免内存泄漏。那么如果我们在面试途中一边说明这样写的思路一边回答问题的话,通常情况下,面试官会追问我们JS的垃圾回收机制是怎么样的。

这就和写在简历上的技术栈一样,不熟悉的宁愿不写上去,否则反而成了破绽。

因此我们在回答深拷贝的问题的时候,提到了垃圾回收机制,并注意到了写法上存在内存泄露的隐患,加以避免。

这本来是个加分项,但是面试官一追问,我们却无法很好地回答什么是JS的垃圾回收机制。

那反而就弄巧成拙了。

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

因此这里不得不和大家一起聊聊,什么是JS的垃圾回收机制。

知识点10

当我们想要聊“垃圾回收”时,实际上在谈论的应该是“内存管理”。当我们在JS程序中创建变量时,JS会自动为这些变量分配内存。当这些变量使用完之后,我们当然不期望这些变量依旧留在内存中,因为内存空间很宝贵。因此JS会在确定这些变量不会再被使用了之后,释放内存

内存管理主要就是这2个过程组成的:分配内存 和 释放内存。

而释放内存,也就是我们常说的,垃圾回收。

系铃容易,解铃难。

在上面的介绍里我们提到了,垃圾回收期望的效果是在“内存不再需要使用时释放”,然而如何判断哪些被分配的内存是确实已经不再被需要的,是非常困难的。

就客观现实而言,亦是如此。不同的垃圾回收算法都有其局限性,在本章节,我会介绍2种类型的垃圾回收算法。

引用计数垃圾收集算法

虽然已经被废弃了,但是了解一下没坏处

在聊引用计数垃圾收集算法之前,我们得先搞懂这里提到的“引用”的概念:

引用:垃圾回收算法主要依赖于引用的概念。在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象。例如,一个 Javascript 对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用)。——MDN

(在这里,“对象”的概念不仅特指 JavaScript 对象,还包括函数作用域(或者全局词法作用域)。)

举个例子:


var obj1 = { a:1 }
var obj2 = obj1

在这个例子中,我们创建了一个对象{ a:1 },它被 obj1obj2 引用。

接下来我们继续聊引用计数垃圾收集算法

这是最初级的垃圾收集算法。此算法把“对象是否不再需要”简化定义为“对象有没有其他对象引用到它”。如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收。——MDN

我们来通过几个简单的例子理解这个算法

// 示例1
var obj = { a: 1 }
obj = null;

在第一行代码,我们创建了{ a: 1 }这个对象,obj对它存在1次引用,因此{ a: 1 }这个对象目前还不会被垃圾回收机制回收。

在第二行代码,obj被重新赋值成了null,此时程序里没有任何其他变量对{ a: 1 }这个对象产生引用,即没有引用指向{ a: 1 }这个对象(存在0次引用),于是{ a: 1 }这个对象就会被垃圾回收机制回收掉。

在最开始的介绍里,我在定义下方用括号提到了一行备注

在这里,“对象”的概念不仅特指 JavaScript 对象,还包括函数作用域(或者全局词法作用域)。

因此根据这行备注,我们再一起看下示例2:

// 示例2

function test() {
  var obj1 = { a: 1 };
  var obj2 = { b: 1 };
}

test();

我们在函数作用域创建了2个对象: { a: 1 }{ b: 1 },之后我们执行test函数,由于函数作用域会在函数执行后销毁,因此在其内部创建的变量也会一同销毁,不再有任何对象对{ a: 1 }{ b: 1 }产生引用(0引用),因此垃圾回收机制会把这俩对象回收掉。

看完简单的,再来看一个复杂的(修改自MDN示例):

// 示例3
var obj = {
  a: {
    b: 1,
  },
};


var obj2 = obj; 

obj = 1; 

var objA = obj2.a; 

obj2 = "Over"; 

objA = null;

我们一起逐行分析一下:

var obj = {
  a: {
    b: 1,
  },
};

最开始,有2个对象被创建了,一个是{ b: 1 },另一个是{ a: { b: 1 } }。为了表述方便,我们暂且使用 BA 来表示它们。

  • 由于BA的属性,因此存在AB的显示引用,B不会被垃圾回收机制回收。
  • A又被赋值给了obj,因此存在objA的引用,A也不会被垃圾回收机制回收。

紧接着:

var obj2 = obj;
obj = 1;
  • 第一行导致obj2A也产生了引用。
  • 第二行导致obj不再对A产生引用,因此A的引用计数又变回1。
var objA = obj2.a;

这一行又创建了一个变量objA,它对Aa属性产生了引用,那当然也对A产生了引用,因此A的引用计数增加到2。

obj2 = "Over"; 
objA = null;
  • 第一行对obj2重新赋值,因此obj2不再对A产生引用,A的引用计数为1
  • 第二行对objA重新赋值,因此objA不再对A产生引用,A的引用计数为0,可以被垃圾回收机制回收。
  • A被垃圾回收机制回收后,不再有任何对B的引用存在了,因此B也可以被垃圾回收机制回收。至此两个对象全部回收完毕。

以上就是有关引用计数垃圾收集算法的全部介绍了。当我们频繁地提起引用引用引用的时候,不知道你是否已经建立起了一种条件反射。

如果我说这种垃圾回收机制存在缺陷,你能够想到是什么吗?

没错,缺陷就是无法处理循环引用

让我们略微改造一下示例2的代码:

// 示例4
function test() {
  var obj1 = { a: 1 };
  var obj2 = { b: 1 };

  obj1.a = obj2;
  obj2.b = obj1;

}

test();

在示例4中,下方两行赋值语句造成了循环引用的出现。按照原本的逻辑,当函数执行完毕后,这两个对象离开函数作用域,自身的使命已经完成,等待被垃圾回收机制回收,释放内存。但是由于它俩循环引用,彼此之间互相存在1次引用,因此引用计数垃圾收集算法发现它们并不是0引用的,于是不会回收它们,这就导致了内存泄漏。

假如这两个对象并不是像现在这样这么简单地只包含一个属性,并且属性值还是个简单的原始值,而是属性值里存在一个Array实例,并且这个数组的size超过10000,那么这就是很严重的内存泄漏问题了。

考虑到这种缺陷,我们引入了另一种垃圾回收算法,而这种算法也一直使用到了今天,那就是标记-清除算法。

标记-清除算法

标记-清除算法:这个算法把“对象是否不再需要”简化定义为“对象是否可以获得”。

这个算法假定设置一个叫做根(root)的对象(在 Javascript 里,根是全局对象)。垃圾回收器将定期从根开始,找所有从根开始引用的对象,然后找这些对象引用的对象……从根开始,垃圾回收器将找到所有可以获得的对象和收集所有不能获得的对象。——MDN

采用标记-清除算法之后,循环引用导致的内存泄漏问题就得到了解决。回看示例:

function test() {
  var obj1 = { a: 1 };
  var obj2 = { b: 1 };

  obj1.a = obj2;
  obj2.b = obj1;

}

test();

test函数被执行之后,产生循环引用的这两个对象无法被全局对象获取,因此由于它们是“无法获取”的对象,所以会被垃圾回收机制回收。

当然,标记-清除算法也并不是十全十美的,它也有自己的缺陷。

那些无法从根对象查询到的对象都将被清除

不过呢,在实践中我们很少会碰到类似的情况,所以基本上可以忽略这个问题。

从 2012 年起,所有现代浏览器都使用了标记 - 清除垃圾回收算法。所有对 JavaScript 垃圾回收算法的改进都是基于标记 - 清除算法的改进,并没有改进标记 - 清除算法本身和它对“对象是否不再需要”的简化定义。——MDN

如果你对具体的垃圾回收算法想做更进一步,更深度地学习,可以阅读一下微医团队的这篇文章,相信会对你产生帮助:

OK,我们终于将扩展的知识点讲完了,现在继续回到深拷贝这道题目上。

在上面章节的内容中,我们学习了如何选择一个合适的遍历对象中属性的方法,以及如何避免循环引用导致的爆栈问题。接下来我们要处理MapSetArray这三种剩下的引用类型数据的深拷贝。

知识点11

那么就先从Map开始好了。

Map 对象保存键值对,并且能够记住键的原始插入顺序。任何值(对象或者原始值)都可以作为键或值。——MDN

提到它,我很想和大家聊一下它的实例方法gethas,这在写一些算法题的时候,使用has,往往能帮助我们做出更准确的判断。

举个例子:

const hash = new Map();

hash.set(7, 0);

if (hash.get(7)) {
  console.log("find it");
} else {
  console.log("can not find");
}

这段程序执行之后输出如下:

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

虽然我们明明往Map里面set过了7这个键值,为什么用get方法会获取不到呢?因为get返回的值是存入的值,0。而0在条件语句中会被类型转换,转换成false。因此,当我们在写一些算法题,需要保存数组中元素出现的索引值时,如果保存的是首项,即索引值为0的那一项,当我们使用get去尝试作为条件语句的判断标准,往往就会出错

因此,在其位,谋其职。我们要使用has方法。

const hash = new Map();

hash.set(7, 0);

if (hash.has(7)) {
  console.log("find it");
} else {
  console.log("can not find");
}

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

这样我们的条件语句就不会出错了。

当我们要去深拷贝一个Map对象时,我们需要调用Map的构造函数,创建一个空的Map实例,然后遍历传入的目标对象的键值,一个一个给空的Map实例去set好键值对即可。

如何遍历一个Map的键值对呢?

答:使用Map的实例方法forEach即可。

Map 实例的 forEach()  方法按插入顺序对该 map 中的每个键/值对执行一次提供的函数。

const hash2 = new Map([
  ["a", 1],
  ["b", {}],
  ["c", null],
]);

hash2.forEach((value, key) => {
  console.log(key + ": ", value);
});

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

于是我们可以修改一下深拷贝函数,补充上拷贝Map的内容。

  // 判断是否是 Map
  if (obj instanceof Map) {
    const cloneObj = new Map();

    obj.forEach((value, key) => {
      cloneObj.set(key, myDeepClone(value));
    });

    return cloneObj;
  }

让我们过一下测试用例:

const hash2 = new Map([
  ["a", 1],
  ["b", { d: "Hello" }],
  ["c", null],
  ["e", undefined],
]);

const hash2_copy = myDeepClone(hash2);

console.log("hash2", hash2);
console.log("hash2_copy", hash2_copy);

hash2_copy.set("dddddd", 12312321);
hash2_copy.set("a", 0);

console.log("hash2", hash2);
console.log("hash2_copy", hash2_copy);

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现, 从输出结果来看,确实过了测试用例,但是有一个问题,Map也是会存在循环引用的,我们的代码能通过发生了循环引用的用例吗?

const hash3 = new Map([
  ["a", 1],
  ["b", { d: "Hello" }],
  ["c", null],
  ["e", undefined],
]);

hash3.set("f", hash3);

console.log("hash3", hash3);
const hash3_copy = myDeepClone(hash3);

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

显然是不行,因此我们也得在Map的case里,加上处理循环引用的逻辑。

  // 判断是否是 Map
  if (obj instanceof Map) {
    if (hash.has(obj)) {
      return hash.get(obj);
    }
    const cloneObj = new Map();
    hash.set(obj, cloneObj);

    obj.forEach((value, key) => {
      cloneObj.set(key, myDeepClone(value, hash));
    });

    return cloneObj;
  }

然后让我们再试一次

const hash3 = new Map([
  ["a", 1],
  ["b", { d: "Hello" }],
  ["c", null],
  ["e", undefined],
]);

hash3.set("f", hash3);

console.log("hash3", hash3);
const hash3_copy = myDeepClone(hash3);
console.log("hash3_copy", hash3);

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

OK,不会再出现爆栈的情况了。

这里我们再扩展一个可能会问到的面试问题:

Map和Object的区别有哪些?

(下方表格内容引自MDN文档,大家可以查阅一下)

Map Object
意外的键 Map 默认不包含任何键。它只包含显式存入的键值对。

Object 有原型,因此它包含默认的键,如果不小心的话,它们可能会与你自己的键相冲突。

备注:这可以通过使用 Object.create(null) 来绕过,但很少这样做。

安全性 Map 可以安全地与用户提供的键值一起使用。

Object 上设置用户提供的键值对可能会允许攻击者覆盖对象的原型,这可能会导致对象注入攻击。就像意外的键问题一样,这也可以通过使用 null 原型对象来缓解。

键的类型 Map 的键可以为任何值(包括函数、对象或任何原始值)。 Object 的键必须为 StringSymbol
键的顺序

Map 中的键以简单、直接的方式排序:Map 对象按照插入的顺序迭代条目、键和值。

尽管现在普通的 Object 的键是有序的,但情况并非总是如此,并且其排序比较复杂的。因此,最好不要依赖属性的顺序。

该顺序最初仅在 ECMAScript 2015 中为自有属性定义;ECMAScript 2020 还定义了继承属性的顺序。但请注意,没有单一机制可以迭代对象的所有属性;各种机制各自包含不同的属性子集。for-in 仅包含可枚举的字符串键属性;Object.keys 仅包含可枚举的自有字符串键属性;Object.getOwnPropertyNames 包括自有的字符串键属性,即使是不可枚举的;Object.getOwnPropertySymbols 仅对 Symbol 键属性执行相同的操作,等等。)

大小

Map 中的项目数量很容易从其 Map.prototype.size,size 属性中获得。 确定 Object 中的项目数量通常更麻烦,效率也较低。一种常见的方法是通过获取 Object.keys() 返回的数组的"长度"。
迭代 Map可迭代对象,所以它可以直接迭代。

Object 没有实现迭代协议,因此对象默认情况下不能直接通过 JavaScript 的 for...of 语句进行迭代。

备注:

  • 一个对象可以实现迭代协议,或者你可以使用 Object.keysObject.entries 来获取一个对象的可迭代对象。
  • for...in 语句允许你迭代对象的可枚举属性。
性能

在涉及频繁添加和删除键值对的场景中表现更好。

未针对频繁添加和删除键值对进行优化。

序列化和解析

没有对序列化或解析的原生支持。

(但你可以通过使用 JSON.stringify()及其 replacer 参数和 "JSON.parse()" 及其 reviver 参数来为 Map 构建自己的序列化和解析支持。参见 Stack Overflow 问题 How do you JSON.stringify an ES6 Map?)。

原生支持使用 JSON.stringify() 序列化 Object 到 JSON。

原生支持使用 JSON.parse() 解析 JSON 为 Object

知识点12

接下来我们要处理Set对象的深拷贝。

Set 对象允许你存储任何类型(无论是原始值还是对象引用)的唯一值。——MDN

我们可以使用add方法往一个Set对象中增添元素,使用delete方法从一个Set对象中删除指定的元素,还可以使用clear方法清空一个Set对象中的全部元素。

让我们看一个简单的例子:

// 传入可迭代对象即可,通过构造函数初始化
const setDemo = new Set([1, 2, 3, 4]);

console.log(setDemo);
// 添加元素
setDemo.add("a");
setDemo.add("b");
console.log(setDemo);
// 删除指定元素
setDemo.delete(1);
console.log(setDemo);
// 清空
setDemo.clear();

console.log(setDemo);

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

同样的,和Map的思路是一致的,当我们尝试去深拷贝一个Set对象时,我们通过new Set()创建一个空的Set实例,然后遍历传入的目标对象的键(使用forEach),一个一个给空的Set实例去add完成元素添加即可。

(同样,我们也要注意循环引用,因此这里的代码就一气呵成了)

  // 判断是否是 Set
  if (obj instanceof Set) {
    if (hash.has(obj)) {
      return hash.get(obj);
    }
    const cloneObj = new Set();
    hash.set(obj, cloneObj);

    obj.forEach((value) => {
      cloneObj.add(myDeepClone(value, hash));
    });

    return cloneObj;
  }

同样的,我们用测试用例试试:

const setDemo = new Set([1, 2, 3, 4]);

console.log(setDemo);
setDemo.add("a");
setDemo.add("b");
setDemo.delete(1);
setDemo.add(setDemo);
const setDemo_copy = myDeepClone(setDemo);
console.log("setDemo: ", setDemo);
console.log("setDemo_copy: ", setDemo_copy);
setDemo_copy.add("hello");
setDemo_copy.delete("a");
console.log("setDemo: ", setDemo);
console.log("setDemo_copy: ", setDemo_copy);

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

知识点13

最后,我们处理Array的深拷贝。

如果大家对Array比较感兴趣,可以阅读我之前写的这篇文章,相信会对你有所帮助:

我们直接看代码怎么写~

  // 判断是否是 Array
  if (obj instanceof Array) {
    if (hash.has(obj)) {
      return hash.get(obj);
    }
    const cloneObj = [];
    hash.set(obj, cloneObj);
    obj.forEach((value) => {
      cloneObj.push(myDeepClone(value, hash));
    });

    return cloneObj;
  }

然后我们试下产生循环引用的测试用例:

const nums = [1, 2, { a: 123 }, 5, 6, 7, 8, 9, 10];
nums.push(nums);
const nums_copy = myDeepClone(nums);

console.log("nums: ", nums);
console.log("nums_copy: ", nums_copy);
nums_copy.push(123123);

nums_copy.shift();

console.log("nums: ", nums);
console.log("nums_copy: ", nums_copy);

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

OK,没问题~

至此,我们终于写完了这个深拷贝函数!!!!!!

告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP02】(下篇)当我们浏览其他同学在网上分享的面经时,我们会发现,

答案

function myDeepClone(obj, hash = new WeakMap()) {
  // 判断是否是函数
  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) {
    if (hash.has(obj)) {
      return hash.get(obj);
    }
    const cloneObj = new Map();
    hash.set(obj, cloneObj);

    obj.forEach((value, key) => {
      cloneObj.set(key, myDeepClone(value, hash));
    });

    return cloneObj;
  }
  // 判断是否是 Set
  if (obj instanceof Set) {
    if (hash.has(obj)) {
      return hash.get(obj);
    }
    const cloneObj = new Set();
    hash.set(obj, cloneObj);

    obj.forEach((value) => {
      cloneObj.add(myDeepClone(value, hash));
    });

    return cloneObj;
  }
  // 判断是否是 Array
  if (obj instanceof Array) {
    if (hash.has(obj)) {
      return hash.get(obj);
    }
    const cloneObj = [];
    hash.set(obj, cloneObj);
    obj.forEach((value) => {
      cloneObj.push(myDeepClone(value, hash));
    });

    return cloneObj;
  }
  // 判断是否是 Object
  if (obj instanceof Object) {
    if (hash.has(obj)) {
      return hash.get(obj);
    }
    const cloneObj = {};
    hash.set(obj, cloneObj);
    Reflect.ownKeys(obj).forEach((key) => {
      // 如果属性不可枚举
      if (!obj.propertyIsEnumerable(key)) {
        Object.defineProperty(cloneObj, key, {
          value: myDeepClone(obj[key], hash),
        });
      } else {
        cloneObj[key] = myDeepClone(obj[key], hash);
      }
    });

    return cloneObj;
  }
}

结语

通过上、下两篇文章,我们终于写完了这个深拷贝函数,总共涉及到了13个知识点:

  1. 知识点01:js中的数据类型
  2. 知识点02:深拷贝和浅拷贝的区别
  3. 知识点03:typeof null === 'object'的原因
  4. 知识点04:js中的内置对象都有哪些?举几个常见的
  5. 知识点05:instanceOf运算符以及判断原型的方式
  6. 知识点06:js中的遍历对象键值的方式有几种
  7. 知识点07:js中的原型和原型链
  8. 知识点08:递归调用和循环引用
  9. 知识点09:js中的内置对象:WeakMap
  10. 知识点10:js中的垃圾回收机制
  11. 知识点11:js中的内置对象: Map
  12. 知识点12:js中的内置对象:Set
  13. 知识点13:js中的内置对象: Array

期望你能从中有所收获!

不过我们的深拷贝函数还是可以优化的呢,这里放几个开放性问题:

  • 处理循环引用的逻辑在每一个case中都写了一遍,这部分的代码比较冗余,可以重构。
  • 使用instanceOf运算符判断实例的原型是可以解决问题,但又没有更好的方式?
    • 比如使用toString,通过[object Object]去判断。
  • lodash.cloneDeep中并不是传入函数就返回空对象的,而是会根据传入的函数是否是对象的键值,来决定返回原函数还是返回空对象。
      if (isFunc || !cloneableTags[tag]) {
        return object ? value : {}
      }
    

这些问题你是否能够完成优化呢?相信你一定可以的,这样你能将这些知识点掌握得更加熟练,从而在以后的工作中运用自如~

期待与你在【EP03】相遇!

转载自:https://juejin.cn/post/7405536151288053769
评论
请登录