面试题 Js 篇

面试题 Js 篇

数据类型

基本的数据类型介绍,及值类型和引用类型的理解

在 JS 中共有 8  种基础的数据类型,分别为:Undefined 、 Null 、 Boolean 、 Number 、 String 、 Object 、 Symbol 、 BigInt。

其中 Symbol  和 BigInt  是 ES6 新增的数据类型,可能会被单独问:

  • Symbol 代表独一无二的值,最大的用法是用来定义对象的唯一属性名。
  • BigInt 可以表示任意大小的整数。

值类型是直接存储在栈(stack)中的简单数据段,占据空间小、大小固定,属于被频繁使用数据,所以放入栈中存储。

值类型的赋值变动过程如下:

1
2
3
4
let a = 100
let b = a
a = 200
console.log(b) // 100

引用类型存储在堆(heap)中的对象,占据空间大、大小不固定。如果存储在栈中,将会影响程序运行的性能。

引用类型的赋值变动过程如下:

1
2
3
4
let a = { age: 20 }
let b = a
b.age = 30
console.log(a.age) // 30

数据类型的判断

  • typeof:能判断所有值类型,函数。不可对 null、对象、数组进行精确判断,因为都返回 object 。

对于 null 来说:

1
typeof null === 'object' // true

源于 JavaScript 的 bug,牵涉到太多的 Web 系统,修复它会产生更多的 bug,令许多系统无法正常工作,所以也许永远也不会修复

我们需要用复合条件来检测 null 的类型:

1
2
var a = null
!a && typeof a === 'object' // true

null 是基本类型中唯一一个假值(falsy 或者 false-like)类型

还有一种情况:

1
2
3
typeof function a() {
/* ... */
} === 'function' // true

function 也是 object 的子类型,具体来说,函数是 “可调用对象”,它有一个内部属性 [call],该属性使其可以被调用。函数不仅是对象,还可以拥有属性,例如:

1
2
3
function a(b, c) {
/* ... */
}

函数对象的 length 属性是其声明参数的个数:

1
a.length // 2

因为该函数声明了两个命名参数,b 和 c,所以其 length 值为 2。

1
typeof [1, 2, 3] === 'object' // true

数组也是对象,确切地说,它也是 object 的一个子类型,数组的元素按数字顺序来进行索引(而非像普通对象那样通过字符串键值),其 length 属性是元素的个数。

因为 JavaScript 中的变量没有类型,在对变量执行 typeof 操作时,得到的结果并不是该变量的类型,而是该变量持有的值的类型。

1
2
3
4
5
6
7
var a = 42
typeof a // "number"

a = true
typeof a // "boolean"

typeof typeof 42 // "string"

typeof 42 首先返回字符串”number”,然后 typeof “number” 返回”string”。

  • instanceof:能判断对象类型,不能判断基本数据类型,其内部运行机制是判断在其原型链中能否找到该类型的原型。比如考虑以下代码:
1
2
3
4
5
6
7
class People {}
class Student extends People {}

const vortesnail = new Student()

console.log(vortesnail instanceof People) // true
console.log(vortesnail instanceof Student) // true

其实现就是顺着原型链去找,如果能找到对应的 Xxxxx.prototype  即为 true 。比如这里的 vortesnail  作为实例,顺着原型链能找到 Student.prototype  及 People.prototype ,所以都为 true 。

  • **Object.prototype.toString.call ()**:所有原始数据类型都是能判断的,还有 Error 对象,Date 对象等。
1
2
3
4
5
6
7
8
9
Object.prototype.toString.call(2) // "[object Number]"
Object.prototype.toString.call('') // "[object String]"
Object.prototype.toString.call(true) // "[object Boolean]"
Object.prototype.toString.call(undefined) // "[object Undefined]"
Object.prototype.toString.call(null) // "[object Null]"
Object.prototype.toString.call(Math) // "[object Math]"
Object.prototype.toString.call({}) // "[object Object]"
Object.prototype.toString.call([]) // "[object Array]"
Object.prototype.toString.call(function () {}) // "[object Function]"

衍生问题:如何判断变量是否为数组?

1
2
3
4
Array.isArray(arr) // true
arr.__proto__ === Array.prototype // true
arr instanceof Array // true
Object.prototype.toString.call(arr) // "[object Array]"

手写深拷贝

如何写出一个惊艳面试官的深拷贝?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 使用递归,会出现栈溢出问题,改为循环可解决

function isObject(o) {
return (
Object.prototype.toString.call(o) === '[object Object]' ||
Object.prototype.toString.call(o) === '[object Array]'
)
}

function deepClone(o, hash = new map()) {
if (!isObject(o)) return o //检测是否为对象或者数组
if (hash.has(o)) return hash.get(o)
let obj = Array.isArray(o) ? [] : {}

hash.set(o, obj)
for (let i in o) {
if (isObject(o[i])) {
obj[i] = deepClone(o[i], hash)
} else {
obj[i] = o[i]
}
}
return obj
}

首先需要了解 JavaScript 中赋值和参数传递的工作机制

JavaScript 中的引用和其他语言中的引用 / 指针不同,它们不能指向别的变量 / 引用,只能指向值。如果一个值有 10 个引用,这些引用指向的都是同一个值,变量相互之间没有引用 / 指向关系。JavaScript 对值和引用的赋值 / 传递在语法上没有区别,完全根据值的类型来决定。

  • 对于简单值(即标量基本类型值,scalar primitive)总是通过值复制的方式来赋值 / 传递,包括 null、undefined、string、number、boolean 和 ES6 中的 symbol。

  • 对于复合值(compound value)—— 对象(包括数组和封装对象)和函数,则总是通过引用复制的方式来赋值 / 传递。

一个例子:

1
2
3
4
5
6
7
8
9
10
11
var a = 2
var b = a // b是a的值的一个复本
b++
a // 2
b // 3

var c = [1, 2, 3]
var d = c // d是[1,2,3]的一个引用
d.push(4)
c // [1,2,3,4]
d // [1,2,3,4]

上例中 2 是一个标量基本类型值,所以变量 a 持有该值的一个复本,b 持有它的另一个复本,b 更改时,a 的值保持不变。c 和 d 分别指向同一个复合值 [1,2,3] 的两个不同的引用,所以它们更改的是同一个值,更改后它们指向更新后的新值 [1,2,3,4]。

由于引用指向的是值本身而非变量,所以一个引用无法更改另一个引用的指向。

1
2
3
4
5
6
7
8
var a = [1, 2, 3]
var b = a
a // [1,2,3]
b // [1,2,3]
// 更改b指向一个新值,不影响a对原来值的引用
b = [4, 5, 6]
a // [1,2,3]
b // [4,5,6]

在函数参数中:

1
2
3
4
5
6
7
8
9
10
11
12
function foo(x) {
// x是a的引用
x.push(4)
x // [1,2,3,4]
// 更改x指向一个新值,不影响a对原来值的引用
x = [4, 5, 6]
x.push(7)
x // [4,5,6,7]
}
var a = [1, 2, 3]
foo(a)
a // 是[1,2,3,4],不是[4,5,6,7]

我们向函数传递 a 的时候,实际是将引用 a 的一个复本赋值给 x,而 a 仍然指向 [1,2,3]。在函数中我们可以通过引用 x 来更改数组的值(push (4) 之后变为 [1,2,3,4])。但 x = [4,5,6] 并不影响 a 的指向,所以 a 仍然指向 [1,2,3,4]。我们不能通过引用 x 来更改引用 a 的指向,只能更改 a 和 x 共同指向的值。

如果要将 a 的值变为 [4,5,6,7],必须更改 x 指向的数组,而不是为 x 赋值一个新的数组:

1
2
3
4
5
6
7
8
9
10
11
function foo(x) {
x.push(4)
x // [1,2,3,4]
// 然后
x.length = 0 // 清空数组
x.push(4, 5, 6, 7)
x // [4,5,6,7]
}
var a = [1, 2, 3]
foo(a)
a // 是[4,5,6,7],不是[1,2,3,4]

通过值复制的方式来传递复合值(如数组):

1
foo(a.slice())

slice (..) 不带参数会返回当前数组的一个浅复本(shallow copy)。由于传递给函数的是指
向该复本的引用,所以 foo (..) 中的操作不会影响 a 指向的数组。

如果要将标量基本类型值传递到函数内并进行更改,就需要将该值封装到一个复合值(对象、数组等)中,然后通过引用复制的方式传递:

1
2
3
4
5
6
7
8
9
function foo(wrapper) {
// wrapper是obj的引用
wrapper.a = 42
}
var obj = {
a: 2,
}
foo(obj)
obj.a // 42

这里 obj 是一个封装了标量基本类型值 a 的封装对象。obj 引用的一个复本作为参数 wrapper 被传递到 foo (..) 中。这样我们就可以通过 wrapper 来访问该对象并更改它的属性。函数执行结束后 obj.a 将变成 42。

与预期不同的是,虽然传递的是指向数字对象的引用复本,但我们并不能通过它来更改其
中的基本类型值:

1
2
3
4
5
6
7
8
function foo(x) {
x = x + 1
x // 3
}
var a = 2
var b = new Number(a) // Object(a)也一样
foo(b)
console.log(b) // 是2,不是3

原因是标量基本类型值是不可更改的(字符串和布尔也是如此)。如果一个数字对象的标
量基本类型值是 2,那么该值就不能更改,除非创建一个包含新值的数字对象

(以上摘录自《你不知道的 JavaScript(中卷)》第二章 - 数字)

浅拷贝:创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的值,如果属性是引用类型,拷贝的就是内存地址 ,所以如果其中一个对象改变了这个地址,就会影响到另一个对象。

浅拷贝
浅拷贝

深拷贝:将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,且修改新对象不会影响原对象。

深拷贝
深拷贝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/**
* 深拷贝
* @param {Object} obj 要拷贝的对象
* @param {Map} map 用于存储循环引用对象的地址
*/

function deepClone(obj = {}, map = new Map()) {
if (typeof obj !== 'object') {
return obj
}
// 校验null类型
if (!obj && typeof obj === 'object') {
return obj
}

// 解决循环引用问题,我们可以额外开辟一个存储空间,
// 来存储当前对象和拷贝对象的对应关系,当需要拷贝当前对象时,
// 先去存储空间中找,有没有拷贝过这个对象,
// 如果有的话直接返回,如果没有的话继续拷贝。
if (map.get(obj)) {
return map.get(obj)
}

// 考虑数组
let result = {}
// 初始化返回结果
if (
obj instanceof Array ||
// 加 || 的原因是为了防止 Array 的 prototype 被重写,Array.isArray 也是如此
Object.prototype.toString(obj) === '[object Array]'
) {
result = []
}
// 防止循环引用
map.set(obj, result)
for (const key in obj) {
// 保证 key 不是原型属性
if (obj.hasOwnProperty(key)) {
// 递归调用
result[key] = deepClone(obj[key], map)
}
}

// 返回结果
return result
}

根据 0.1+0.2 ! == 0.3,讲讲 IEEE 754 ,如何让其相等?

了解 IEEE 754:0.1 + 0.2 不等于 0.3?为什么 JavaScript 有这种 “骚” 操作?
运算机制:硬核基础二进制篇(一)0.1 + 0.2 != 0.3 和 IEEE-754 标准

原因总结:

  • 进制转换 :js 在做数字计算的时候,0.1 和 0.2 都会被转成二进制后无限循环 ,但是 js 采用的 IEEE 754 二进制浮点运算,最大可以存储 53 位有效数字,于是大于 53 位后面的会全部截掉,将导致精度丢失。
  • 对阶运算 :由于指数位数不相同,运算时需要对阶运算,阶小的尾数要根据阶差来右移(0 舍 1 入),尾数位移时可能会发生数丢失的情况,影响精度。

解决办法:

1. 转为整数(大数)运算。

1
2
3
4
5
6
7
8
9
10
11
function add(a, b) {
const maxLen = Math.max(
a.toString().split('.')[1].length,
b.toString().split('.')[1].length
)
const base = 10 ** maxLen
const bigA = BigInt(base * a)
const bigB = BigInt(base * b)
const bigRes = (bigA + bigB) / BigInt(base) // 如果是 (1n + 2n) / 10n 是等于 0n的。。。
return Number(bigRes)
}

2. 使用 Number.EPSILON 设置误差范围,通常称为机器精度(machine epsilon),对 JavaScript 的数字来说,这个值通常是 2^-52 (2.220446049250313e-16)。

从 ES6 开始,该值被定义在 Number.ESPILON 中,我们可以直接使用:

1
2
3
4
5
function isEqual(a, b) {
return Math.abs(a - b) < Number.EPSILON
}

console.log(isEqual(0.1 + 0.2, 0.3)) // true

也可以为 ES6 之前的版本写 polyfill:

1
2
3
if (!Number.EPSILON) {
Number.EPSILON = Math.pow(2, -52)
}

能够呈现的最大浮点数大约是 1.798e+308(这是一个相当大的数字),它定义在 Number.MAX_VALUE 中。最小浮点数定义在 Number.MIN_VALUE 中,大约是 5e-324,它不是负数,但无限接近于 0!可以说,Number.EPSILON 的实质是一个可以接受的最小误差范围,一般来说为 Math.pow (2, -52) 。

3. 转成字符串,对字符串做加法运算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 字符串数字相加
var addStrings = function (num1, num2) {
let i = num1.length - 1
let j = num2.length - 1
const res = []
let carry = 0
while (i >= 0 || j >= 0) {
const n1 = i >= 0 ? Number(num1[i]) : 0
const n2 = j >= 0 ? Number(num2[j]) : 0
const sum = n1 + n2 + carry
res.unshift(sum % 10)
carry = Math.floor(sum / 10)
i--
j--
}
if (carry) {
res.unshift(carry)
}
return res.join('')
}

function isEqual(a, b, sum) {
const [intStr1, deciStr1] = a.toString().split('.')
const [intStr2, deciStr2] = b.toString().split('.')
const inteSum = addStrings(intStr1, intStr2) // 获取整数相加部分
const deciSum = addStrings(deciStr1, deciStr2) // 获取小数相加部分
return inteSum + '.' + deciSum === String(sum)
}

console.log(isEqual(0.1, 0.2, 0.3)) // true

原型与原型链

之前写的原型和原型链总结的文章:原型和原型链

总结:

  • 原型:
    • 所有引用类型都有一个__proto__(隐式原型) 属性,属性值是一个普通的对象
    • 所有函数都有一个 prototype(原型) 属性,属性值是一个普通的对象
    • 所有引用类型的__proto__属性指向它构造函数的 prototype
  • 原型链:当访问一个对象的某个属性时,会先在这个对象本身属性上查找,如果没有找到,则会去它的__proto__隐式原型上查找,即它的构造函数的 prototype,如果还没有找到就会再在构造函数的 prototype 的__proto__中查找,这样一层一层向上查找就会形成一个链式结构,我们称为原型链。

作用域与作用域链

JavaScript 深入之词法作用域和动态作用域
深入理解 JavaScript 作用域和作用域链

  • 作用域:规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。换句话说,作用域决定了代码区块中变量和其他资源的可见性。(全局作用域、函数作用域、块级作用域)
  • 作用域链:从当前作用域开始一层层往上找某个变量,如果找到全局作用域还没找到,就放弃寻找 。这种层级关系就是作用域链。(由多个执行上下文的变量对象构成的链表就叫做作用域链,学习下面的内容之后再考虑这句话)

需要注意的是,js 采用的是静态作用域,所以函数的作用域在函数定义时就确定了。

执行上下文

JavaScript 深入之执行上下文栈
JavaScript 深入之变量对象
JavaScript 深入之作用域链
JavaScript 深入之执行上下文

总结:当 JavaScript 代码执行一段可执行代码时,会创建对应的执行上下文。对于每个执行上下文,都有三个重要属性:

  • 变量对象(Variable object,VO);
  • 作用域链(Scope chain);
  • this

JavaScript 的 this 原理
彻底理解 js 中 this 的指向

闭包

JavaScript 深入之闭包

根据 MDN 中文的定义,闭包的定义如下:

在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。可以在一个内层函数中访问到其外层函数的作用域。

也可以这样说:

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

下面我们来看一段代码,清晰地展示了闭包:

1
2
3
4
5
6
7
8
9
function foo() {
var a = 2
function bar() {
console.log(a)
}
return bar
}
var baz = foo()
baz() // 2 —— 朋友,这就是闭包的效果。

函数 bar () 的词法作用域能够访问 foo () 的内部作用域。然后我们将 bar () 函数本身当作一个值类型进行传递。在这个例子中,我们将 bar 所引用的函数对象本身当作返回值。

在 foo () 执行后,其返回值(也就是内部的 bar () 函数)赋值给变量 baz 并调用 baz (),实际上只是通过不同的标识符引用调用了内部的函数 bar ()。

bar () 显然可以被正常执行。但是在这个例子中,它在自己定义的词法作用域以外的地方执行。

在 foo () 执行后,通常会期待 foo () 的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收器用来释放不再使用的内存空间。由于看上去 foo () 的内容不会再被使用,所以很自然地会考虑对其进行回收。

而闭包的 “神奇” 之处正是可以阻止这件事情的发生。事实上内部作用域依然存在,因此没有被回收。谁在使用这个内部作用域?原来是 bar () 本身在使用。

拜 bar () 所声明的位置所赐,它拥有涵盖 foo () 内部作用域的闭包,使得该作用域能够一直存活,以供 bar () 在之后任何时间进行引用。

bar () 依然持有对该作用域的引用,而这个引用就叫作闭包。

循环中的闭包

看下面的代码:

1
2
3
4
5
for (var i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i)
}, i * 1000)
}

实际上,它并不像我们期望的那样每秒一次分别输出数字 1-5,它会以每秒一次的频率输出 5 次 6。为什么?

输出显示的是循环结束时 i 的最终值,因为当定时器运行时,即使每个迭代中执行的是 setTimeout (.., 0),所有的回调函数依然是在循环结束后才会被执行,因此会每次输出一个 6 出来。

缺陷是我们试图假设循环中的每个迭代在运行时都会给自己 “捕获” 一个 i 的副本。但是根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i。

这样说的话,当然所有函数共享一个 i 的引用。循环结构让我们误以为背后还有更复杂的机制在起作用,但实际上没有。如果将延迟函数的回调重复定义五次,完全不使用循环,那它同这段代码是完全等价的。

下面回到正题。缺陷是什么?我们需要更多的闭包作用域,特别是在循环的过程中每个迭代都需要一个闭包作用域。

IIFE (立即执行函数表达式,Immediately Invoked Function Expression)会通过声明并立即执行一个函数来创建作用域。

它需要有自己的变量,用来在每个迭代中储存 i 的值:

1
2
3
4
5
6
7
8
for (var i = 1; i <= 5; i++) {
;(function () {
var j = i
setTimeout(function timer() {
console.log(j)
}, j * 1000)
})()
}

可以对这段代码进行一些改进:

1
2
3
4
5
6
7
for (var i = 1; i <= 5; i++) {
;(function (j) {
setTimeout(function timer() {
console.log(j)
}, j * 1000)
})(i)
}

在迭代内使用 IIFE 会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。

let 声明,可以用来劫持块作用域,并且在这个块作用域中声明一个变量,本质上这是将一个块转换成一个可以被关闭的作用域:

1
2
3
4
5
6
for (var i = 1; i <= 5; i++) {
let j = i // 是的,闭包的块作用域!
setTimeout(function timer() {
console.log(j)
}, j * 1000)
}

变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量:

1
2
3
4
5
for (let i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i)
}, i * 1000)
}

(以上摘录自《你不知道的 JavaScript(上卷)》第一部分第五章 - 作用域闭包)

call、apply、bind 实现

JavaScript 中 call,apply,bind 方法的总结

call

call () 方法在使用一个指定的 this 值和若干个指定的参数值的前提下调用某个函数或方法。

举个例子:

1
2
3
4
5
6
7
var obj = {
value: 'vortesnail',
}
function fn() {
console.log(this.value)
}
fn.call(obj) // vortesnail

通过 call 方法我们做到了以下两点:

  • call 改变了 this 的指向,指向到 obj 。
  • fn 函数执行了。

那么如果我们自己写 call 方法的话,可以怎么做呢?我们先考虑改造 obj。

1
2
3
4
5
6
7
var obj = {
value: 'vortesnail',
fn: function () {
console.log(this.value)
},
}
obj.fn() // vortesnail

这时候 this 就指向了 obj ,但是这样做我们手动给 obj 增加了一个 fn 属性,这显然是不行的,不用担心,我们执行完再使用对象属性的删除方法(delete)不就行了?

1
2
3
obj.fn = fn
obj.fn()
delete obj.fn

根据这个思路,我们就可以写出来了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Function.prototype.myCall = function (context) {
// 判断调用对象
if (typeof this !== 'function') {
throw new Error('Type error')
}
// 首先获取参数
let args = [...arguments].slice(1)
let result = null
// 判断 context 是否传入,如果没有传就设置为 window
context = context || window
// 将被调用的方法设置为 context 的属性
// this 即为我们要调用的方法
context.fn = this
// 执行要被调用的方法
result = context.fn(...args)
// 删除手动增加的属性方法
delete context.fn
// 将执行结果返回
return result
}

apply

我们会了 call 的实现之后,apply 就变得很简单了,他们没有任何区别,除了传参方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Function.prototype.myApply = function (context) {
if (typeof this !== 'function') {
throw new Error('Type error')
}
let result = null
context = context || window
// 与上面代码相比,我们使用 Symbol 来保证属性唯一
// 也就是保证不会重写用户自己原来定义在 context 中的同名属性
const fnSymbol = Symbol()
context[fnSymbol] = this
// 执行要被调用的方法
if (arguments[1]) {
result = context[fnSymbol](...arguments[1])
} else {
result = context[fnSymbol]()
}
delete context[fnSymbol]
return result
}

bind

bind 返回的是一个函数,这个地方可以详细阅读这篇文章,讲的非常清楚:解析 bind 原理,并手写 bind 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Function.prototype.myBind = function (context) {
// 判断调用对象是否为函数
if (typeof this !== "function") {
throw new Error("Type error");
}
// 获取参数
const args = [...arguments].slice(1),
const fn = this;
return function Fn() {
return fn.apply(
this instanceof Fn ? this : context,
// 当前的这个 arguments 是指 Fn 的参数
args.concat(...arguments)
);
};
};

new 实现

  1. 首先创一个新的空对象。
  2. 根据原型链,设置空对象的 proto 为构造函数的 prototype 。
  3. 构造函数的 this 指向这个对象,执行构造函数的代码(为这个新对象添加属性)。
  4. 判断函数的返回值类型,如果是引用类型,就返回这个引用类型的对象。
1
2
3
4
5
6
function myNew(context) {
const obj = new Object()
obj.__proto__ = context.prototype
const res = context.apply(obj, [...arguments].slice(1))
return typeof res === 'object' ? res : obj
}

异步

event loop、宏任务和微任务

Promise

async/await 和 Promise 的关系

  • async/await 是消灭异步回调的终极武器。
  • 但和 Promise 并不互斥,反而,两者相辅相成。
  • 执行 async 函数,返回的一定是 Promise 对象。
  • await 相当于 Promise 的 then。
  • try…catch 可捕获异常,代替了 Promise 的 catch。

浏览器的垃圾回收机制

「硬核 JS」你真的了解垃圾回收机制吗

实现一个 EventMitter 类