2024前端年末备战面试题——JavaScript篇

5,848 阅读1小时+

JavaScript篇

本文是本人根据MDN上的解答或者网上一些其他朋友的文章以及自己的理解,整理归纳出来的一篇JavaScript方面的面试题,其中不免有许多漏掉的问题或是答案,发现错误的或者是有什么问题需要补充的朋友们,可以在评论区留言,大家虚心交流,一起进步。

1. js中的数据类型和数据结构

JavaScript中,可分为基本数据类型引用数据类型两大类。

(1)基本数据类型

基本数据类型包括number(数字类型)string(字符串类型)boolean(布尔型)undefined(未定义)null(空)bigint(大数)Symbol(独一无二的值)

(2)引用数据类型

引用数据类型只有object(对象)

(3)数据结构

很多人觉得,js中也有数组啊,还有函数,这些也应该属于数据类型

其实并非这样。在js中array(数组)function(函数)Map/WeakMap(字典)Set/WeakSet(集合)这种,它们都是在object类型之上进行拓展,所衍生出来的一些数据结构

2. Map和Set有何不同,WeakMap和WeakSet又是什么?

(1)相同点

  • MapSet都是一种数据结构,可以作为存放内容的容器
  • MapSet相较于普通的数组array来说,查找效率更快;
  • MapSet都通过delete方法来删除数据;
  • MapSet都通过has方法来获取元素是否在集合中;

(2)不同点

  • Map的值类似于一个二维数组,而Set的值是一个伪数组(其实是一个对象,但是可以通过索引去访问)
  • Map更像是一个字典,存储的数据由key: value的格式组成,而Set更像是一个集合,里面存储了一个个的
  • Map键名不会重复,Set则是不会重复;
  • Map通过get方法访问数据,Set则只能通过遍历或者转成数组,再去访问;
  • Map通过set方法去添加数据,Set则是通过add方法去添加数据;

(3)WeakMap和WeakSet

WeakMapWeakSet它们的作用其实和Map还有Set大概相同,它们的出现是为了处理使用Map和Set时出现的内存泄漏问题,它们的区别有以下几点:

  • WeakSetWeakMap只能存放对象,而不像Map和Set一样可以存放任意类型
  • WeakSet中存放的对象,以及WeakMap中作为键名的对象,都是弱引用,当这个对象只作为WeakSet的值,或者只作为WeakMap的键名时,会被垃圾回收器回收

3. BigInt和Number的区别

在js中number类型只能表示-2^53 + 1 到 2^53 - 1 范围内的整数,超过这个返回就会精度丢失,而bigint的出现,就是为了能够让js正确的表示不在这个区间范围内的整数;

可以在一个整数字面量后面加n的方式定义一个BigInt,也可以调用BigInt()函数,并且传递一个整数值或字符串。但是不能使用new运算符

(1)和Number的相同点

  • if条件语句中,BigInt和Number类似
  • Boolean转换时,BigInt和Number类似
  • BigInt类型的数据可以和Number类型的数据进行比较,但是结果是宽松相等(== 才成立, === 不行)的;
  • BigIntNumber类似,可以使用+-*/%等运算;
  • BigInt可以和Number类型的数据放在同一数组进行排序;

(2)和Number的不同点

  • 类型不同:进行类型判断时,二者类型不同;
  • 不能使用Math对象:不能使用Math对象的一些方法,比如Math.floorMath.ceil等;
  • 不能和Number运算:不能和Number类型的数据进行运算,必须要转换成同一类型才可以,但是转换的过程可能会精度丢失
  • BigInt的数据不能使用JSON.stringify()

(3)什么时候使用BigInt

当我们要表示一个大数时,这个大数的值已经不能用Number类型来表示了,并且我们不打算将它转换成Number类型的情况下,可以考虑使用BigInt

(4)BigInt会造成的问题

  • BigInt也会造成精度丢失的问题,如果你使用BigInt函数创建一个BigInt,并且你的传参是整数,在传参的过程中,会造成精度丢失的问题;
const myBig = BigInt(99999999999999999)
const yourBig = BigInt('99999999999999999')
console.log(myBig) // 100000000000000000n
console.log(yourBig) // 99999999999999999n
  • BigInt在进行/运算时,结果中会向下取整,因为BigInt只能表示整数
const big = 5n

console.log(big / 2n) // 2n,而不是2.5n

4. 如何判断数据的类型

(1)基本数据类型的判断

基本数据类型的判断,我们通常可以使用typeof方法进行判断。

 const n = 1
 const s = '1'
 const b = true
 const u = undefined
 const no = null
 const big = 1n
 const sym = Symbol(1)

 console.log(typeof n) //number
 console.log(typeof s) // string
 console.log(typeof b) // boolean
 console.log(typeof u) // undefined
 console.log(typeof no) // object
 console.log(typeof big) // bigint
 console.log(typeof sym) // symbol

我们发现,基本数据类型,用typeof都...不对劲啊,怎么混进去了奇怪的东西,typeof null的值竟然是object

(2)为什么typeof null是object

这是因为JavaScript历史原因,在javaScript最初的实现中,js的是由表示类型的标签实际数值表示的,而恰好object的类型标签是0null又代表空指针,在大多数平台下解析到的类型标签也是0,所以typeof的值也就变成了object;

这也是为什么我们在判断引用数据类型的时候不能全靠typeof的一个原因,因为在某些特殊场景下,它会容易让我们的结果产生混淆

(3)判断引用数据类型

使用typeof来判断几种常见的引用数据类型

const f = function () {}
const o = {}
const a = []
const m = new Map()
const s = new Set()
const d = new Date()

console.log(typeof f) // function
console.log(typeof o) // object
console.log(typeof a) // object
console.log(typeof m) // object
console.log(typeof s) // object
console.log(typeof d) // object

发现除了function可以正常识别之外,其他全都是object,所以说面对引用数据类型,我们已经不能用typeof来进行判断了。

使用instanceof来判断

instanceof运算符一般用于检测构造函数的prototype属性是否在对象的原型链上出现

const f = function () {}
const o = {}
const a = []
const m = new Map()
const s = new Set()
const d = new Date()

console.log(f instanceof Function) // true
console.log(o instanceof Object) // true
console.log(a instanceof Array) // true
console.log(m instanceof Map) // true
console.log(s instanceof Set) // true
console.log(d instanceof Date) // true
console.log(f instanceof Array) // false
console.log(a instanceof Map) // false
console.log(f instanceof Object) // true

由上面代码可以看出,比如我们函数的构造函数的prototype属性是Function,那它的原型链上就会有Function,我们就可以通过instanceof Function来判断该属性是否为函数,它原型链上没有Array,就会返回false,这个方法唯一的缺点就是所有的引用类型instanceof Object判断时,结果都为true。

使用toString方法判断

js中所有的类型都源于对象,它们在原型链的顶端都是Object,而Object的原型上有toString方法,我们可以通过调用Object.prototype.toString来判断它们的类型。

const n = 1
const s = '1'
const big = 1n

const f = function () {}
const m = new Map()
const o = {}

console.log(Object.prototype.toString.call(n)) // [object Number]
console.log(Object.prototype.toString.call(s)) // [object String]
console.log(Object.prototype.toString.call(big)) // [object BigInt]
console.log(Object.prototype.toString.call(f)) // [object Function]
console.log(Object.prototype.toString.call(m)) // [object Map]
console.log(Object.prototype.toString.call(o)) // [object Object]

这里需要注意几个点:

  • 得到的结果是[object xxxxxx]的格式,需要我们对结果进行匹配才能得知是什么类型;
  • 之所以调用Object.prototype.toString而不是变量自身的toString方法,是因为不同类型变量构造函数中重写了toString方法,访问toString方法时,会优先使用自身的;

5. 如何判断两个对象相等

(1)使用“==”、“===”进行判断

  • ==不能判断对象是否相等
  • ===判断两个对象是否相等,判断的是它们的引用地址和值是否都相等;
const obj1 = {
  key: 1
}

const obj2 = {
  key: 1
}

const obj3 = obj1

console.log(obj1 === obj2) // false, 因为obj2是重新创建的一个对象,他们两个在内存中存储的地址不同
console.log(obj1 === obj3) // true

(2)遍历

通过遍历整个对象,拿到每一个key和与其对应的value,去和另一个对象中的key: value进行对比,如果都相同,就返回true,否则返回false。

  • 通过这种方式对比,要考虑对象内部是否有其它对象,如果有的话则需要递归遍历判断
  • 通过这种方式对比,得到的只是是否相等,而不是地址是否相等;

6. 如何判断对象是否为空

  • JSON.stringfy:通过使用JSON.stringify(obj) === '{}'来判断是不是一个空对象;
  • Object.keys(obj):该方法会返回对象可枚举的字符串键属性名组成的数组,如果最终结果的length属性为0,说明是空对象,该方法只能返回类型为字符串的键名,如果对象中有Symbol的键名,返回的数组也为空
  • Reflect.ownKeys(obj):返回一个由目标对象自身的属性键组成的数组,如果数组长度为0,说明是空对象,该方法不止会返回对象中类型为字符串的键名,还会返回类型为Symbol的键名

7. ===和==的区别

  • ===严格相等运算符,它会检查运算符两边的两个操作数值是否相等,并且类型是否相等,不同类型的两个操作数,即使值一样,也会被认为是不相等的;
  • ==非严格相等(宽松相等)运算符,它只会检查运算符两边的两个操作数值是否相等,如果类型不同的话,在JavaScript中,会根据规则对两边的操作数进行类型转换

8. 类型之间的转换

(1)js中所有的转换情况

在js中,一般只会发生以下几种转换:转为boolean(布尔值)转为string(字符串)转为number(数字类型)

类型目标类型转换结果
numberboolean除了+0-0NaN都为true
stringboolean除了''(空字符串)都为true
引用类型boolean都为true
undefined、nullboolean都为false
Symbolboolean都为true
BigIntbooleannumber一样
numberstring只是类型改变,具体内容不变
booleanstringtrue = 'true'false = 'false'
Symbolstring只是类型改变,具体内容不变
undefined、nullstring只是类型改变,具体内容不变
引用类型string视情况而定
BigIntstringnumber一样,但是会把后面的n去掉
stringnumber如果字符串内容为数字,就会转换成数字,否则转换成NaN
booleannumbertrue = 1false = 0
Symbolnumber报错
undefined、nullnumbernull = 0undefined = NaN
引用类型number空数组为0,数组只有一项,且该项的值为number或者字符串类型的数字时,为该数字,其余情况为NaN
BigIntnumber会把后面的n去掉,如果是大数,会造成精度丢失

(2)显式类型转换

显式类型转换指的就是我们通过代码将数据转换成我们预想之中的类型,也就是说,比如我们想把数字转换成字符串,我们通过toString方法去实现我们的目的,这种方式称为显式

let a = 1
a = a.toString()

console.log(typeof a) // string

(3)隐式类型转换

隐式类型转换指的是我们在进行某种操作的过程中,代码内部编译时发生了类型转换,可能会发生我们预期之外的结果。

  • 数字类型和字符串类型进行相加时,会先将数字转化为字符串然后相加;
  • 数字类型和字符串类型进行-、*、/运算时,会先将字符串转化为数字然后进行运算;
  • 条件判断语句中使用非boolean类型时,会先转化为boolean类型然后判断;
  • 在使用==进行比较时,如果两边是string和boolean会转换为数字类型再进行对比,但是undefinednull不会进行转换;
  • 在使用==进行比较时,如果一边是object,一边是string、boolean、symbol,那么就会将object转换成原始类型再进行比较。
const a = 1
const b = '2'
if (a) {
// if(1) -> 1会隐式转换为boolean类型的true
console.log('true')
} else {
console.log('false')
}
console.log(a + b) // '12'
console.log(a - b) // -1

(3)对象转换成字符串

js中,引用数据类型在转换成字符串时,会比较特殊,一共有下面四个步骤:

  • 如果对象有Symbol.toPrimitive()方法,优先调用该方法;
  • 如果没有就会看是否有valueOf()方法,然后次调用该方法;
  • 如果还没有,就会调用toString()方法;
  • 如果还没有,就会报错;

我们平时如果想要将引用类型转变为字符串,通常会调用它的toString方法,为什么不同的引用类型的toString方法表现形式不一样呢

这是因为某些类型在它们的原型链中重写了toSting方法

const obj1 = {}

const obj2 = {
    [Symbol.toPrimitive]: () => {
      return '123'
    },
}

const obj3 = {
    [Symbol.toPrimitive]: null,
    valueOf: () => {
      return '123'
    },
}

const obj4 = {
    [Symbol.toPrimitive]: null,
    valueOf: null,
    toString: () => {
      return '123'
    },
}

const obj5 = {
    [Symbol.toPrimitive]: null,
    valueOf: null,
    toString: null,
}

console.log(obj1 == '123') // false
console.log(obj2 == '123') // true
console.log(obj3 == '123') // true
console.log(obj4 == '123') // true
console.log(obj5 == '123') // Uncaught TypeError: Cannot convert object to primitive value

从上面代码我们发现,默认情况下,对象是不可能=='123'的,但是不管我们给对象设置了Symbol.toPrimitivevalueOftoString,它和123进行非严格对比时,都返回了true,而当我们把这三个属性都设为null时,浏览器进行了报错。

(4)一些特殊的类型转换

console.log([] == ![]) // true
  • 式子右侧![]先转换成boolean,变成了!true也就是false
  • 式子变成了[] == false
  • 然后进行等式两边转换为number类型进行比较;
  • 左侧[]变成了0,右侧false变成了0,于是最终比对的就是0 == 0
console.log(Number(null)) // 0
console.log(Number(undefined)) // NaN

console.log(null == undefined) // true
  • null的含义为空指针,它代表一个空值,因此转换成number的时候,会变成0
  • undefined的含义为未定义的,它代表一个不存在的值,在转换成number的时候相当于对一个不存在的值进行转换,因此会返回NaN
  • 但是对于null == undefined来说,它们在进行非严格对比时不会进行类型转换,也就是说它们对比的结果并不是转换成number再去对比的,而它们在代码中的语义都代表了没有值,所以在非严格对比的情况下,返回的是true

9. NaN是什么

NaN 是一个表示非数字的值,它及其行为不是JavaScript发明的。它在浮点运算中的语义(包括 NaN !== NaN)是由 IEEE 754 指定的。

  • 可以通过isNaN()方法,去判断一个值是否为NaN
  • NaN通过(=====)与其他任何值(包括NaN)对比,结果都为false
  • NaN是任何关系比较(><>=<=)的操作数之一时,结果总是 false
  • 如果NaN涉及数学运算(但不涉及位运算),结果通常也是NaN(除了NaN ** 0 = 1除外);

10. undefined和null的区别

(1)undefined

undefined通常表示未定义的值,它在js中表示一个变量最原始的状态,当一个变量没有被赋值时,它的值就是undefined

  • typeof undefined的值是undefined
  • Number(undefined)的值为NaN;
  • 创建新变量,并且未对变量赋值时,值默认为undefined
  • 调用函数时,未给形参传值,函数内部接收到的参数的值为undefined
  • 函数没有return,默认会return undefined,所以函数的默认返回值为undefined
  • 访问对象中没有的属性,会返回undefined
  • 使用void运算符,返回值为undefined

(2)null

null通常表示空指针,它意为没有指向任何对象,在预期中,它的值应该是一个对象,但是又没有关联的对象

  • typeof null的值为object
  • Number(null)的值为0;
  • null通常由开发者手动赋值,而不会作为默认值存在

(3)总结

  • undefined在许多场景下被作为默认值,而null通常是由开发者手动赋值
  • 通过运算符对undefinednull进行操作的结果不同;
  • 二者类型不同,含义不同;

11. 原型和原型链

JavaScript中所有对象都有一个内置属性([[Prototype]]),而这个内置属性指向其构造函数的原型(prototype),我们把这个内置属性称为对象的原型,对象的原型的本质也是一个对象,因此原型对象也有自己的原型,直至nullnull没有原型,没有这种形成类似链条关系的结构,我们称为原型链

对象原型

  • 访问对象原型的标准方法为Object.getPrototypeOf()
  • 可以通过Object.setPrototypeOf()去设置对象的原型;
  • 可以通过Object.prototype.isPrototypeOf()去检查一个对象是否存在于另一个对象的原型链中
  • 可以通过someObj.hasOwnProperty(key)去判断一个对象的key是存在于该对象本身还是存在于该对象的原型链上;
  • 我们也可以通过obj.[[Prototype]](在大多数浏览器中实现为obj.__proto__,并不允许通过[[Prototype]]去访问)去访问对象的原型;
  • 对象的原型([[Prototype]])指向它的构造函数的prototype属性
 const obj = {
    test: 1
  }

 const prototype = Object.getPrototypeOf(obj)

 console.log(prototype === obj.__proto__) // true
 console.log(Object.prototype === obj.__proto__) // true
 console.log(Object.prototype === prototype) // true

原型链

  • 原型链顶端null
  • 查找对象中属性时,会先去对象中查找,如果没有就会顺着去原型链上查找,如果还未找到,就会报错

12. 变量的声明方式

在js中,变量的声明方式在ES6之前只有var一种,而在ES6之后varletconst三种方式。

  • var:声明一个变量,在该变量被声明之后,可以在下文代码中对该变量进行重新赋值,并且每次赋值都会覆盖上一次的值,它不受限于块级作用域,但受限于局部作用域
  • let:和var一样,用于声明一个变量,但是let声明的变量只在当前块级作用域中生效,只有在当前块级作用域内进行重新赋值,才会覆盖上一次的值;
  • const:用于声明一个常量,和let一样,只在块级作用域中生效,并且不可以重新赋值
if (true) {
    var a = 1
    a = 2
    console.log(a) // 2
}
console.log(a) // 2

if (true) {
    let b = 1
    b = 2
    console.log(b) // 2
}
console.log(b) // Uncaught ReferenceError: b is not defined

if (true) {
    const c = 1
    c = 2
}
// Uncaught TypeError: Assignment to constant variable.

if (true) {
    const c = 1
    console.log(c) // 1 
}
console.log(c) // Uncaught ReferenceError: c is not defined
  • 使用var声明的变量,不仅可以在if代码块中可以访问并且可以重新赋值,而且可以在if代码块之外进行访问
  • 使用let声明的变量,仅可以在if代码块中可以访问并且可以重新赋值,不可以在if代码块之外进行访问
  • 使用const声明的常量,无法进行重新赋值,并且无法在if代码块之外进行访问

之所以var可以在if代码块外进行被访问,是因为var不受限于块级作用域,而let、const受限于块级作用域

13. GO、AO、VO、VE以及作用域和作用域链

(1)GO、AO、VO、VE

js的运行是单线程的,因此,js代码在执行的过程,更像是一个进栈、出栈的过程,js引擎(拿V8来说)在执行代码时,会创建一个执行上下文栈(Execution Context Stack)

在执行全局代码时,会创建一个全局执行上下文(Global Context),然后将其放入执行上下文栈进行执行,期间会创建一个变量对象VO(Variable Object),开始执行代码时,V8内部会帮助我们创建一个全局对象GO(Global Object),GO中会存放一些全局变量以及全局方法,此时VO指向GO

在执行全局代码中的函数时,会创建一个函数执行上下文(Functional Execution Context),然后将其放入执行上下文栈进行执行,期间会创建一个VO,开始执行代码时,会创建一个局部活跃对象AO(Activation Object),AO中会存放一些局部变量、局部方法以及函数的参数,此时VO指向AO

由此我们知道:

  • 每一个执行上下文(可能是全局,也可能是函数)创建时,都会创建一个变量对象VO,根据执行上下文的不同VO的指向也不同
  • GO保存的是全局变量以及全局方法,只有在整个文件执行完毕时,才会销毁
  • AO保存的是函数的局部变量以及局部方法,在函数运行完毕时,会被销毁

VE是什么?VE其实就是最新的ECMA标准中把VO的概念改为了VE(Variable Environment(变量环境)),环境中定义的一些变量或函数将会作为一条环境记录(environment record)绑定在变量环境中,之所以更改是因为之前的VO、GO、AO,我们通常把它们定义为对象,但是这种方法太笼统了,在ES6之后,有许多数据结构的出现,比如Map等,因此把VO的定义改为不只局限于对象,更加的严谨,更加的灵活。

(2)作用域

作用域指的就是当前代码的执行上下文,在全局执行上下文中就是GO,在函数执行上下文中就是AO

在js中,常见的作用域有以下几种:

  • 全局作用域:脚本中所有代码默认作用域,也就是说<script>标签内所有代码默认的作用域
  • 模块作用域:在模块化开发中,每一个模块都有自己的作用域
  • 函数作用域:每创建一个函数,函数体内就是一块独立的函数作用域
  • 块级作用域:由letconst衍生出来的一种作用域,每个花括号({})创建出来的代码块,就是块级作用域

(3)作用域链

当我们在当前作用域中(假设当前在某个函数的AO中)访问、修改某个变量时,如果发现当前作用域没有这个变量,就会去上层作用域(上一个AO或者GO)进行查找,直至找到全局作用域(GO),如果还没有,就会报错。这种类似链条形式逐级向上查找的模式,我们称之为作用域链

(4)延长作用域链的方法

  • 闭包:其原理是函数(a)内返回一个函数(b),然后在全局作用域中调用b,按照正常逻辑,函数b的作用域链应该为b的AO -> 全局变量(GO),但是由于函数b是在函数a内创建的,它在创建时,就记录了父级作用域(函数a的AO),因此此时函数b的作用域链应该为b的AO -> a的AO -> 全局变量GO
  • with语句:已经属于废弃功能,格式为with(obj) { ... },它会将{}内的代码的上层作用域指向obj,相当于延长了内部代码的作用域链;
  • try...catch的catch语句:之所以catch(e)能接收到错误信息,是因为在try语句报错时,会将报错信息放到catch语句的作用域链最前端,因此在catch语句之中,才能访问到错误信息;

14. 什么是闭包?作用是什么?

  • 闭包(closure)是一个函数以及其捆绑的周边环境状态(lexical environment词法环境)的引用的组合;
  • 也就是说,闭包可以让你在一个内层函数中访问到其外层函数的作用域
  • 在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来

通俗来讲,闭包就是一个函数它词法环境的组合,而闭包的作用则是可以访问函数外部的自由变量,因为在js中,每个函数被创建时都可以访问全局作用域的变量,因此我们可以认为,在js中,每一个函数在创建时,都生成了一个闭包

词法环境

词法(lexical)一词指的是,词法作用域根据源代码中声明变量的位置来确定该变量在何处可用。嵌套函数可访问声明于它们外部作用域的变量。

也就是说,在我们代码写出来的那一刻,词法作用域已经固定了。如下代码:

function outer() {
    const name = 'out'
    function inner() {
      console.log(name)
    }
}

当我们写出这个代码时,inner函数词法作用域已经固定了,其中的name就是外层outer函数中的变量name

如果我们进行如下操作:

function outer() {
    const name = 'out'
    function inner() {
      console.log(name)
    }

    return inner
}

const fun = outer()
fun() // out

按理来说,此时fun在全局作用域执行,应该在全局作用域中查找name,是访问不到函数内部变量的,为什么还会打印出out呢?这是因为inner的词法作用域限制了它的上级作用域就是outer函数的作用域。而这种可以在外部访问函数内部作用域的情况,就是一种闭包

闭包的作用和缺点

  • 闭包可以让我们在函数作用域外部访问函数内部的变量,从而保护我们的变量不被全局变量所污染;
  • 闭包可以延长作用域链
  • 如果函数内部的变量被外部变量所接收,那么闭包就会产生不好的效果,比如影响垃圾回收,造成内存泄漏

15. var、let、const的区别以及变量提升

(1)什么是变量提升

变量提升就是js引擎在解析代码时,会预先将声明变量的操作进行提前,这种行为就称为变量提升

console.log(a) // undefined

var a = 1

众所周知,js代码是由上而下执行的,在打印变量a时,还未执行var a = 1这一步,为什么没有报错,而是打印出了undefined呢?

这是因为js引擎在解析代码时,会预先将声明变量的操作进行提前,并且默认赋值为undefined,因此上面的代码在js引擎帮助我们解析之后,其实变成了下面的代码:

var a = undefined
console.log(a) // undefined

a = 1

如果是函数呢?

console.log(a) // ƒ a() { ... }
var a = 1

function a() {
    console.log('i am function')
}

我们发现,当声明一个变量a,并且创建一个函数a时,同样会变量提升,并且默认值不再是undefined,而是函数内容,这说明在进行变量提升时,函数的优先级要高于普通变量;

之所以函数进行变量提升时,默认值不是undefined,是因为V8引擎在解析代码时,如果发现是函数,会预先在内存中开辟一块新的地址,用于存放函数内容,里面存放了函数的作用域([[scope]]),该作用域指向其父级作用域,并且还存放了函数的执行体。因此在变量提升时,我们访问该函数,访问的其实是函数的内存地址,里面有函数体,因此访问的结果不是undefined

image.png

(2)使用let、const

console.log(a) // Uncaught ReferenceError: Cannot access 'a' before initialization 
console.log(b) // Uncaught ReferenceError: Cannot access 'b' before initialization

const a = 1
let b = 2

可以看到,在使用letconst声明的变量声明之前访问变量时,会发生报错信息初始化前无法访问“a/b”,这也是let和const解决的一个问题,解决了变量提升的问题,但是letconst并不是不会进行变量提升了,而是因为暂时性死区的原因。

(3)暂时性死区

暂时性死区指的是通过let和const声明的变量,在声明之前,不能对其进行访问

但是这并不能说明let和const不会进行变量提升,如果不会进行变量提升的话,之间访问应该是会报Uncaught ReferenceError: xxx is not defined的,但是并没有,而是提示我们Cannot access 'x' before initialization,只是对它们的访问进行了限制

(4)var、let、const的区别

  • 当前作用域中,使用三者声明变量,都只能在当前作用域进行访问
  • let和const会受到块级作用域限制var不会
  • 三者都存在变量提升,但是let和const因为暂时性死区的关系,无法在声明前进行访问
  • var和let声明的变量,在当前作用域链中可以被修改const无法被修改;
  • 全局作用域中,使用var声明变量时,会将变量存放进window对象let和const不会;
  • let和constES6中出现,因此只能在支持ES6的环境中使用,而var没有这个限制

16. 定义函数的方法有哪些

(1)函数的组成

函数由函数关键字函数名形参函数体组成,其中函数名在特定条件下可以省略

(2)定义函数的方式

  • 函数声明:由函数关键字函数名形参函数体组成。
function myFun(params) {
    console.log('function body')
}
  • 函数表达式:通过声明变量,并且将函数赋值给变量的方式进行定义函数,函数声明中可以省略函数名变量名作为函数名
const myFun = function(params) {
    console.log('function body')
}
  • 构造函数:通过函数的构造函数Function来创建函数。
const myFun = new Function('a', 'b', 'console.log(a + b)')

其他创建函数的方法详见MDN

(3)函数的类型

  • 具名函数:具有函数名的函数,一般用函数声明进行声明;
  • 匿名函数没有函数名的函数,一般用于立即执行函数中
  • 立即执行函数:无需调用立马执行的函数,一般都是匿名函数,它必须由()包裹起来,并且在后面跟上(),用于立即调用函数。为了防止js引擎解析错误,最好在立即执行函数之前添加;
;(function (params) {
    console.log('function body')
})()
  • 箭头函数:在ES6中新增的函数写法没有函数关键字没有函数名、只有形参和函数体,并且由=>连接的函数。
const myFun = () => {
    console.log('function body')
}

17. 函数的参数

  • 函数可以接收一个或多个参数
  • 在函数定义阶段中的参数称之为形参
  • 在函数调用阶段中的参数称之为实参

(1)普通函数中的arguments

当我们在定义函数时,并没有给函数设置形参,但是在实际调用时,却传入了实参,这时候可以通过函数中的arguments来接收;

arguments是一个对应于传递给函数的参数的类数组对象。

  • arguments是一个对象,而非一个数组,我们也可称它为类数组(伪数组)
  • 它通过数组索引的方式去获取值,但是并没有数组实例的方法;
  • 它是数组中内置的一个对象,不用我们手动设置,自动存放传入的参数信息;
function myFun() {
    console.log(arguments instanceof Array) // false
    console.log(arguments[0]) // 1
    console.log(arguments[1]) // 2
    console.log(arguments[2]) // 3
}

myFun(1, 2, 3)

(2)箭头函数中的arguments

箭头函数中不存在arguments

const myFun = () => {
    console.log(arguments) // Uncaught ReferenceError: arguments is not defined
}

myFun(1, 2, 3)

(3)ES6中替代arguments的剩余参数

剩余参数:如果函数的最后一个命名参数以...为前缀,则它将成为一个由剩余参数组成的真数组,其中从0(包括)到theArgs.length(排除)的元素由传递给函数的实际参数提供。

const myFun = (...args) => {
    console.log(args) // [1,2,3]
}

myFun(1, 2, 3)

function myFun2(a, b, ...args) {
    console.log(args) // [3]
}
myFun2(1, 2, 3)

剩余参数即支持箭头函数,也支持普通函数

(4) 剩余参数和arguments的区别

  • 剩余参数...为前缀arguments是一个单独的变量名;
  • 剩余参数需要在形参中定义arguments不需要定义可以直接访问;
  • 剩余参数可以以任何变量名命名arguments是固定的变量名;
  • 剩余参数返回的是除了已接收参数以外的参数arguments所有接收的参数;
  • 剩余参数真数组arguments伪数组

18. 函数中的this指向问题

绝大多数情况下,函数的调用方式决定了 this 的值(运行时绑定)。this不能在执行期间被赋值,并且在每次函数被调用时 this 的值也可能会不同。

(1)this指向什么

console.log(this) // Window

function myFun() {
    console.log(this)
}

const obj = {
    myFun
}

myFun() // Window

obj.myFun() // { myFun: f() }

从上面代码发现,在不同的地方打印this,结果都是不同的,可以总结出一些规律:

  • 全局调用时this的值默认指向Window对象;
  • this的绑定和代码编写的位置没有关系
  • this的绑定和调用的方式有关系

(2)默认绑定

function myFun() {
    console.log(this);
}

const obj = {
    inner: function () {
      console.log(this);
    },
};

function myFun2(fun) {
    fun();
}

myFun(); // Window
myFun2(obj.inner); // Window

"use strict";
myFun(); // undefined
myFun2(obj.inner); // undefined
  • 默认情况下(即函数独立调用,并没有绑定在某个对象上调用时),this的指向都为Window(严格模式下为undefined)

(3)隐式绑定

function foo() {
    console.log(this);
}

const obj = {
    name: "obj",
    foo: foo,
};

const obj1 = {
    name: "obj1",
    obj: obj,
};

const fun = obj.foo;

obj.foo(); // {name: 'obj', foo: f}
obj1.obj.foo(); // {name: 'obj', foo: f}
fun(); // Window

"use strict";
obj.foo(); // {name: 'obj', foo: f}
obj1.obj.foo(); // {name: 'obj', foo: f}
fun(); // undefined
  • 当函数被某个对象调用时,函数的this就会自动指向调用它的对象,这种行为可以称之为隐式绑定。通过隐式绑定我们可以大概搞明白,为什么在全局中的函数调用会绑定Window了,因为fun()其实就相当于Window.fun(),是Window对象调用了fun函数

(4)显式绑定

js为我们提供了三种显示绑定this的方法,分别为callapplybind

  • 在js中所有函数都可以使用callapplybind方法;
  • callapply是在原有函数上绑定this,bind则是返回一个新函数
  • 三者的第一个参数都是要绑定this的值;
  • call的传参方式是一个列表,有多少参数就传多少,apply的传参方式是把所有参数放进一个数组,然后把这个数组传过来bind的传参和call一样;
const obj = {
    name: "obj",
    a: 20,
    b: 30,
};

function add(c) {
    console.log(this.a + this.b + c);
}

add.call(obj, 10); // 60
add.apply(obj, [30]); // 80
const newFun = add.bind(obj, 20); 
newFun(); // 70
  • 通过callapplybind可以手动设置函数的this,并且可以根据不同的规则给函数传参,这种方式称之为显式绑定

(5)内置函数的this绑定

const names = ["1", "2", "3"];
names.forEach(function (item) {
    console.log(this); // Window
});

"use strict";
const names = ["1", "2", "3"];
names.forEach(function (item) {
    console.log(this); // undefined
});

const obj = {
    name: "obj",
};

const names = ["1", "2", "3"];
    names.forEach(function (item) {
    console.log(this); // {name: 'obj'}
}, obj);

forEach函数举例,默认非严格模式情况下指向Window严格模式下undefined,某些内置函数的参数允许传递this,我们可以通过传参的方式绑定this

(6)new关键字绑定(new的过程发生了什么)

首先要搞明白,new的过程发生了什么?

  • 创建一个全新的对象
  • 将该对象的原型(即__proto__)指向其构造函数的prototype属性
  • 将该对象作为this;(绑定this);
  • 如果没有返回新的对象,就会将该对象进行返回;

由代码实现(简易版)如下(以new Array为例):

const obj = Object.create(null)

obj.__proto__ = Array.prototype

const result = Array.call(obj)

return typeof result === 'object' ? result : obj

(7)特殊场景的this绑定

显式绑定为undefinednull

function myFun() {
    console.log(this); 
}

myFun.call(undefined); // Window
myFun.apply(null); // Window

"use strict";
myFun.call(undefined); // undefined
myFun.apply(null); // null
  • 显式绑定undefinednull时,在非严格模式下忽略显示绑定,使用默认规则,this的值为Window,而在严格模式下,就是undefinednull

间接函数引用

function myFun() {
    console.log(this);
}

const obj = {
    name: "obj",
    myFun: myFun,
};

const obj1 = {
    name: "obj1",
};

obj.myFun(); // obj

(obj1.myFun = obj.myFun)(); // Window

"use strict";
(obj1.myFun = obj.myFun)(); // undefined
  • 因为(obj1.myFun = obj.myFun)这个式子的返回值是myFun函数,因此最后一步其实就变成了myFun(),就相当于默认绑定,所以在非严格模式下为Window严格模式下为undefined

箭头函数的this

const arrowFun = () => {
    console.log(this);
};

const obj = {
    arrowFun: arrowFun,
};

const obj1 = {
    name: "obj1",
    myFun: function () {
      setTimeout(() => {
        console.log(this);
      }, 100);
    },
};

arrowFun(); // Window
arrowFun.call("123"); // Window
obj.arrowFun(); // Window
obj1.myFun(); // {name: 'obj1', myFun: ƒ}
obj1.myFun.call("123"); // String {'123'}

从上面代码可总结出箭头函数this的几大特点:

  • 箭头函数进行显式this绑定无效;
  • 箭头函数的this指向它父级的this(当我们改变箭头函数父级的this时,箭头函数的this也跟着改变了);

(8)各种this绑定规则的优先级

  1. 默认绑定规则优先级最低;
  2. 隐式绑定规则其次;
  3. 显式绑定规则高于隐式绑定
  4. 内置函数传参相当于显式绑定
  5. new绑定优先级高于bind
  • new绑定无法和callapply同时使用,因此不存在谁的优先级高;
  • new绑定可以和bind一起使用,但是new绑定的优先级更高

19. 什么是构造函数

查看MDN上对构造函数(constructor)的介绍

  • 通俗来讲,构造函数就是一种特殊的函数,它的定义更像是,它通常和new操作符一起使用,创建一个实例(也就是对象)
  • 实例继承来自构造函数的所有方法和属性;
  • 构造函数都有一个属性prototype,构造函数创建出来的实例会继承prototype上的方法
  • 构造函数创建出来的实例也有自己的一个内置属性[[Prototype]](__proto__),该属性指向构造函数的prototype
  • 构造函数prototype上有一个属性constructor,该属性指向构造函数本身
  • 按照规范来说,构造函数名称的首字母必须大写,普通的函数也可以当作构造函数使用
function Person(name, age) {
    this.name = name
    this.age = age
}

function person(name, age) {
    this.name = name
    this.age = age
}

const p = new Person('lee', 18)
const p2 = new person('lee', 19)
console.log(p) // Person {name: 'lee', age: 18}
console.log(p2) // person {name: 'lee', age: 19}

20. 继承

首先我们要知道,js中的继承都是基于原型链的。

(1)冒充继承

冒充继承顾名思义,并非真正意义上的继承,将父类的构造函数作为子类的一个内部方法,从而实现继承父类属性的一种操作。

function Parent(name) {
    this.name = name
    this.getName = function () {
      console.log(this.name)
    }
}

Parent.prototype.sex = '男'

function Child(name) {
    this.parent = Parent
    this.parent(name)
    // 此时执行
    // this.name = name
    // this.getName = function () { console.log(this.name) }
    // 这时子类已经继承了所有的父类属性和方法,内部多了一个this.parent = Parent, 只需删掉
    delete this.parent
}

const child = new Child('Lee')
child.getName() // Lee
console.log(child.name) // Lee
console.log(child.sex) // undefined
  • 优点:这种方式可以实现多继承
  • 缺点:无法继承父类原型链上的属性和方法;
  • 缺点:需要将父类需要的参数依次传递过去;

(2)原型继承

原型继承就是将父类的实例作为子类的原型

function Parent(name) {
    this.name = name
    this.arr = [1, 2, 3]
}

Parent.prototype.sex = '男'

function Child() {
    this.age = 18
}

// 将子类的原型对象指向父类的实例
Child.prototype = new Parent('Lee')
// 由于子类的原型指向了父类的实例,因此子类的constructor变成了父类,而正常构造函数的prototype中的constructor应该指向它自己
// 我们手动改变
Child.prototype.constructor = Child
const child1 = new Child()
const child2 = new Child()
console.log(child1.name) // Lee
console.log(child1.arr) // [1, 2, 3]
console.log(child1.sex) // 男
child1.arr.pop()
console.log(child2.arr) // [1, 2]
  • 优点:可以继承父类原型链上的属性;
  • 缺点:不能通过将参数传递给子类的方式再将参数传递给父类
  • 缺点:当父类有引用类型的数据时,所有实例会共享,修改其中一个,会影响其他实例;

(3)构造函数继承

构造函数继承指的就是在子类内部改变父类构造函数的this指向,将父类构造函数的this指向子类的this,以达到继承的目的。

function Parent(firstName, lastName) {
    this.name = firstName + lastName
    this.arr = [1, 2, 3]
}

Parent.prototype.sayHi = 'hi'

function Child(firstName, lastName) {
    // 调用父类并传参
    Parent.call(this, firstName, lastName)
    // 或者使用
    // Parent.apply(this,[firstName,lastName])
}

let child1 = new Child('Lee', 'Hi')
let child2 = new Child('Lee', 'Hi')
child1.arr.pop()
console.log(child1.sayHi) // undefined
console.log(child1.arr) // [1, 2]
console.log(child1.name) // Lee Hi
console.log(child2.arr) // [1, 2, 3]
  • 优点:子类可以传参,并将参数传递给父类
  • 优点:子类实例的所有属性和方法独享,修改其中一个实例的值,不会影响其他实例;
  • 缺点:只能继承父类已经实现的方法,无法继承父类原型链中的方法

(4)组合继承

组合继承就是原型继承构造函数的结合。

function Parent(firstName, lastName) {
    this.name = firstName + lastName
    this.arr = [1, 2, 3]
}

Parent.prototype.sayHi = 'hi'

function Child(firstName, lastName) {
    Parent.call(this, firstName, lastName)
}

// 将子类的原型对象指向父类的实例
Child.prototype = new Parent('', '')
Child.prototype.constructor = Child
let child1 = new Child('Lee', 'Hi')
let child2 = new Child('Lee', 'Hi')
console.log(child1.name) // LeeHi
console.log(child1.sayHi) // hi
child1.arr.pop()
console.log(child1.arr) // [1, 2]
console.log(child2.arr) // [1, 2, 3]
  • 优点:实现了构造函数继承原型继承的优点(子类可以传参、不同实例的属性和方法独享、继承了父类原型链的属性);
  • 缺点:调用了多次父类的构造函数(使用Parent.call时会调用一遍,new Parent时又调用了一遍);

(5)寄生继承

寄生继承就是封装一个构造函数,该构造函数接收一个对象,并将该对象作为原型,创建一个新对象,新对象会继承原对象以及原对象原型链上的所有属性和方法,然后在新对象上添加私有方法和属性

// ES5
// 封装一个函数,该函数接收一个对象,函数内部创建一个空的构造函数
// 将该对象作为构造函数的原型
// 返回一个构造函数的实例(空对象)
function strongObj(obj) {
    function Fun(){}
    Fun.prototype = obj
    return new Fun()
}
// ES6
// ES6中新增了一个Object.create(obj | null)方法;
// 该方法通过传入一个对象,创建一个以这个对象为原型的新对象,等同于上述strongObj函数

const obj = {
    name: 'obj',
    arr: [1, 2, 3]
}

obj.__proto__.sayHi = 'hi'

function Child(obj) {
    let child = Object.create(obj)
    child.self = 'self'

    return child
}
const obj1 = Child(obj)
const obj2 = Child(obj)
console.log(obj1.name) // obj
console.log(obj1.sayHi) // hi
obj1.arr.pop()
console.log(obj1.arr) // [1, 2]
console.log(obj2.arr) // [1, 2]
  • 优点:可以继承原型上的属性和方法;
  • 优点:创建出来的新对象在Child方法中新增的属性独享
  • 缺点:只能给Child函数传递参数,不能给父类(原始对象)传递参数;

(6)寄生组合继承

其实组合继承的缺点就是多次调用了父类构造函数,如果能将这一点改变,就近乎完美了,而寄生组合继承就是解决了这个问题。

寄生组合继承主要是改变了组合继承中Child.prototype = new Parent()这一步,可以直接使用Child.prototype = Parent.prototype,就可以省去一次Parent函数的调用,但是这样又会引发新的问题,这样操作的话,Child的原型和Parent的原型在内存中就指向同一块地址,当我们修改Child的原型属性时Parent的原型属性也会被影响

寄生继承的思路可以让我们解决这个问题,使用Object.create(Parent.protype)创建一个新对象,将这个对象作为Child.prototype,就解决了所有问题,这也是为什么叫做寄生组合式继承,它是寄生继承组合继承的结合,寄生组合式继承是ES5中近乎完美的继承方法

function Parent(firstName, lastName) {
    this.name = firstName + lastName
    this.arr = [1, 2, 3]
}

Parent.prototype.sayHi = 'hi'

function Child(firstName, lastName) {
    Parent.call(this, firstName, lastName)
}

// 将子类的原型对象指向父类的实例
Child.prototype = Object.create(Parent.prototype)
Child.prototype.constructor = Child
let child1 = new Child('Lee', 'Hi')
let child2 = new Child('Lee', 'Hi')
console.log(child1.name) // LeeHi
console.log(child1.sayHi) // hi
child1.arr.pop()
console.log(child1.arr) // [1, 2]
console.log(child2.arr) // [1, 2, 3]

(7)Class继承

ES6之前,所有的继承都是通过开发者自己封装实现的,官方并没有给出一个继承的属性,在ES6之后,有了class关键字,它代表一个

Class有几个关键字classconstructorsuperextends

  • class代表声明一个类;
  • constructor代表构造函数
  • super相对于通过call、apply调用父构造函数并改变this指向
  • extends代表继承;
  • 类中的所有方法除了构造函数方法中的属性和方法外,其他都挂载在类的原型链上
class Parent {
    constructor(firstName, lastName) {
      this.name = firstName + lastName
      this.arr = [1, 2, 3]
    }
    sayHi() {
      console.log('sayHi')
    }
}

class Child extends Parent {
    constructor(firstName, lastName) {
      super(firstName, lastName)
      this.selfName = 'child'
    }
}
const child1 = new Child('Lee', 'Hi')
const child2 = new Child('Lee', 'Hi')
console.log(child1.name) // LeeHi
child1.sayHi() // hi
child1.arr.pop()
console.log(child1.arr) // [1, 2]
console.log(child2.arr) // [1, 2, 3]

console.log(new Parent())

image.png

  • class可以实现ES5继承中的所有操作;
  • sayHi方法没有定义在constructor中,因此它会被挂载到Parent类的原型属性上

21. Promise

MDN对Promise的介绍

Promise 有着它自己的规范,大家也可以去看一看Promise/A+

个人理解:

  • Promise是一个,或者可以称之为一个构造函数,因为它可以使用new 关键字来创建一个Promise对象
  • Promise的参数是一个函数,该函数的参数又是两个函数,一般称之为resolvereject
  • Promise是有状态的,一旦状态确定,就不可被修改,默认状态为pending(初始状态),当我们调用参数中的resolve函数,内部状态会变为fulfilled(已完成),如果我们调用参数中的reject函数,内部状态会变为rejected(已拒绝)
  • Promise可以接收回调,我们可以通过.then来对Promise进行链式调用,.then也是一个函数,它可以接收两个参数(也都为函数)第一个参数是调用resolve产生的回调第二个参数是调用reject产生的回调

(1)Promise的基本使用

const p = new Promise((resolve, reject) => {})
console.log(p) // Promise {<pending>}

const p1 = new Promise((resolve, reject) => {
    resolve() // Promise {<fulfilled>: undefined}
})
console.log(p1)
const p2 = new Promise((resolve, reject) => {
    reject() // Promise {<rejected>: undefined}
})
console.log(p2)

由此可以看出Promise的三种状态

(2)Promis的回调

const p = new Promise((resolve, reject) => {
    resolve()
}).then(res => {
    console.log(res) // undefined
})

const p1 = new Promise((resolve, reject) => {
    reject()
}).then(null, err => {
    console.log(err) // undefined
})
  • 通过.then方法的第一个参数,接收到了resolve的回调,并打印出了undefined
  • 通过.then方法的第二个参数,接收到了reject的回调,并打印出了undefined

有人会认为Promise.then()只能接收一个参数,而reject的回调是在Promise.catch中接收的。如下:

const p1 = new Promise((resolve, reject) => {
    resolve()
}).then(res => {
    console.log(res)
}).catch(err => {
    console.log(err)
})

这种方法也可以接收reject的回调,只是这种方法只是JavaScript帮助我们实现的一个语法糖而已,在PromiseA+规范中,是没有.catch这个说法的。

(3)Promise回调的传参

传递普通值

const p = new Promise((resolve, reject) => {
    resolve(1)
})
.then(res => {
    console.log(res) // 1
})
  • 普通值作为参数传给resolve或者reject,该参数会作为.then方法第一个参数或第二个参数(都是函数)的参数传递过去。

传递Promise

// 传递无状态的Promise
const innerP = new Promise(() => {})
const p = new Promise((resolve, reject) => {
    resolve(innerP)
}).then(res => {
    console.log(res) // 啥都没有
})

// 传递有状态的Promise
const innerP = new Promise(resolve => {
    setTimeout(() => {
      // resolve()
      resolve(1)
    }, 1000)
})
const p = new Promise((resolve, reject) => {
    resolve(innerP)
}).then(res => {
    // console.log(res) // 等待1s -> undefined
    console.log(res) // 等待1s -> 1
})
  • 当传递一个有状态的Promise作为resolve或reject的参数时,会等待Promise的resolve或reject的结果,然后将该结果作为.then方法第一个参数或第二个参数(都是函数)的参数传递过去。

传递一个普通对象

const obj = {
    name: 'Lee'
}
const p = new Promise((resolve, reject) => {
    resolve(obj)
}).then(res => {
    console.log(res) // {name: 'Lee'}
})
  • 传递普通对象时,和传递普通值结果一样。

传递一个thenable对象

thenable对象指的就是一个对象,它拥有then方法,该方法拥有两个参数,且都为函数(第一个参数相对于resolve,第二个相当于reject),并且在then方法之中调用了这两个参数的其中一个

const obj = {
    name: 'Lee',
    then: function (resolve, reject) {
      resolve(this.name)
    }
}
const p = new Promise((resolve, reject) => {
    resolve(obj)
}).then(res => {
    console.log(res) // Lee
})
  • 传递一个thenable对象时,会将该thenable对象转换为Promise对象,效果和传递一个Promise对象相似。

总结

  • 如果 resolve/reject 传入一个普通值(包括普通对象) ,那么会将该值原封不动的做为.then 中的参数被 resolve/reject 的回调接收
  • 如果 resolve/reject 传入一个 Promise 对象,那么会将Promise 对象的结果做为.then 中的参数被 resolve/reject 的回调接收,如果有延时执行,.then 会等待传入的 Promise 有结果之后才会执行。
  • 如果 resolve/reject 传入一个 thenable 对象,那么会将thenable 对象的 resolve 或 reject 的结果做为.then 中的参数被 resolve/reject 的回调接收,如果有延时执行,.then 会等待传入的 thenable 有结果之后才会执行。

(4)对象方法then

  1. then() 方法返回一个Promise。它最多需要有两个参数Promise 的成功和失败情况的回调函数

  2. 参数:onFulfilled 可选 onRejected 可选

  3. onFulFilled 是当 Promise 变成接受状态(fulfilled)时调用的函数。该函数有一个参数,即接受的最终结果。如果该参数不是函数,则会在内部被替换为 (x) => x,即原样返回 promise 最终结果的函数。

  4. onRejected 是当 Promise 变成拒绝状态(rejected)时调用的函数。该函数有一个参数,即拒绝的原因。 如果该参数不是函数,则会在内部被替换为一个 抛出器函数((x) => { throw x; })。

  5. then方法可以多次调用

  6. then方法可以链式调用,上次then的返回值会作为下次then方法中的参数

const p = new Promise((resolve, reject) => {
    resolve(1)
})

p.then(res => {
    console.log(res) // 1
})

p.then(2).then(res => {
    console.log(res) // 1
})

const p1 = new Promise((resolve, reject) => {
    reject('err')
})

p1.then(undefined, err => {
    console.log(err) // err
})

p1.then(undefined, 'err2').then(undefined, err => {
    console.log(err) // err
})

上述代码中,当一个Promise调用多次then方法时,所有方法都会被调用,如果then的参数不是函数会转换成x => x/x => throw x(x为Promise中resolve/reject的参数)的格式

(5)对象方法catch

catch() 方法返回一个 Promise在 Promise 被拒绝之后,执行相应的回调。它的行为与调用 Promise.prototype.then(undefined, onRejected) 相同

参数:onRejected

当 Promise 被拒绝时,被调用的一个方法。 该函数拥有一个参数reason来表示Promise 被拒绝的原因。如果 onRejected 抛出一个错误或返回一个本身失败的 Promise,那么通过 catch() 返回的 Promise 就会被 rejected;否则,它将显示为成功(resolved),这也就意味着,如果在onRejected 中继续抛出一个错误,可以在后续继续使用 catch()链式调用进行捕获

const p = new Promise((resolve, reject) => {
    reject(1)
})

p.catch(err => {
    console.log(err)
    throw 2
}).catch(err => {
    console.log(err)
})

(6)对象方法finally

finally() 方法返回一个Promise。在 promise 结束时无论结果是 fulfilled 或者是 rejected,都会执行指定的回调函数。这为在 Promise 是否成功完成后都需要执行的代码提供了一种方式。 这避免了同样的语句需要在 then()和 catch()中各写一次的情况。

参数:onFinally -> Promise 结束后调用的方法

const p = new Promise((resolve, reject) => {
    resolve(1)
})

p.then(res => {
    console.log(res)
}).finally(() => {
    console.log('end')
})

// 打印顺序 1 -> 'end'

(7)静态方法resolve/reject

Promise.resolve(value)方法用于解决(resolve)一个值,并返回一个Promise对象,参数可以是Promisethenable对象普通值的任意一种。

如果参数是普通值,那么返回的Promise,就是一个已完成的Promise,值就是传入的值;

如果参数是thenable对象,那么返回的Promise,就是一个初始态的Promise,会以thenable对象的最终结果,作为返回Promise的结果,当thenable方法的then函数返回另一个thenable时,会被Promise.resolve进行展平

如果参数是Promise对象,那么会返回这个Promise对象

console.log(Promise.resolve(123)) // Promise {<fulfilled>: 123}

const promise = new Promise(resolve => resolve(123))
console.log(Promise.resolve(promise))
// Promise {<fulfilled>: 123}

const thenable = {
    then: (resolve, reject) => {
      resolve(123)
    }
}
console.log(Promise.resolve(thenable))

从上面代码可以得知,Promise.resolve()返回的是一个解析过的Promise,并且根据传入的值,返回不同的Promise

const thenable = {
    then: (resolve, reject) => {
      resolve({
        then: function (onFulfilled, onRejected) {
          onFulfilled(123)
        }
      })
    }
}
Promise.resolve(thenable).then(res => {
    console.log(res) // 123
})

上述代码证明了thenable中的then方法resolve/reject另一个thenable时,会被Promise.resolve连续处理,保证最终结果不是一个thenableMDN上对此的说明

(8)静态方法all

  • Promise.all()传入一个可迭代对象(一般为数组),返回一个Promise
  • 如果传入的可迭代对象是空,或者传入的可迭代对象中的Promise都resolve时,Promise.all()进行resolve回调,如果可迭代对象中的值不为Promise,Promise不会处理这些值,但还是会放到最终的结果数组中,会将所有的resolve结果放到数组中返回
  • 如果传入的可迭代对象中有一个Promise为reject时,Promise.all()进行reject回调,并将第一个reject的原因作为返回值
const p1 = new Promise(resolve => {
    resolve(1)
})

const p2 = 2

const p3 = new Promise((resolve, reject) => {
    reject(3)
})
Promise.all([p1, p2])
.then(res => {
  console.log(res) // [1, 2]
})
.catch(err => {
  console.log(err)
})

Promise.all([p1, p3])
.then(res => {
  // 不会执行
  console.log(res) 
})
.catch(err => {
  console.log(err) // 3
})

(9)静态方法race

  • Promise.race()传入一个可迭代对象(一般为数组),返回一个Promise
  • Promise.race()的结果由第一个完成的Promise决定;
const p1 = new Promise(resolve => {
    setTimeout(() => {
      resolve(1)
    }, 300)
})

const p2 = new Promise(resolve => {
    setTimeout(() => {
      resolve(2)
    }, 200)
})

const p3 = new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(3)
    }, 100)
})
Promise.race([p1, p2, p3])
.then(res => {
  console.log(res) // 3 因为100毫秒后p3先完成
})
.catch(err => {
  console.log(err)
})

(10)静态方法any

  • Promise.any()传入一个可迭代对象(一般为数组),返回一个Promise
  • 可迭代对象为空可迭代对象中的Promise都reject时,Promise.any会reject,会将reject的原因作为数组返回;
  • 可迭代对象中任何一个Promise执行resolve时,那Promise.any就会resolve,并且返回第一个resolve的值
const p1 = new Promise(resolve => {
    reject(1)
})

const p2 = new Promise(resolve => {
    resolve(2)
})

const p3 = new Promise((resolve, reject) => {
    resolve(3)
})
Promise.any([p1, p2, p3])
.then(res => {
  console.log(res) // 2
})
.catch(err => {
  console.log(err)
})

Promise.any([])
.then(res => {
  console.log(res)
})
.catch(err => {
  console.log(err) // AggregateError: All promises were rejected
})

(11)静态方法allSettled

  • Promise.allSettled()传入一个可迭代对象(一般为数组),返回一个Promise
  • 传入的可迭代对象为空可迭代对象中的所有Promise都有结果时,该方法resolve
  • Promise.allSettled()的值为记录每个Promise状态和结果的对象数组,对象有两个key,如果Promise的状态为fulfilled,那么就是status和value,如果Promise的状态为rejected,那么就是status和reason
const p = new Promise((resolve, reject) => resolve(1))

const p1 = new Promise((resolve, reject) => reject(2))

const p2 = new Promise((resolve, reject) => reject(3))

Promise.allSettled([p, p1, p2]).then(res => {
    console.log(res)
})
// output: [0: {status: 'fulfilled', value: 1} 1: {status: 'rejected', reason: 2} 2: {status: 'rejected', reason: 3}]

(12)静态方法withResolvers

  • Promise.withResolvers()用于返回一个对象,其包含一个新的Promise对象和两个函数,用于解决或拒绝它,这两个函数对应于Promise对象中的resolve和reject
  • 它和普通创建Promise对象唯一不同的区别就是,这种方法创建的Promise对象,Promise本身和resolve、reject函数处于同一作用域,而不是被一次性使用
const p = Promise.withResolvers()

console.log(p) // {promise: Promise{<fulfilled>: 1}, reject: f(), resolve: ()}

p.resolve(1)

p.promise.then(res => {
    console.log(res) // 1
})

22. Promise.resolve和new Promise(resolve => { resolve() })的区别(reject同理)

传入Promise时返回值不同

const promise = new Promise(resolve => resolve(123))

const promise1 = new Promise(resolve => {
    resolve(promise)
})

console.log(promise) // Promise {<fulfilled>: 123}

console.log(promise1) // Promise {<pending>}

console.log(Promise.resolve(promise)) // Promise {<fulfilled>: 123}

这是因为Promise.resolve()在传入一个Promise对象时,返回的是Promise对象本身,而new Promise(resolve => resolve())的参数如果是一个Promise对象时,浏览器会开启一个微任务去先处理这个作为参数的Promise对象,而当前Promise又要等待这个参数的Promise结果,因此在取值时,会拿到一个pending状态的Promise结果,这和浏览器的事件循环机制有关

new Promise(resolve => resolve()).then的执行时机会晚2个事件循环

const promise = new Promise(resolve => resolve(123))

const promise1 = new Promise(resolve => {
    resolve(promise)
})

new Promise(resolve => {
    promise
})

promise1
.then(res => {
  console.log(res - 1)
  return res - 1
})
.then(res => {
  console.log(res - 1)
  return res - 1
})
.then(res => {
  console.log(res - 1)
  return res - 1
})

Promise.resolve(promise)
.then(res => {
  console.log(res)
  return res
})
.then(res => {
  console.log(res + 1)
  return res + 1
})
.then(res => {
  console.log(res + 1)
  return res + 1
})

// output: 123 124 122 125 121 120

按理来说,按照代码顺序,从上到下执行,到了promise1.then时,会把promise1.then放入微任务队列,然后再去执行Promise.resolve(promise).then,然后将其放入微任务队列, 此时微任务队列应该是[promise1.then, Promise.resolve(promise).then],然后先打印122,再打印124,为什么顺序不是这样呢?

和上面的原因一样,是因为new Promise(resolve => resolve())的参数如果是一个Promise对象时,浏览器会开启一个微任务去先处理这个作为参数的Promise对象,等待这个作为参数的Promise对象被处理完,才会继续处理当前的Promise对象,因此代码相当于变成了这样:

const promise = new Promise(resolve => resolve(123))

const promise1 = new Promise(resolve => {
    resolve(promise)
})

// 伪代码
new Promise(resolve => {
// 处理promise对象,发现promise对象的返回值是123
    resolve(123)
})
.then(res => {
  // 拿到了promise对象的结果
  return res
})
.then(res => {
  console.log(res - 1)
  return res - 1
})
.then(res => {
  console.log(res - 1)
  return res - 1
})
.then(res => {
  console.log(res - 1)
  return res - 1
})

Promise.resolve(promise)
.then(res => {
  console.log(res)
  return res
})
.then(res => {
  console.log(res + 1)
  return res + 1
})
.then(res => {
  console.log(res + 1)
  return res + 1
})
  • 代码从上到下解析,第一次发现微任务,添加至微任务队列,微任务队列内容为[resolve(123), console.log(res)];
  • 执行微任务队列内容,发现第一个任务无需打印,然后将第一个任务的后续任务加入微任务队列,第二个任务打印123,将第二个任务的后续任务加入微任务队列,此时微任务队列内容为[resolve(res), console.log(res+1)];
  • 再次执行微任务队列,发现第一个任务无需打印,然后将第一个任务的后续任务加入微任务队列,第二个任务打印124,将第二个任务的后续任务加入微任务队列,此时微任务队列内容为[console.log(res - 1),console.log(res+1)];
  • 继续执行微任务队列,发现第一个任务打印122,第二个任务打印125,然后再将后续任务加入队列;
  • 依此类推,正常完成输出内容,从而发现,new Promise(resolve => {resolve()}).then的处理时机,比Promise.resolve().then的处理时机,晚了两步。

23. Promise是为了解决什么

Promise主要用于解决回调地狱问题,在Promise出现之前,想要处理异步任务只能通过定时器等方式,并且不支持链式调用,导致各种回调,代码杂乱错误异常不容易捕获等问题,但是Promise本身在回调过多的情况下,也会产生很多.then链式调用的代码,只是相对于之前的方式来说好了一些,但并不是最优

24. 迭代器生成器

(1)迭代器是什么

百度百科对迭代器的定义:

迭代器是程序设计的软件设计模式,可在容器对象(container,例如链表或者数组)上遍访的接口,设计人员无需关心容器对象的内存分配的实现细节。

MDN对迭代器协议的介绍

而在js中迭代器就是一个通过使用next方法实现了迭代器协议的对象。

next方法有以下要求:

  1. 一个无参数有参数函数,返回一个有以下两个属性的对象;
  • done:它的值是boolean类型,如果迭代器中可以产生下一个序列的值,则该值为false,如果迭代器已经迭代完毕,则为true。也就是说迭代器遍历时,如果下一个值还有,done就为false,遍历完了,说明完成了,done就变成true了。
  • value:它的值是任意JavaScript值done为true时,该值可以省略
  1. 如果返回了一个非对象值,则会抛出一个异常(iterator.next() returned a non-object value)

(2)迭代器代码形式

const arr = [1, 2, 3, 4, 5, 6]
let index = 0
const iterator = {
    next: () => {
      if (index < arr.length) {
        return { value: arr[index++], done: false }
      } else {
        return { value: undefined, done: true }
      }
    }
}

console.log(iterator.next()) // {value: 1, done: false}
console.log(iterator.next()) // {value: 2, done: false}
console.log(iterator.next()) // {value: 3, done: false}
console.log(iterator.next()) // {value: 4, done: false}
console.log(iterator.next()) // {value: 5, done: false}
console.log(iterator.next()) // {value: 6, done: false}
console.log(iterator.next()) // {value: undefined, done: true}

(3)可迭代对象

  • 可迭代对象迭代器不是一种概念,实现了可迭代协议的对象就是可迭代对象
  • 可迭代协议迭代器协议不同;MDN对两种协议的介绍
  • 可迭代对象要求该对象必须实现@@iterator方法,也就是说,对象本身(或在它的原型链上)必须要有一个@@iterator属性,通常我们可以使用Symbol.iterator进行访问;
  • Symbol.iterator是一个无形参的函数,返回值必须是一个迭代器
  • 因为Symbol.iterator是一个无形参的函数,因此只能迭代对象内部属性
const iterableObj = {
    value: [1, 2, 3, 4, 5],
    [Symbol.iterator]: function () {
      let index = 0
      return {
        next: () => {
          if (index < this.value.length) {
            return { done: false, value: this.value[index++] }
          } else {
            return { done: true, value: undefined }
          }
        }
      }
    }
}

const iterator = iterableObj[Symbol.iterator]()
console.log(iterator.next()) // {done: false, value: 1}
console.log(iterator.next()) // {done: false, value: 2}
console.log(iterator.next()) // {done: false, value: 3}

此时,iterableObj就是一个可迭代对象,可以调用它的[Symbol.iterator]方法,得到一个生成器,然后遍历该对象的内部属性

(4)可迭代对象有什么用

const obj = {
    name: 'Lee',
    age: 18
}

for (let key of obj) {
    console.log(key) // Uncaught TypeError: obj is not iterable
}

当我们使用for...of去遍历一个普通对象时,控制台报错了,告诉我们obj不是一个可迭代对象

如果我们把普通的对象实现可迭代协议呢?

const iterableObj = {
    value: [1, 2, 3, 4, 5],
    [Symbol.iterator]: function () {
      let index = 0
      return {
        next: () => {
          if (index < this.value.length) {
            return { done: false, value: this.value[index++] }
          } else {
            return { done: true, value: undefined }
          }
        }
      }
    }
}

for (let key of iterableObj) {
    console.log(key) // 1 2 3 4 5
}

这时,发现可以正常遍历了。 所以说可迭代对象的用途是

  • 可以通过迭代器遍历我们想遍历的属性;
  • 可以使用一些js内置的方法;

(5)js中运用迭代器的场景

  • StringArrayMapSet等都是可迭代对象
  • for...of...(展开运算符)解构都是利用了迭代器原理(仅限于可迭代对象);

普通对象也可以进行...展开解构,但它并不是利用了迭代器,而是在ES9中专门为对象实现简单操作的语法糖

(6)生成器和生成器函数是什么

生成器

  • 生成器是由生成器函数产生的;
  • 生成器是一种特殊的迭代器
  • 生成器主要运用于函数,是 ES6 中新增的一种函数控制的方案,它能够使我们灵活的控制函数暂停执行以及继续执行

生成器函数

  • 生成器函数的声明方式和普通函数差不多,但是需要在function关键词后加一个*号;
  • 生成器函数可以通过yield关键字来控制函数的执行流程;
  • 生成器函数调用时并不会执行函数,它的返回值是一个生成器(Generator)
  • 生成器函数调用既然返回的是生成器,而生成器又是一种特殊的迭代器,因此我们可以通过next关键字来调用生成器函数
function* generatorFun() {
    console.log(1)
    yield
    console.log(2)
    yield
    console.log(3)
    console.log(4)
    console.log(5)
}

const fun = generatorFun()
console.log(fun) // generatorFun {<suspended>}
fun.next() // 1
fun.next() // 2
fun.next() // 3 4 5

(7)生成器的返回值

  • 生成器是一种特殊的迭代器,所以也会有返回值({value:**, done: **}的格式);
function* generatorFun() {
    console.log(1)
    yield
    console.log(2)
}

const fun = generatorFun()
console.log(fun.next()) // {value: undefined, done: false}

返回值的valueundefined,这是因为我们没有给它设置返回值,如果return一个值会发生什么?

function* generatorFun() {
    console.log(1)
    return 1
    yield
    console.log(2)
}

const fun = generatorFun()
console.log(fun.next()) // {value: 1, done: true}

当手动return一个值时,调用生成器的返回值中value有值了,但是done却变成true了,我们的生成器明明还没有执行完。

这是因为,在生成器函数中调用return时,相当于手动中止了生成器,给生成器传参的方法很简单,就是把参数放在yield关键字后面,参数就会作为调用生成器的value值

function* generatorFun() {
    console.log(1)
    yield 1
    console.log(2)
}

const fun = generatorFun()
console.log(fun.next()) // {value: 1, done: false}

(8)生成器的传参

  • 我们可以通过向生成器函数传参,让第一次调用生成器的结果变为该参数;
  • 下一次调用生成器时传参,参数会作为上一个yield语句的返回值接收
function* generatorFun(num) {
    console.log(num)
    const num1 = yield
    console.log(num1)
    const num2 = yield
    console.log(num2)
}

const fun = generatorFun(1)
fun.next() // 1
// 第二个next传参,会作为第一个yield的返回值
fun.next(2) // 2
// 第三个next传参,会作为第二个yield的返回值
fun.next(3) // 3

(9)终止生成器函数

  • 通过调用生成器的return方法,来终止生成器函数
  • return方法可以传递参数,相当于在上一次正常执行下一次yield之间穿插了一行return
function* generatorFun(num) {
    console.log(num)
    const num1 = yield
    console.log(num1)
    const num2 = yield
    console.log(num2)
}

const fun = generatorFun(1)
fun.next() // 1
fun.return(2) // 无反应
fun.next(3) // 无反应

// 等同于
function* generatorFun(num) {
    console.log(num)
    return 2
    const num1 = yield
    console.log(num1)
    const num2 = yield
    console.log(num2)
}

(10)生成器的抛出异常和异常捕获

  • 可以通过生成器的throw()方法进行抛出异常;
  • 可以通过try...catch语句捕获异常;
  • throw方法可以传参,参数为异常信息,可以被catch捕获;
  • 异常未被捕获会阻止生成器函数的继续运行,异常被捕获不影响生成器函数的运行;
function* generatorFun() {
    console.log("第一个yield执行");
    try {
        yield;
    } catch (err) {
        console.log("捕获到了异常:", err);
    }

    console.log("第二个yield执行");
    yield;
    console.log(3);
}
const fun = generatorFun();
fun.next(); // 第一个yield执行 捕获到了异常: 2
fun.throw(2); // 第二个yield执行

function* generatorFun() {
    console.log('第一个yield执行')
    yield
    console.log('第二个yield执行')
    yield
    console.log(3)
}
const fun = generatorFun()
fun.next() // 第一个yield执行 捕获到了异常: 2
fun.throw(2) // Uncaught 2

(11)yield*

  • 该表达式用于委托给另一个generator或可迭代对象
  • 该表达式迭代操作的对象,并产生它返回的每个值;
  • 该表达式本身的值迭代器关闭(done为true)时返回的值
function* g1() {
  yield 2;
  yield 3;
  yield 4;
}

function* g2() {
  yield 1;
  yield* g1();
  yield 5;
}

// 此时的g2相当于什么呢?它就相当于:
function* g2() {
  yield 1;
  yield 2;
  yield 3;
  yield 4;
  yield 5;
}

(12)生成器的意义

生成器可以让我们控制函数的执行,当调用多个函数时,可以等待前一个函数有了返回值再去调用下一个,能够让程序更加灵活,虽然Promise也可以做到这一点,但是生成器无疑是看起来更加简便的一种做法

25. async/await

  • async/awaitES8(ES2017)出现的一个异步调用的语法糖;
  • async/await的原理是Promisegenerator(生成器)

不要再说async/awaitES7出现的了,ES6是ES2015,ES2017不就是ES8吗

(1)async

  • async用于声明一个异步函数,是单词(asynchronous)的缩写;
  • async声明的函数的返回值,会隐式包裹一层Promise对象;
  • async声明的函数如果返回一个普通值,会得到一个fulfilled状态的 Promise。.then 的结果是这个值本身
  • async声明的函数如果返回一个thenable对象或Promise,会得到一个pending状态的 Promise。.then 的结果是thenable或Promise对象的结果

(2)await

  • await表示的是等待 ,等待的是右侧的表达式结果
  • await关键字只能在带有async关键字的函数内部使用,在外部使用时会报错;
  • await的格式[返回值] = await 表达式
  • await后面的表达式可以是一个Promise对象或任何要等待的值
  • await后面如果跟的是一个Promise,并且该 Promise 的状态是fulfilled,则返回值是这个Promiseresolve 的值,如果该 Promise 的状态是rejected,则会抛出异常。如果是一个thenable对象,那么返回值是 thenable 最终的状态。如果是普通值,那么返回值是表达式本身的值。
  • await暂停当前异步函数的运行,直到右侧表达式执行完毕,在异步函数中,await 语句下面的代码会被放入异步队列中;
  • await右侧表达式的的执行不受 await 影响
  • await在最新的ES标准中和Promise.resolve()语义一致。

当使用async/await对多个函数进行顺序调用时我们可以这么写:

  async function result() {
    const res = await fun()
    const res1 = await fun1()
    const res2 = await fun2()
  }

这种格式很像我们的生成器函数,而async/await的原理就是生成器+promise

  • await右侧的代码会同步执行,因为await的语义和Promise.resolve()一致;
function baseFun() {
    return 1
}

async function myFun() {
    await baseFun()
    // 等同于
    Promise.resolve(baseFun())
}
  • await语句下面的代码会被加入异步队列异步执行,可以理解为,await下面的代码等同于Promise.resolve().then()中的代码
function baseFun() {
    return 1
}

async function myFun() {
    await baseFun()
    console.log('异步执行')
    // 等同于
    Promise.resolve(baseFun()).then(() => {
      console.log('异步执行')
    })
}

很多人说async/await写出的代码是同步代码,其实这种说法是完全错误的,async/await代码本质上还是异步代码,只不过有了暂停的概念,让后续的同步代码等待异步代码的执行,看起来像是同步代码而已

(3)捕获async/await的异常

  • 如果await后面的语句抛出了异常,我们可以通过try...catch来捕获。
function baseFun() {
    throw new Error('错误~')
}

async function myFun() {
    try {
      await baseFun()
    } catch (err) {
      console.log(err) // Error: 错误~
    }
}

myFun()

(4)async/await解决了什么问题

Promise生成器函数解决了一部分的回调地狱问题,但是碰见复杂场景时,还是略有不足。async/await的出现,让我们在解决复杂的异步回调问题时,代码能够更加的优雅,并且面对错误处理时,可以结合try...catch完美的捕获异常,总的来说,它能够让我们的代码在处理异步问题时更加的清晰明了,书写更加规范整齐,增强阅读性。

26. 事件循环

(1)js代码执行过程

js单线程的,但是浏览器是多进程多线程的,每个浏览器的tab都是一个进程,而一个进程中可以有多个线程,浏览器在处理我们的js代码时,会开启一个主线程,所有的js代码都在这个线程执行,但是如果碰到了比如一些定时器任务,难道还要等待它完成才能让用户进行下一步操作吗?这显然是不合理的,而浏览器采用了排队的方式进行处理。

浏览器将我们的js代码中的每一个任务分为了同步任务异步任务,而排队指的就是浏览器在遇到一些异步任务时,会将它们暂时放入任务队列,等到主线程的同步任务执行完毕,再去执行这些队列的任务

(2)任务队列

在之前,任务队列只包含两种,分别是微任务队列宏任务队列,而如今官方已经取消了宏任务的说法,将其细分为了多种其他队列,比如chrome浏览器,就将宏任务又划分了如延时队列交互队列等多个队列,并且每个队列有不同的优先级

(3)任务队列的执行时机

根据W3C官方的解释,每个任务有不同的类型,同类型的任务必须在同一个队列不同的任务可以属于不同的队列。不同任务队列有不同的优先级,在一次事件循环中,由浏览器自行决定取哪一个队列的任务。但浏览器必须有一个微队列,微队列的任务一定具有最高的优先级,必须优先调度执行

  • 微任务队列优先级最高,它包括了Promise.then()MutationObserver等任务;
  • 宏任务队列优先级较低,它包含了定时器任务、postMessage、<script>、UI渲染、dom监听、ajax等任务;
  • 在浏览器划分的多个任务队列中交互队列的优先级排在微任务队列之后,而延时队列的优先级排在最后;

(4)什么是事件循环

事件循环(event loop)指的就是js在处理异步任务时进行的循环查找过程,查找什么呢?查找任务队列中的任务

  • 主线程会优先执行同步任务,遇到异步任务会将它们放入相应的异步任务队列
  • 执行完同步任务,会先查看微任务队列是否有要处理的微任务,如果有,从头到尾依次执行,并将这次执行产生的其他异步任务继续放入任务队列,等待下次事件循环继续执行
  • 执行完毕微任务队列中的任务,会根据优先级查看其他任务队列是否有需要执行的任务,如果有,依次执行;
  • 在执行除了微任务队列之外的队列任务时,每执行完一个任务,就会查看微任务队列是否有新增的微任务,如果有,就会继续优先执行微任务队列,以保证微任务队列必须优先调度执行
  • 循环执行,直至所有任务调度完毕;

而这整个过程,就是事件循环,它是为了保证单线程的js在处理异步任务时永不阻塞的一种机制。

27. js中数组原型常用方法

数组原型上的常用方法有以下几种:

  • arr.push(val):在数组尾部插入一个或多个元素,并返回数组长度
  • arr.pop()删除数组最后一个元素,并返回删除的元素
  • arr.unshift(val):在数组头部插入一个或多个元素,并返回数组长度
  • arr.shift()删除数组第一个元素,并返回删除的元素
  • arr.concat():将两个数组进行合并,并返回合并后的新数组,原数组不会收到改变;
  • arr.join():将一个数组或类数组对象所有元素拼接成一个字符串,并以指定的分隔符进行分割(默认为“,”),如果数组只有一个元素,则不会产生分隔符,返回这个字符串
  • arr.indexOf():可以接收两个参数,该方法会找到第一个参数参数在数组中第一次出现的索引,并返回这个索引第二个参数的作用为从数组第几项开始查找,如果没有找到,则返回-1
  • arr.includes():可以接收两个参数,会判断第一个传入的参数是否为数组的子元素,第二个参数的作用为从数组第几项开始,返回值为boolean;
  • arr.forEach():将一个数组进行遍历,每次遍历可以执行给定的函数
  • arr.flat():将一个多维数组拍平,传参为多维数组的层级(即要拍平几次),传入Infinity即为无限(直到拍平为止),并返回一个新的数组
  • arr.filter()遍历一个数组,并且根据传入的回调函数,将所有符合规则的元素放在一个新数组中返回,不会改变原数组
  • arr.find()遍历一个数组,并且根据传入的回调函数,返回符合条件的第一个元素
  • arr.findIndex()类似arr.find(),但是返回的是元素在数组中对应的角标
  • arr.map()遍历一个数组,每个元素会根据传入的回调函数重新拼装,并返回一个新数组,不会影响原数组;
  • arr.some()遍历一个数组,根据传入的回调函数,只要有一个元素条件不满足,就会返回false,否则返回true;
  • arr.every()类似some,但所有元素不满足条件时,才会返回false;
  • arr.slice():传入两个参数,分别为开始的索引和结束的索引,会根据参数区间截取原数组的一部分,并返回一个新的函数,如果值为负数,会进行+arr.length计算转换为正数
  • arr.splice():传入三个参数,可以对原数组进行移除或替换操作,第一个参数为起始位置,第二个参数为要删除/修改的个数,第三个参数是可选的,如果传入则代表替换,没传则代表删除,该方法会返回一个数组,如果移除了元素,就返回被移除元素组成的数组,否则为空数组;
  • arr.sort():对数组进行排序,可以传入一个函数作为参数,该函数有两个参数a(第一个用于比较的元素),b(第二个用于比较的元素),然后返回值应该是一个数字,其正负性表示两个元素的相对顺序,不传参数则会将所有元素转换为字符串,然后按照UTF-16码元顺序比较字符串,并进行排序;
  • arr.reduce():对数组中的每一项执行传入的reducer函数,每次运行会将上次reducer函数的结果作为参数传入,最后将所有的结果汇总成单个返回值

(1)indexOf和includes的区别

  • indexOf是用===来进行判断的,因为NaN不等于任意值,因此无法识别出NaN
  • indexOf无法识别数组中的空槽,而includes会将空槽视为undefined
const arr = [1, NaN, , 3]

console.log(arr.indexOf(NaN)) // -1
console.log(arr.includes(NaN)) // true
console.log(arr.indexOf(undefined)) // -1
console.log(arr.includes(undefined)) // true

因此,如果我们平时想判断某个元素是否在数组中,最好使用includes

(2)详细说一下sort方法

  • sort可以接收一个参数或不接收参数;
  • sort不接收参数时会将不为undefined的数据转换为字符串,然后根据它们的UTF-16 码元值升序排序
  • sort在接收参数时,参数必须是一个函数,且参数有两个参数,分别代表第一个被比较的值和第二个被比较的值,并且参数的返回值必须是一个数字
  • sort允许数组有空槽,排序时,空槽会移动到数组末尾,并排在所有undefined元素之后
  • sort方法在不同浏览器内部实现原理不一样,有的通过快速排序实现,有的通过多种排序算法实现

默认比较行为

const arr = ['你', '好', '啊']
console.log(arr[0].charCodeAt(0)) // 20320
console.log(arr[1].charCodeAt(0)) // 22909
console.log(arr[2].charCodeAt(0)) // 21834
console.log(arr.sort()) // ['你', '啊', '好']

传入参数时比较行为

  • 当我们传参时,假设参数函数中的两个参数为a,b,我们可以直接返回a - bb - a
  • 当返回结果为负数时,a在b前,当返回结果为正数时,a在b后,当结果为0时,保持原位置;
const arr = [3, 1, 2]
arr.sort((a, b) => {
    return a - b
})
console.log(arr)
// output: [1,2,3]

const arr = [3, 1, 2]
arr.sort((a, b) => {
    return b - a
})
console.log(arr)
// output: [3,2,1]

比较特殊类型时

当数组中全都是非NaN和Infinity的数字时,我们可以简单的将返回值设置为a-bb-a,但是如果a和b不是一个数字类型时,排序结果往往就会不正常了,这时候需要我们自己对排序函数进行处理;

const arr = [
    {
      name: 'a',
      age: 3
    },
    {
      name: 'b',
      age: 6
    },
    {
      name: 'c',
      age: 1
    }
]
arr.sort((a, b) => {
    if (a.age > b.age) return 1
    if (a.age === b.age) return 0
    return -1
})
console.log(arr)

// output: [{name: 'c', age: 1}, {name: 'a', age: 3}, {name: 'b', age: 6}]

undefined和空槽的处理

  • sort方法会将undefined移动到末尾;
  • sort方法会将空槽移动到undefined之后;
const arr = [, undefined, , 1, undefined, 3, 2]
console.log(arr.sort())

// output:  [1, 2, 3, undefined, undefined, empty × 2]

(3)详细说一下reduce方法

  • reduce方法接收两个参数,第一个参数是一个函数(callback)第二个参数是默认值( initialValue 可选的)
  • 第一个参数有三个参数,第一个参数是上一次调用该函数的结果(如果是第一次执行,如果设置了默认值,该值就是默认值,没有设置默认值该值就是arr[0]),第二个参数是当前元素的值(如果设置了默认值,第一次执行,该值就是arr[0],如果没有默认值,该值就是arr[1]),第三个参数是调用了reduce的数组
  • reduce的主要作用是对数组每一项执行callback,并将该次执行的结果作为下次callback的参数传递进去
  • 如果调用reduce的数组为空,并且没有默认值,就会抛出异常。

无默认值

const arr = [1, 2, 3, 4, 5, 6]
const res = arr.reduce((prev, next) => {
    return prev + next
})

const strRes = arr.reduce((prev, next) => {
    return prev.toString() + next.toString()
})

console.log(res) // 21
console.log(strRes) // '123456'

无默认值时会将数组第0项作为默认值,并从第1项开始执行callback函数

有默认值

const arr = [1, 2, 3, 4, 5, 6]
const res = arr.reduce((prev, next) => {
    return prev + next
}, 0)

const strRes = arr.reduce((prev, next) => {
    return prev.toString() + next.toString()
}, 0)

console.log(res) // 21
console.log(strRes) // '0123456'

有默认值时会将传入的参数作为默认值,并从第0项开始执行callback函数

(4)如何将一个伪数组转换为数组

对于一个伪数组,可以通过遍历伪数组,将其属性放入一个新数组,或者直接使用Array.from进行转换。

  • Array.from可以对一个可迭代对象类数组对象创建一个新的数组;
  • Array.from可以接收三个参数,第一个参数为要转换的对象,第二个参数为一个函数,该函数可以对第一个参数的每一项进行处理,第三个参数为显式绑定this
function getArguments() {
    return Array.from(arguments, x => x * 2)
}

const arr = getArguments(1, 2, 3, 4, 5, 6)
console.log(arr) // [2, 4, 6, 8, 10, 12]
arr.pop()
console.log(arr) //  [2, 4, 6, 8, 10]

通过Array.fromarguments对象转化成了真正的数组,并且可以使用数组原型上的一些方法

(5)如何对一个多维数组进行降维

可以递归遍历数组,如果发现该项还是一个数组,就递归遍历,将每一项的值放进一个新数组,进行返回。同时我们也可以使用数组的arr.flat方法。

  • arr.flat(num)接收一个参数,该参数决定了数组降维的层级,默认为1,如果值为 Infinity,那么会进行无限层的降维,直至数组的层级为1
const arr = [[1, 2], 3, [6, 7]]
const newArr = arr.flat(1)
console.log(newArr) // [1, 2, 3, 6, 7]

const arr = [[[[[[[[[1]]]]]]]]]
const newArr = arr.flat(Infinity)
console.log(newArr) // [1]

(6)如何对数组进行随机排序

通过sort方法,只需要将返回值的正负性变为随机就好(数据量较大时不推荐)。

const arr = [1, 2, 6, 90, 58, 26]
    arr.sort((a, b) => {
        // Math.random()生成一个[0, 1)的随机数
        // 如果当前生成的随机数>0.5,返回值为正数,反之为负数
        return Math.random() - 0.5
    })
console.log(arr) // [1, 26, 2, 6, 58, 90]

(7)数组的遍历方法有哪些,哪个效率最高

数组的遍历方法有for循环forEachmapfor...infor...of

  • for循环可以通过数组长度进行遍历,然后通过索引进行访问
  • forEach可以直接获取数组每一项的值,并且可以获取索引,还可以改变this指向
  • map不光可以遍历数组的每一项,还可以对进行增删改查返回一个新数组
  • for...in只能遍历出数组的索引,想要获取数组的值,需要通过索引访问
  • for...of只能遍历出数组每一项的值,不能获取到索引;

效率方面for循环 > for...of > forEach > map > for...in

(8)如何退出for循环

  • 普通for循环可以直接使用break或者return进行退出循环;
  • for...of语句可以使用break退出循环,但是不能使用return(会报错)
  • forEach按理来说不能退出循环,在其中使用return时,只是退出当前循环的后续步骤,但还会开启下次循环,但是可以使用抛出异常这种非常规方法终止循环,在外层用try...catch进行错误捕获
  • map不能退出循环,和forEach一样,内部是回调函数,函数内不能写break,如果return只是返回当前值而已
  • for...in语句可以使用break退出循环,但是不能使用return(会报错)

28. Object.defineProperty()

Object.definePropertyjs中非常重要的一个属性,因为它是Vue2框架实现响应式操作的底层原理,它允许精准的添加或修改对象上的属性。

  • Object.defineProperty()方法接收3个参数
  • 第一个参数为要操作的对象
  • 第二个参数为一个字符串或Symbol,必须是这个对象的键名
  • 第三个参数为属性描述符(descriptor),也是最重要的参数;

(1)基本使用

const obj = {
    name: 'Lee'
}

// 给对象添加age属性
Object.defineProperty(obj, 'age', {
    value: 18
})

console.log(obj) // {name: 'Lee', age: 18}

// 修改对象的属性
Object.defineProperty(obj, 'name', {
    value: 'coder'
})
console.log(obj) // {name: 'coder', age: 18}

(2)属性描述符

Object.defineProperty()属性描述符一共有六种,分别为configurableenumerablevaluewritablegetset

它的基本使用就是通过value添加或修改对象的属性值。

configurable

configurable用于控制是否能修改当前对象的属性描述符,默认为false

const obj = {}

Object.defineProperty(obj, 'name', {
    value: 'Lee'
})
console.log(obj) // {name: 'Lee'}

// 下面的方法都会报错
Object.defineProperty(obj, 'name', {
    value: 'coder'
})
Object.defineProperty(obj, 'name', {
    enumerable: true
})
Object.defineProperty(obj, 'name', {
    configurable: true
})
Object.defineProperty(obj, 'name', {
    writable: true
})
Object.defineProperty(obj, 'name', {
    get: function() {
      return this.a
    }
})
Object.defineProperty(obj, 'name', {
    set: function(value) {
      this.a = value
    }
})
  • 它的默认值为false,说明我们默认状态下是不可以修改属性描述符的
  • 即使是只把configurable属性设置为true,也是不可以的,这说明,如果我们想要操作属性描述符,必须在添加数据时就将configurable属性设置为true

enumerable

enumerable可以控制当前键名是否作为对象的可枚举属性(是否能被for...in/Object.keys()/展开运算符等遍历出来),默认值为false

const obj = {}

Object.defineProperty(obj, 'name', {
    value: 'Lee',
    configurable: true
})
console.log(obj) // {name: 'Lee'}

for (let key in obj) {
    console.log(key) // 空,因为此时name属性不是可枚举的
}

Object.defineProperty(obj, 'name', {
    enumerable: true // 将name属性变为可枚举属性
})

for (let key in obj) {
    console.log(key) // 可以被for...in遍历
}

writable

writable可以控制当前属性是否可写(即对该属性进行赋值操作),默认为false,该属性只可读不可写

const obj = {}

Object.defineProperty(obj, 'name', {
    value: 'Lee',
    configurable: true
})

obj.name = 'coder'

console.log(obj.name) // Lee,此时name是不可写的,因此进行赋值操作无效

Object.defineProperty(obj, 'name', {
    writable: true // 设置name属性为可写属性
})

obj.name = 'coder'

console.log(obj.name) // coder

get

get是属性描述符中最重要的两个属性之一,它是用作getter属性一个函数,默认值为undefined,访问被操作的属性时,会触发get函数,并且将this设置为操作的对象(当然,这是get函数没有使用箭头函数的前提下),get函数的返回值作为该属性的值

const obj = {
    objName: 'obj'
}

Object.defineProperty(obj, 'name', {
    configurable: true
})

Object.defineProperty(obj, 'name', {
    get: function () {
      console.log(this) // {objName: 'obj'}
      return '我不叫Lee'
    }
})

console.log(obj.name) // '我不叫Lee'

明明我们没有设置namevalue属性,但是访问时,却变成了get函数的返回值,get函数的作用就是如此,我们一旦设置了get属性描述符get函数的返回值就是我们访问该属性的值(无论我们将它赋值成什么)

set

set是属性描述符中另一个重要的属性,它和get类似,只不过它是作用于setter属性一个函数,默认值也是undefined,当我们给属性进行赋值操作时set函数会被触发,它会接收一个参数,这个参数就是我们要进行赋值操作的值

const obj = {
    objName: 'obj'
}

Object.defineProperty(obj, 'name', {
    configurable: true
})

Object.defineProperty(obj, 'name', {
    get: function () {
      return this.copyName
    }
})

Object.defineProperty(obj, 'name', {
    set: function (value) {
      console.log(this)
      this.copyName = value
    }
})

obj.name = 1
console.log(obj.name)

当我们对obj.name进行赋值操作时,打印出来了this对象,和get函数一样this指向当前操作的对象,我们可以通过监听给对象某个属性赋值,去改变对象中其他值,然后通过get函数被外界访问,从而替代valuewritable描述符。

(3)数据描述符和访问器描述符指的是什么

  • Object.defineProperty()的第三个参数为属性描述符,属性描述符可分为数据描述符访问器描述符
  • 访问器描述符只有getset,其它四个为数据描述符
  • 当描述符不具备value、writable、get、set中的任何一个(因为valuewritable时可选的,描述符只有configurableenumerable属性时),它一定就是数据描述符
  • 描述符不可以同时拥有(value或writable)和(get或set)(也就是说value不能和get、set同时出现,其它同理),否则会报错;
const obj = {
    objName: 'obj'
}

Object.defineProperty(obj, 'name', {
    value: 'Lee',
    configurable: true,
    get: function (value) {
      return this.objName
    }
})

// Uncaught TypeError: Invalid property descriptor. Cannot both specify accessors and a value or writable attribute, #<Object>
// 无效的属性描述符。不能同时指定访问器和值或可写属性,

29. Object.defineProperties()

  • Object.defineProperties()方法其实就是多个Object.defineProperty()的简便写法;
  • Object.defineProperty()只允许我们对某个对象的某一个属性进行操作,而Object.defineProperties()允许我们传入一个对象,对该对象的所有属性分别进行操作。
const obj = {}
Object.defineProperties(obj, {
    name: {
      value: 'Lee'
    },
    age: {
      value: 18
    }
})

console.log(obj) // {name: 'Lee', age: 18}

30. Object.defineProperty()对数组对象的监听

const obj = {}
Object.defineProperty(obj, 'arr', {
    configurable: true,
    get: function () {
      return this.copyArr
    },
    set: function (value) {
      console.log(value)
      this.copyArr = value
    }
})

obj.arr = [1] // [1]
obj.arr.push(2) // 空
obj.arr = [1, 3] // [1, 3]
obj.arr.splice(0, 1) // 空

由此可见,当我们使用Object.defineProperty()访问器描述符监听数组时,数组的赋值我们是可以正确监听并处理的,但是调用数组原型上的一些方法(如pop、push、shift等)时,set属性描述符是无法监听到的,这也是为什么在vue2中vue内部重写了部分数组原型的方法

31. Object.defineProperty()的作用和意义

Object.defineProperty()可以让我们更加灵活的去操作一个对象,可以限制对象的属性不被外部访问或修改,让一些属性可以变得更加安全,更加不容易被污染,于此同时,我们还可以通过其中的访问器描述符拦截赋值和读取操作,并按照开发者的意愿对拦截之后的结果进行一些灵活的处理,能够完成更多复杂的操作

32. Proxy

Proxy 对象用于创建一个对象(对象、数组、函数、甚至另一个代理对象)的代理,从而实现基本操作的拦截自定义(如属性查找、赋值、枚举、函数调用等)。

Proxy一共有两个参数

  • target:是proxy的第一个参数,代表被代理的对象
  • handler:一个通常以函数作为属性的对象,里面的每个属性分别代表了各种行为,目前handler一共有13个属性

(1)handler.apply()

apply被用于函数劫持,该函数一共有三个参数

  • target:被代理的对象;
  • thisArg:被调用时的上下文对象(this);
  • argumentsList:被调用时的传参列表;
function myFun(name) {
    console.log(name)
}

const myFunProxy = new Proxy(myFun, {
    apply(target, thisArg, argArray) {
      console.log(target === myFun) // true
      console.log(thisArg) // {name: 'obj1', fun: Proxy(Function)}
      console.log(argArray) // ['Lee', 'Lee1']

      console.log(argArray[0] + 'proxy') // Leeproxy
    }
})

const obj1 = {
    name: 'obj1',
    fun: myFunProxy
}

obj1.fun('Lee', 'Lee1')

从代码得知,使用Proxy代理myFun函数时,会返回一个新的函数,调用这个新的函数时,会被handler种的apply属性拦截,并将原函数、调用代理函数时的this指向、调用代理函数时的传参通过参数的形式放入apply函数中

(2)handler.construct()

construct用于拦截new操作符,因此target必须是可以使用new操作符的,也就是说target最好是一个构造函数或者类,它一共有两个参数

  • target:被代理的对象;
  • argumentsList:调用代理对象时传入的参数列表;
function MyFun(name) {
    this.name = name
}

const MyFunProxy = new Proxy(MyFun, {
    construct(target, argArray) {
      console.log(target === MyFun)
      console.log('在执行构造函数之前做了一些操作')
      return new target(...argArray)
    }
})

const obj = new MyFunProxy('Lee')
console.log(obj)

// output: true -> 在执行构造函数之前做了一些操作 -> {name: 'Lee'}

construct的作用就是可以拦截我们的new 操作符,让我们在执行new之前,做一些其它的操作。

(3)handler.defineProperty()

defineProperty用于拦截对象的defineProperty操作,它的返回值必须是一个Boolean类型,用来表示是否成功操作代理对象。该方法一共有三个参数,三个参数和Object.defineProperty()的三个参数相同。

  • targetObject.defineProperty()中代表要操作的对象,而这里代表被代理的那个对象
  • property:对象的键名
  • descriptor:属性描述符;
const obj = {}

const objProxy = new Proxy(obj, {
    defineProperty(target, key, descriptor) {
      console.log(target === obj)
      console.log(`对代理对象的${key}操作了`)
      return Object.defineProperty(target, 'name', descriptor)
    }
})

Object.defineProperty(objProxy, 'name', {
    value: 'Lee',
    configurable: true,
    enumerable: true
})

console.log(obj)

// output: true -> 对代理对象的name操作了 -> {name: 'Lee'}

(4) handler.deleteProperty()

deleteProperty用于拦截对象的delete操作,它有两个参数:

  • target:被代理的对象;
  • property:待删除的属性名;
const obj = {
    name: 'Lee',
    age: 18
}

const objProxy = new Proxy(obj, {
    deleteProperty(target, key) {
      console.log(target === obj)
      console.log(`要删除对象的${key}了`)
      return delete target[key]
    }
})

delete objProxy.age

console.log(obj)

// output: true -> 要删除对象的age了 -> {name: 'Lee'}

(5)has()

has用于拦截in操作符,它有两个参数

  • target:被代理的对象;
  • property:in操作符之前的键名;
const obj = {
    name: 'Lee'
}

const objProxy = new Proxy(obj, {
    has(target, key) {
      console.log(target === obj)
      console.log(`判断key值:${key}是否为对象的属性`)
      return key in target
    }
})

console.log('name' in objProxy)

// output: true -> 判断key值:name是否为对象的属性 -> true

(6)get()

get用于拦截访问操作,对代理对象进行访问时,会触发get,它有三个参数

  • target:被代理的对象;
  • property:访问的键名;
  • receiver:Proxy或继承Proxy的对象;

它有以下约束,否则会抛出异常:

  • 如果要访问的目标属性是不可写以及不可配置的,则返回的值必须与该目标属性的值相同
  • 如果要访问的目标属性没有配置访问方法,即 get 方法是 undefined 的,则返回值必须为 undefined
const obj = {}

const objProxy = new Proxy(obj, {
    get(target, key, receiver) {
      console.log(target === obj)
      console.log(receiver === objProxy)
      return 1
    }
})

objProxy.name = 'Lee'

console.log(objProxy.name) 

// output: true -> true -> 1

我们发现Proxy中的handler.get方法Object.defineProperty()中的get属性描述符作用相似,都是可以拦截访问操作,并以get方法最终的返回值作为访问的值。

约束:

const obj = {}

Object.defineProperty(obj, 'name', {
    configurable: false,
    enumerable: false,
    writable: false,
    value: 'Lee'
})

// const objProxy = new Proxy(obj, {
//   get(target, key, receiver) {
//     return 1
//   }
// })

const objProxy = new Proxy(obj, {
    get(target, key, receiver) {
      return target[key]
    }
})

console.log(objProxy.name)

// 第一种情况:Uncaught TypeError: 'get' on proxy: property 'name' is a read-only and non-configurable data property on the proxy target but the proxy did not return its actual value (expected 'Lee' but got '1')

// 第二种情况:Lee

(7)set()

set用于拦截对象或对象原型链上某个属性的赋值操作,它需要返回一个Boolean类型的否返回值,用于代表是否赋值成功。它有四个参数

  • target:被代理的对象;
  • property:被赋值的键名;
  • value:将被赋值的新属性值;
  • receiver:Proxy对象或继承自Proxy对象的对象;

它拥有以下约束:

  • 若被代理的对象是一个不可写并且不可配置的,那么不能改变它的值;
  • 如果赋值的属性,它的属性描述符set是一个undefined,那么不能设置它的值;
  • 严格模式下,如果set()方法返回false,会抛出一个异常;
const obj = {}
const objProxy = new Proxy(obj, {
    set(target, key, value, receiver) {
      console.log(target === obj)
      console.log(receiver === objProxy)
      target[key] = value
      return true
    }
})

objProxy.name = 'Lee'
console.log(objProxy.name) 

// output: true -> true -> Lee

约束:

const obj = {}

Object.defineProperty(obj, 'name', {
    configurable: false,
    enumerable: false,
    writable: false
})

const objProxy = new Proxy(obj, {
    set(target, key, value, receiver) {
      target[key] = value
      return true
    }
})

objProxy.name = 'Lee'
console.log(objProxy.name)

// output: Uncaught TypeError: 'set' on proxy: trap returned truish for property 'name' which exists in the proxy target as a non-configurable and non-writable data property with a different value
const obj = {}

Object.defineProperty(obj, 'name', {
    configurable: true,
    set: undefined
})

const objProxy = new Proxy(obj, {
    set(target, key, value, receiver) {
      target[key] = value
      return true
    }
})

objProxy.name = 'Lee'
console.log(objProxy.name) // undefined 设置了也不管用

(8)其它方法

除此之外,还有其它6种方法,它们分别是:

  1. handler.getOwnPropertyDescriptor():它是拦截Object.getOwnPropertyDescriptor()(返回对象某个属性的属性描述符)操作的;
  2. handler.getPrototypeOf():它是拦截Object.getPrototypeOf()方法的,只有当获取代理对象的原型时才会被触发
  3. handler.isExtensible():用于拦截对对象的Object.isExtensible()(是否可以在对象上添加属性)方法,
  4. handler.ownKeys():用于拦截 Reflect.ownKeys()方法;
  5. handler.preventExtensions():用于拦截Object.preventExtensions()(防止该对象被扩展或者改变原型)方法;
  6. handler.setPrototypeOf():主要用来拦截 Object.setPrototypeOf()(设置对象原型)方法;

具体的使用场景可参考MDN

33. Reflect

Reflect 是一个JavaScript内置的对象,它提供拦截JavaScript操作的方法。这些方法与proxy handler的方法相同Reflect 不是一个函数对象,因此它是不可构造的(不能使用new操作符)。

Reflect和Proxy的异同

  • Reflect对象也有13种方法,并且方法名和Proxy相同;
  • Proxy的handler中的13种方法是一个个函数,函数的返回值需要我们设置,而Reflect中的13种方法,我们只需传入相应的参数,它会自动返回对应的值
  • Proxy的handler方法中,如果我们返回值设置的不当,或者代理的对象不符合条件,可能系统会直接给我们抛出异常,我们必须去捕获这些异常,才能让后续的代码继续执行,而Reflect方法正好弥补了这一点,面对一些不符合的场景,它会直接帮助我们返回false,并且Reflect方法种的返回值正好对应了Proxy方法中需要的返回值,因此我们可以在Proxy的handler方法中返回一个Reflect对象相应的方法,去获取返回值

Reflect中的get/set

Reflect中有两个属性很重要,就是get和set

  • get类似于target[key]访问对象属性,不过它是以函数返回值的方式返回的;
  • set类似于target.ket = value给对象赋值,不过它也是以函数的方式进行赋值,并返回是否赋值成功的一个Boolean值
  • get/set函数的最后一个参数receiver可选的,如果target对象设置了getter/setter函数,那么receiver就作为调用getter/setter函数时的this
const obj = {
    name: 'Lee'
}

console.log(obj.name) // Lee
console.log(Reflect.get(obj, 'name')) // Lee

obj.age = 18
Reflect.set(obj, 'age', 19) // 等同于 obj.age = 19,覆盖了obj.age = 18
console.log(obj.age) // 19

Reflect中get/set的receiver参数

没有传入receiver时:

const obj = {}
      
Object.defineProperty(obj, 'name', {
    configurable: true,
    get: function () {
      console.log('当前的this为:', this) // 当前的this为: {name: undefined}
    }
})

Reflect.get(obj, 'name')

默认情况下,get属性描述符中的this就是当前操作的对象,即obj对象

传入receiver时:

const obj = {}

const obj1 = {
    name: 'obj1'
}

Object.defineProperty(obj, 'name', {
    configurable: true,
    enumerable: true,
    get: function () {
      console.log('当前的this为:', this) // {name: 'obj1'}
    }
})

Reflect.get(obj, 'name', obj1)

此时get属性描述符中的this就是传入的receiver对象。

也就是说,receiver参数的作用就是,在对象的属性设置了get/set属性描述符时,可以给get/set属性描述符设置this

它有什么用呢?

如果有以下场景:

  • 原对象的某个属性a设置了属性描述符get
  • 原对象创建了一个代理对象,代理对象设置了handler.get方法;
  • 访问代理对象的a属性时,会去查看原对象的a属性值,此时触发原对象的getter函数

代码如下:

const obj = {}

Object.defineProperty(obj, 'name', {
    configurable: true,
    enumerable: true,
    get: function () {
      console.log('当前的this为:', this)
    }
})

const objProxy = new Proxy(obj, {
    get(target, key, receiver) {
      return target[key]
    }
})

objProxy.name
obj.name

// output: 
// 当前的this为: {}
// 当前的this为: {}

我们发现,不管是通过访问代理对象还是原对象触发的getter函数,最终getter函数的this都指向原对象,如果我们想在访问代理对象时,getter函数中的this指向代理对象呢? 这时候就可以用Reflect.get去实现

const obj = {}

Object.defineProperty(obj, 'name', {
    configurable: true,
    enumerable: true,
    get: function () {
      console.log('当前的this为:', this)
    }
})

const objProxy = new Proxy(obj, {
    get(target, key, receiver) {
      return Reflect.get(target, key, receiver)
    }
})

objProxy.name
obj.name

// output: 
// 当前的this为: Proxy(Object) {}
// 当前的this为: {}

此时我们代理对象的handler.get方法不再直接通过访问原对象去获取值了,而是通过Reflect去帮助我们获取原对象的值,并且把receiver传递过去,作为原对象getter方法的this

34. 如何遍历一个对象的属性

  • 可以使用for...in循环;
  • 可以使用Object.keys(obj).forEach()实现遍历;
  • 可以使用Object.values(obj).forEach(),不过该方法只能遍历每一个属性的值
  • 可以使用Reflect.ownKeys()实现遍历;
  • 可以使用Object.entries(),它返回一个二维数组,数组的每一项为对象的每一个属性的key、value组成的数组
  • 可以使用Object.getOwnPropertyNames()和Object.getOwnPropertySymbols()分别遍历所有非Symbol的key和所有Symbol的key

35. 如何防止对象的值不被修改

  • 可以使用Object.freeze()冻结一个对象;
  • 可以使用Object.defineProperty()给对象每个属性变为不可写的

36. 0.1+0.2为什么不等于0.3

JavaScript中,不只是0.1+0.2,还有其他很多运算场景下,都会发生精度丢失的问题,而产生这种现象的根本原因就是所有计算在计算机层面来说都是二进制计算

我们先来看一下十进制的小数是怎么转化成二进制的

它的转化过程是这样的:

  • 先将小数的整数位取出,然后进行/2操作,得到商和余数;
  • 再用商 / 2,再次得到商和余数,直至商为0,此时所有余数拼接起来,就是整数部分;
  • 再将小数的小数位取出,然后进行* 2操作,得到积;
  • 再将积的整数位取出,然后继续用积的小数位 * 2,再次得到一个积;
  • 重复上一步操作,直至小数位为0,此时所有的整数位就是小数部分转化为二进制的值;

使用0.625举例

  • 整数位为0,/2的商直接为0,余数为0;
  • 小数位为0.625,进行*2操作,变为1.25,取出整数部分1;
  • 此时小数位为0.25,进行*2操作,变为0.5,取出整数部分0;
  • 此时小数位为0.5,进行*2操作,变为1.0,至此结束,取出整数部分1;

那么0.625转化为二进制就是0.101

那么0.2转化为二进制呢?它的结果是一个0.0011001100110011...无限循环,而0.3呢?它的结果是一个0.0100110011001100...也是无限循环,那它们两个值相加,肯定就不是0.3了,而是一个无限长度的小数

37. eval是做什么的?

eval会将字符串以js脚本的方式执行,在一些框架或者库的源码中,就有很多使用eval的场景。

  • 传入参数字符串时:会当作js脚本执行,如果字符串是一个算数表达式,那么则会返回这个表达式的结果
  • 传入的参数不是一个字符串时:会将参数原封不动的返回;
eval("console.log('eval')") // eval
console.log(eval('1 + 2 + 3')) // 6
console.log(eval(1)) // 1

38. AJAX、XHR、Fetch是什么

(1)AJAX

AJAX(Asynchronous JavaScript And XML)是一种使用XMLHttpRequest技术构建更复杂,动态的网页的编程实践。

AJAX 允许只更新一个HTML页面的部分[DOM],而无须重新加载整个页面。AJAX 还允许异步工作,这意味着当网页的一部分正试图重新加载时,你的代码可以继续运行(相比之下,同步会阻止代码继续运行,直到这部分的网页完成重新加载)。

MDN对AJAX的介绍

(2)XHR

XHR就是XMLHttpRequest对象,主要用于跟服务器交互,虽然名称是这样,但是它可以获取任何类型的数据,并且除了HTTP协议外还支持其它协议。

MDN对XMLHttpRequest的介绍

(3)Fetch

Fetch已经在逐渐取代AJAX,全局的 fetch()方法用于发起获取资源的请求。它返回一个 promise,这个 promise 会在请求响应后被 resolve,并传回 Response对象。

MDN对fetch的介绍

39. 令a == 1 && a == 2 && a == 3成立

重写对象的Symbol.toPrimitivevalueOftoString方法。

const a = {
    num: 1,
    [Symbol.toPrimitive]: function () {
      return this.num++
    }
    // valueOf: function () {
    //   return this.num++
    // },
    // toString: function () {
    //   return this.num++
    // }
}

console.log(a == 1 && a == 2 && a == 3) // true

40. 尾递归优化你了解吗?

尾递归优化就是在进行递归操作时,递归函数的最后一步是调用本身,而没有其他任何操作

尾递归优化依赖于js引擎,在ES6中提出了尾递归优化,但是还有很多浏览器没有进行适配

尾递归优化例子

// 求阶乘
function factorial(num) {
    if (num <= 0) return 1
    return num * factorial(num - 1)
}

function newFactorial(num, prevRes) {
    if (n === 0) return prevRes
    return newFactorial(num - 1, num * prevRes)
}

在第一个函数中,每次递归时还有一步*num的运算,而第二个函数,最后一步只是调用自身,并没有其他操作。这种方式就可以使用尾递归优化

尾递归优化了什么

来看第一个函数的执行过程:

  • 创建全局上下文,函数调用栈中加入全局上下文;
  • 执行factorial函数,函数调用栈加入factorial上下文;
  • 执行到最后一步,为num * factorial(num - 1),这时候需要计算factorial(num - 1)的值,于是函数调用栈中又加入一个factorial上下文
  • 如果我们传入的num是一个很大的数,此时为了等待返回值,函数调用栈可能已经创建了很多函数执行上下文了,最终就会爆栈,爆栈溢出的错误;

而如果我们使用了尾递归优化,当递归函数的最后一步只调用自身时,执行到return时,函数调用栈会将当前上下文移除,只把返回值保存起来,然后加入新的函数执行上下文,每执行一次都会弹出,然后换新的进来,这样一来,我们的函数调用栈永远就只会有全局上下文和当前活跃的函数上下文了,就可以有效的防止栈溢出问题

如何开启尾递归优化

因为目前许多浏览器还没有对尾递归进行优化,就算有进行优化的也只在严格模式下生效,于是想让浏览器进行尾递归优化,必须要开启严格模式

function optimizeFactorial(num) {
    // 执行严格模式
    "use strict";
    function newFactorial(num, prevRes) {
      if (n === 0) return prevRes
      return newFactorial(num - 1, num * prevRes)
    }
    return newFactorial(num, 1)
}

41. V8引擎的隐藏类你了解吗?

隐藏类v8引擎对对象访问速度以及内存空间所做的一个优化。

js是一门动态语言,之所以性能不如静态语言,是因为像静态语言在创建一个对象时,它会先创建一个,然后对象的结构和属性就已经被固定了,而js不同,比如我们通过js创建一个对象obj,一开始给了它a、b两个属性,但是突然我们想往里面加一个c属性,可能我们直接使用obj.c = xxx就给它赋值了,这种行为其实是不好的,因为它会影响V8所做的隐藏类的优化

隐藏类,顾名思义,一个“隐藏”的类,在我们编写代码的过程中,是看不到的,但是在V8解析我们的代码时,它会帮助我们的对象生成一个类。比如:

const obj = {
    name: 1,
    age: 2
}

// v8解析时创建一个隐藏类
function Hidden(name,age) {
    this.name = name
    this.age = age
}

隐藏类有什么用

  1. V8为一个对象创建了一个隐藏类只会,V8就会默认这个对象的属性和结构已经固定了,不会再改变,因此它会记录每一个属性以及对应属性在对象中的偏移量(索引),当我们访问对象的属性时,V8会先去查找隐藏类,然后查看当前属性在对象属性中的索引,直接找到该属性在内存的位置(知道了索引就不用遍历对象键名一个个比对是不是当前要查找的键名了),然后就可以很快速的给到我们结果,这就是隐藏类最大的作用之一,提升访问效率

  2. 两个对象的属性和结构相同时,V8会让两个对象共用一个隐藏类,这样就不会声明多余的隐藏类,节省了内存的使用。

  3. 代码内联缓存:当一个属性或方法被调用时,V8会先查看调用对象的结构,查看之前是否访问过相同结构的隐藏类对象,如果有的话,就会使用之前记录的访问路径,以达到快速访问的目的。

const obj = {
    name: 1,
    age: 2
}
const obj1 = {
    name: 1,
    age: 2
}
const obj2 = {
    age: 2,
    name: 1
}

在上述代码中,obj和obj1共用一个隐藏类,因为它们属性排布顺序以及属性名都是一样的,而obj和obj2就是两个不同的隐藏类,虽然它们属性相同,但是结构不同(一个是name在上面一个是age在上面)

我们应该做什么

既然V8引擎帮助我们实现了隐藏类,来对我们的性能进行优化,那我们应该合理利用这一点,在编写代码时尽量做到以下几点:

  1. 创建对象时一次性声明所有属性,不要频繁往对象里新增属性
  2. 创建相同属性的对象时,保证属性的顺序是一致的
  3. 不要删除对象的属性

42. 数组去重可以怎么做?

  • 数组去重可以创建新数组,然后遍历数组,然后判断每一项是否存在于新数组中,不存在就添加进去,否则就继续判断下一项,最后返回新数组
  • ES6之后可以利用Set数据结构的特性,对数组进行去重,将数组每一项添加到Set中,然后通过Array.fromSet转换为数组,返回这个数组。

43. 可以说说不同版本的ECMA标准都增加了哪些特性吗?

我只是列举出来了一些常用的,具体全一点的大家可以去网上找相关内容去看。

(1)ES6(es2015)

ES6是属于一个跨度比较大的阶段,诞生了如class箭头函数Promiselet和const展开运算符模板字符串块级作用域模块化等。

(2)ES7(es2016)

ES7新增了数组的includes方法求幂运算等。

(3)ES8(es2017)

ES8新增了async/awaitObject.values()Object.entries()字符串补全参数列表支持尾逗号Object.getOwnPropertyDescriptors()等;

(4)ES9(es2018)

ES9新增了for-await-of异步迭代Promise.finally()Rest/Spread解除模板字符串限制(Lifting template literal restriction)等;

(5)ES10(es2019)

ES10新增了可选的catch参数函数的toString()方法消除前后空格(trimStart()、trimEnd())数组的flat、flatMap方法JSON超集JSON.stringify()加强格式转化Object.fromEntriesSymbol.prototype.description等;

(6)ES11(es2020)

ES11新增了BigInt可选链?.全局对象Promise.allSettled??空值合并模块新特性(import动态导入)String.prototype.matchAll(匹配所有)等;

(7)ES12(es2021)

ES12新增了WeakRefsPromise.any逻辑赋值运算符数字分隔符String.prototype.replaceAll等;

(8)ES13(es2022)

ES13新增了Class Static Block(类静态块)顶层awaitObject.hasOwn().at()方法返回指定索引元素私有字段检查类的私有属性class fields异常链(给Error构造函数新增options)等;

(9)ES14(es2023)

ES14新增了findLast()findLastIndex从尾部查找、Hashbang语法(想执行js文件需要在控制台输入node xx.js)

44. parseInt的参数

parseInt:解析一个字符串,并返回指定基数的十进制整数

它一共有两个参数

  • 第一个参数(string):是一个字符串,需要被解析的值;
  • 第二个参数(radix):指定的基数,为2-36的整数,表示进制的基数,比如2,就是以二进制解析,16就是以16进制解析,如果不在这个返回,就会返回NaN,如果没有传参,就会以第一个参数的值进行推算

需要注意的点:

  • 它是先将传入的字符进行指定基数的转化,然后再将转化后的字符转化成十进制
  • 如果第一个字符不能转换为数字,parseInt 会返回 NaN
  • 如果解析过程中遇到的字符不是指定基数中的数字,比如parseInt('abcde',8),parseInt解析到a,发现a不是8进制中的数字,就会停止a之后所有字符的解析,返回值只会返回已解析的那部分字符的结果,在这个例子中,没有被解析的字符,因此返回NaN
  • 某些数字在字符串表示形式中会使用e字符,因此对特别大或特别小的数字使用parseInt时,会发生意想不到的结果;
  • 使用parseInt转换BigInt类型的数据时,有可能发生精度丢失问题,尾部的n也会丢失;
  • 如果radixundefined、0或者没有指定,会根据以下情况推导:
    • 如果第一个参数以0x开头,会默认radix = 16
    • 如果第一个参数以0开头,会默认radix = 8或10ES5中规定了默认应该使用10,但是不是所有浏览器都遵循规范,所以在使用parseInt时,最好给一个radix值
    • 如果第一个参数以其他任何值开头,都会默认radix = 10

45. 写一个防抖函数

防抖函数的作用是对于那些频繁点击或频繁输入的场景,我们不想让事件短时间内触发多次,只想以用户第一次操作或短时间内最后一次操作为准。这时候我们就可以用到防抖函数。

比如有一个场景,用户的输入,我们通常只需要拿到用户最终的输入结果,再去进行相应的操作,至于他输入过程输入了什么,我们毫不关心,如果将他的每一次输入都进行了处理,无疑是浪费资源的。

但是如果用户输入了很长时间,比如输入的过程长达5分钟,如果我们等待用户输入完再进行处理,有可能页面在5分钟之内都处于空白状态,这种场景也是不合理的。

综上所述,防抖函数的实现过程应该为:

  • 用户触发函数;
  • 判断是否为第一次触发函数,如果第一次触发函数,执行一次,并且开始计时,计时期间内,函数不会执行
  • 第二次触发函数,判断当前是否在计时状态,如果在,清除计时状态,重新开始计时,如果不在,视为第一次触发
  • 计时完成,触发一次,下次触发视为第一次触发

于是我们给防抖函数规定以下参数

  • fn:作为防抖函数的第一个参数,它是一个函数,也是我们本来需要调用的函数
  • delay:延迟时间,它用来控制用户在多长时间内的输入只会被当作一次记录,比如设置1000,则代表用户在1000毫秒内只统计最后一次
  • immediate:是否立即执行,如果为true,那么在用户第一次触发防抖函数时就会执行一次。
function debounce(fn, delay = 800, immediate = true) {
    // 设置定时器
    let timer = null
    // 记录函数现在有没有处于延时状态
    let flag = false
    // 返回一个函数,调用debounce函数时,实际调用的是该函数
    return function (...args) {
      // 如果当前不处于延时状态,说明现在没有等待执行或正在执行的任务,可以立马执行。并          且需要立马执行一次;
      if (immediate && !flag) {
        // 调用fn函数,并且将this、参数传递给fn
        fn.apply(this, args)
      }
      if (timer !== null) {
        // 如果此时有正在等待执行的任务,清除该任务
        clearTimeout(timer)
        timer = null
      }

      // 此时进入延时状态
      flag = true
      timer = setTimeout(function () {
        fn.apply(this, args)
        // 延时任务执行完毕,结束延时状态
        flag = false
      }, delay)
    }
}

46. 写一个节流函数

节流防抖函数最大的区别在于,防抖函数一般只关心最后一次的结果,而节流函数只关心一段时间内你只能触发一次,就拿用户输入来说,不管你正在输入,还是已经输入完了,我只关心当前时间点是否可以被触发

因此节流函数的执行过程应该是:

  • 设置一个时间段,两次触发间隔大于这个时间段,就可以被触发,否则不可以;
  • 第一次触发,执行函数,记录当前触发时间;
  • 第二次触发,判断时间间隔,满足条件则再次触发,否则不触发;
  • 接下来每次触发事件,都进行时间间隔判断

于是我们给节流函数规定以下参数

  • fn:作为节流函数的第一个参数,它是一个函数,也是我们本来需要调用的函数
  • delay:延迟时间,它用来控制用户在两次操作距离多长时间才会生效,比如设置1000,则代表用户在第一次进行操作后,1000毫秒之后才会触发第二次操作
  • immediate:用于控制第一次触发节流函数时是否需要立即执行;
  • last:用于控制用户停止操作后,节流函数是否需要将最终结果再处理一次;
function throttle(fn, delay = 800, immediate = true, last = false) {
    // 如果需要第一次执行,默认时间设为0,以便第一次执行时,时间间隔可以>delay
    let prevTime = immediate ? 0 : Date.now()
    // 设置一个记录定时器变量的timer
    let timer = null
    return function (...args) {
      // 获取当前时间
      const nowTime = Date.now()
      // 如果当前时间 - 上次执行函数的时间 >= 时间间隔,就可以执行函数
      if (nowTime - prevTime >= delay) {
        fn.apply(this, args)
        // 将上一次时间改为这次执行的时间
        prevTime = nowTime
      }
      // 如果最后一次也要执行
      if (last) {
        // 判断前面有没有开启的定时器,有的话就清除
        if (timer !== null) {
          clearTimeout(timer)
          timer = null
        }
        // 没有就赋值, 延迟delay之后继续执行一次函数
        timer = setTimeout(() => {
          fn.apply(this, args)
        }, delay)
      }
    }
}

47. 写出一个new方法

function myNew(fn, ...args) {
    // 首先会创建一个空对象
    // const obj = Object.create(null)
    // 将对象的__proto__指向构造函数的prototype
    // obj.__proto__ = fn.prototype

    // 上面两步可以合并为一步
    const obj = Object.create(fn.prototype)
    // 调用构造函数, 并且将this绑定为该对象, 将参数传进去
    const result = fn.apply(obj, args)
    // 判断构造函数有没有返回其他对象, 如果没有就返回空对象, 如果有,就返回构造函数返回        的对象
    return typeof result === 'object' ? result : obj
}

48. 实现一个call方法

首先我们搞清楚,调用call方法时,发生了什么:

  • 会执行调用call方法的函数
  • 会将调用call方法的函数this显式绑定为call方法的第一个参数
  • call方法面的参数会作为调用函数的参数传递过去;
  • 如果call方法的第一个参数为nullundefined,在非严格模式下会转为全局对象,如果是原始值,还会转换为对象;
Function.prototype.myCall = function (thisArg, ...args) {
    // 判断调用该方法的是不是函数,不是函数直接抛出异常
    if (typeof this !== 'function') {
      console.log('请使用函数调用该方法')
      return
    }
    // 判断传入的this是否为undefined、null, 如果是则转换为Window
    if (thisArg === null || typeof thisArg === undefined) {
      thisArg = window
    }
    // 如果是个原始值,转换为对象
    if (
      typeof thisArg !== 'object' &&
      typeof thisArg !== 'function' &&
      typeof thisArg !== undefined
    ) {
      thisArg = Object(thisArg)
    }
    // this就是当前函数,因为我们调用call时通常是fun.call(),会将call的this隐式绑定为        该函数
    thisArg.fn = this
    // 通过thisArg调用该函数,将该函数的this隐式绑定为thisArg
    const res = thisArg.fn(...args)
    // 删除thisArg上多余的fn属性
    delete thisArg.fn
    return res
}

const obj = {
    name: 'obj'
}

function myFun() {
    console.log(this.name)
}

myFun.myCall(obj) // obj

49. 实现一个apply方法

applycall类似,只不过参数以数组传递

Function.prototype.myApply = function (thisArg, args) {
    // 判断调用该方法的是不是函数,不是函数直接抛出异常
    if (typeof this !== 'function') {
      console.log('请使用函数调用该方法')
      return
    }
    if (thisArg === null || typeof thisArg === undefined) {
      thisArg = window
    }
    if (
      typeof thisArg !== 'object' &&
      typeof thisArg !== 'function' &&
      typeof thisArg !== undefined
    ) {
      thisArg = Object(thisArg)
    }
    thisArg.fn = this
    const res = thisArg.fn(...args)
    delete thisArg.fn
    return res
}

const obj = {
    name: 'obj'
}

function myFun() {
    console.log(this.name)
}

myFun.myApply(obj) // obj

50. 实现一个bind方法

bind方法的使用方法是怎样的?

  • 它会返回一个新函数
  • 它的传参方式类似call
  • 返回的新函数依然可以传参;
  • 返回的新函数可以使用new操作符,因此返回的函数必须能使用new操作符,并且实现原构造函数的功能。
Function.prototype.myBind = function (thisArg, ...args) {
    // 判断调用该方法的是不是函数,不是函数直接抛出异常
    if (typeof this !== 'function') {
      console.log('请使用函数调用该方法')
      return
    }
    if (thisArg === null || typeof thisArg === undefined) {
      thisArg = window
    }
    if (
      typeof thisArg !== 'object' &&
      typeof thisArg !== 'function' &&
      typeof thisArg !== undefined
    ) {
      thisArg = Object(thisArg)
    }
    // 返回值要能被new, 就不能是箭头函数, 不是箭头函数就需要把当前的this保存, 以便在新函数中能拿到当前this
    const _this = this
    return function backFn(..._args) {
      // 如果外部通过new操作符调用了该函数, 那么new的过程中, 执行一遍backFn函数,并且绑定this, backFn如果在显式绑定的this的原型链上, 说明正在执行new操作, 将原函数new的结果返回

      // 如果调用了new backFn()
      // new的过程中会有一步 backFn.call('显式绑定的this(也就是即将通过new backFn()生成的对象)')
      if (this instanceof backFn) {
        return new _this(...args, ..._args)
      }
      return _this.apply(thisArg, [...args, ..._args])
    }
}

51. 实现一个Promise

const PENDING = "pending";
const RESOLVE = "fulfilled";
const REJECT = "rejected";

function toCheckTryCatch(fn, value, resolve, reject) {
  try {
    const result = fn(value);
    resolve(result);
  } catch (err) {
    reject(err);
  }
}

class LPromise {
  constructor(todoFn) {
    this.status = PENDING;
    this.resolveFns = [];
    this.rejectFns = [];
    this.value = undefined;
    this.reason = undefined;

    const resolve = (value) => {
      if (this.status === PENDING) {
        this.value = value;
        this.status = RESOLVE;
        queueMicrotask(() => {
          this.resolveFns.forEach((fn) => fn());
        });
      }
    };

    const reject = (reason) => {
      if (this.status === PENDING) {
        this.reason = reason;
        this.status = REJECT;
        queueMicrotask(() => {
          this.rejectFns.forEach((fn) => fn());
        });
      }
    };

    try {
      todoFn(resolve, reject);
    } catch (err) {
      reject(err);
    }
  }

  then(onFulfilled, onRejected) {
    onFulfilled =
      onFulfilled ||
      ((value) => {
        return value;
      });
    onRejected =
      onRejected ||
      ((err) => {
        throw err;
      });
    return new LPromise((resolve, reject) => {
      if (this.status === RESOLVE) {
        toCheckTryCatch(onFulfilled, this.value, resolve, reject);
      }
      if (this.status === REJECT) {
        toCheckTryCatch(onRejected, this.reason, resolve, reject);
      }
      if (this.status === PENDING) {
        this.resolveFns.push(() => {
          toCheckTryCatch(onFulfilled, this.value, resolve, reject);
        });
        this.rejectFns.push(() => {
          toCheckTryCatch(onRejected, this.reason, resolve, reject);
        });
      }
    });
  }

  catch(onRejected) {
    return this.then(undefined, onRejected);
  }

  finally(onFinally) {
    return this.then(
      () => {
        onFinally();
      },
      () => {
        onFinally();
      }
    );
  }

  // 静态方法
  static resolve(value) {
    return new LPromise((resolve) => resolve(value));
  }

  static reject(reason) {
    return new LPromise((resolve, reject) => reject(reason));
  }

  static all(promiseArr) {
    return new LPromise((resolve, reject) => {
      const values = [];
      promiseArr.forEach((promise) => {
        promise.then(
          (value) => {
            values.push(value);
            if (values.length === promiseArr.length) {
              resolve(values);
            }
          },
          (reason) => {
            reject(reason);
          }
        );
      });
    });
  }

  static allSettled(promiseArr) {
    const result = [];
    return new LPromise((resolve) => {
      promiseArr.forEach((promise) => {
        promise.then(
          (res) => {
            result.push({
              status: "fulfilled",
              value: res,
            });
            if (result.length === promiseArr.length) resolve(result);
          },
          (err) => {
            result.push({
              status: "rejected",
              reason: err,
            });
            if (result.length === promiseArr.length) resolve(result);
          }
        );
      });
    });
  }

  static race(promiseArr) {
    return new LPromise((resolve, reject) => {
      promiseArr.forEach((promise) => {
        promise.then(
          (res) => {
            resolve(res);
          },
          (err) => {
            reject(err);
          }
        );
      });
    });
  }

  static any(promiseArr) {
    const reasons = [];
    return new LPromise((resolve, reject) => {
      promiseArr.forEach((promise) => {
        promise.then(
          (res) => {
            resolve(res);
          },
          (err) => {
            reasons.push(err);
            if (reasons.length === promiseArr.length) {
              reject(new AggregateError(reasons));
            }
          }
        );
      });
    });
  }
}

52. 实现一个对象的深拷贝

为什么需要深拷贝

因为对象是引用数据类型,当直接让一个对象等于另一个对象时,其实是让两个对象指向同一块内存地址,当修改其中一个对象的属性时,会影响到另外一个对象。如何创建出来两个值相同,但是又互不影响的对象,就是对象深拷贝的目的

实现方式

如果对象中只有一些比如stingnumber这种,我们可以通过JSON.stringify()将对象转换为JSON字符串,再通过JSON.parse()方法转换为对象,就可以达到深拷贝的目的,但是这种方法对一些函数正则等特殊类型进行拷贝,并且会忽略undefinedsymbol类型的对象属性;

Object.assign方法,只能实现对单层对象的深拷贝,也就是说,如果对象中有一个属性的值还是一个对象,那么内层的对象就是浅拷贝了,因此在面对一些单层对象时,我们可以使用该方法进行深拷贝,但是碰见复杂的对象时,就不要用这种方法了。

最好的办法还是自己实现一个深拷贝函数,并且将循环引用正则时间对象这些都考虑进去。

循环引用指的就是一个引用类型中的某个属性是自身,如果发生了这种情况,进行深拷贝时,就会无限递归拷贝当前循环引用的值

封装deepClone函数

// 封装一个检查类型的函数
function checkType(data) {
    const type = Object.prototype.toString.call(data)
    // 正则匹配空格开头+后续所有英文字母
    const reg = new RegExp(/\s\w*/)
    // 拿到[0: ' type'], 取出第0项,去除空格,得到最终type
    const result = reg.exec(type)[0].trim()
    return result
}

function deepClone(source, map = new WeakMap()) {
    // 如果此时要拷贝的不是一个引用类型,或者是null,直接返回原值
    if (typeof source !== 'object' || source === null) return source

    // 判断要拷贝的值的类型,创建一个对应类型的空值,用于拷贝
    let result
    if (checkType(source) === 'Map') {
      result = new Map()
    } else if (checkType(source) === 'Set') {
      result = new Set()
    } else if (checkType(source) === 'Array') {
      result = []
    } else {
      result = {}
    }
    // 如果要拷贝的值发生了循环引用的问题, 就会不断进行复制,最终爆栈
    // 所以我们利用Map数据结构,将已经拷贝过的对象存起来,下次就不用拷贝了
    if (map.has(source)) {
      return map.get(source)
    }
    map.set(source, result)

    // 判断特殊类型
    if (checkType(source) === 'Map') {
      source.forEach((item, key) => {
        result.set(key, deepClone(item, map))
      })
      return result
    }
    if (checkType(source) === 'Set') {
      source.forEach(item => {
        result.add(deepClone(item, map))
      })
      return result
    }
    if (checkType(source) === 'Date') {
      result = new Date(source)
      return result
    }
    if (checkType(source) === 'RegExp') {
      result = new RegExp(source)
      return result
    }

    for (let item in source) {
      if (source.hasOwnProperty(item)) {
        if (typeof source[item] === 'object') {
          result[item] = deepClone(source[item], map)
        } else {
          result[item] = source[item]
        }
      }
    }

    return result
}

// 测试
const obj1 = deepClone(obj)

obj.a = 2
console.log(obj.a) // 2
console.log(obj1.a) // 1

obj.e.add(3)
console.log(obj.e) // Set(4) {1, 2, Set(4), 3}
console.log(obj1.e) // Set(3) {1, 2, Set(3)}

obj.f.set(2, [3, 2, 1, 0])
console.log(obj.f.get(2)) // [3, 2, 1, 0]
console.log(obj1.f.get(2)) // [3, 2, 1]

obj.h.setHours(23, 59, 59, 0)
console.log(obj.h) // Sun Dec 24 2023 23:59:59 GMT+0800 (中国标准时间)
console.log(obj1.h) // Sun Dec 24 2023 16:00:11 GMT+0800 (中国标准时间)

obj.i = new RegExp(/\w*/)
console.log(obj.i) // /\w*/
console.log(obj1.i) // /\s\w*/

obj.j = Symbol(2)
console.log(obj.j) // Symbol(2)
console.log(obj1.j) // Symbol(1)

从代码(写的有瑕疵,可以参考lodash源码进行优化)可以看出,拷贝出来的新对象,引用数据类型之间不会互相影响

53. Function.length、(function(){}).length

Function:length代表函数形参的个数,...剩余参数不计算在内

console.log(Function.length) // 1
console.log(Function.prototype.length) // 0

function one(a) {}
console.log(one.length) // 1

function any(...arg) {}
console.log(any.length) // 0

function two(a, b, ...arg) {}
console.log(two.length) // 2

54. 函数柯里化

函数柯里化其实就是将一个多参数的函数转换为一系列单参数的函数,每个单参数函数都可以接收一个参数,并返回一个新的函数。柯里化可以使函数更加灵活和可重用,避免重复的代码。

比如:

function add(x, y, z) {
    return x + y + z
}

add(1, 2, 3)

function addCurrying(x) {
    return function (y) {
      return function (z) {
        return x + y + z
      }
    }
}

addCurrying(1)(2)(3)

以上就是函数柯里化的一种情况,但是这是在我们知道传参个数的情况下,如果我们不确定传参个数,那么应该返回几层函数呢,或者说,如果调用者想通过add(1,2)(3)这种方式调用呢?

(1)函数柯里化的实现

编写柯里化函数,将一个多参数函数生成单参数函数

function add(x, y, z) {
    return x + y + z
}

function curry(fn) {
    // 获取到原函数接收几个参数
    const funLen = fn.length

    // 返回一个函数
    return function backFun(...args) {
      // 记录此次传入的参数数组
      const params = args

      // 如果此次传入的参数个数 >= 原函数所需要的参数了, 直接调用原函数并返回结果
      if (params.length >= funLen) {
        return fn(...args)
      } else {
        // 如果此次传入的参数还不满足执行原函数的条件, 继续返回一个函数
        return function (...args) {
          // 拿到此次函数的参数, 通过apply, 将此次参数和上次参数合并起来, 调用backFun              函数, 再次进行判断参数是否够用, 不够继续返回新函数
          return backFun.apply(null, params.concat(args))
        }
      }
    }
}

const fun = curry(add)
console.log(fun(1)(2)(3)) // 6
console.log(fun(1, 2)(3)) // 6
console.log(fun(1, 2, 3)) // 6

可以简写成以下写法:

const curry = (fn, ...args) => {
    if (args.length >= fn.length) {
      return fn(...args)
    } else {
      return (...nextArgs) => {
        return curry(fn, ...args, ...nextArgs)
      }
    }
}

const fun = curry(add)
console.log(fun(1)(2)(3)) // 6
console.log(fun(1, 2)(3)) // 6
console.log(fun(1, 2, 3)) // 6

它具体执行的流程是这样的:

  • 第一次我们执行curry函数,只传入了原函数,此时args为空数组,if判断不满足,走else
  • else语句返回了一个函数,fun的值就是(...nextArg) => curry(fn, ...args, ...nextArgs)
  • 执行fun(1)(2)(3),会先执行fun(1),就相当于执行curry(add, 1),依旧走了else判断
  • 然后执行fun(1)(2),此时args的值为[1]nextArgs的值为[2],于是相当于执行curry(add, 1, 2)
  • 继续执行,执行到fun(1)(2)(3)时,相当于执行了current(add,1,2,3),发现if判断满足
  • 执行add(1,2,3),返回正确值;

(2)函数柯里化的作用

  1. 参数复用:比如我们第一次计算了1+2+3的值,后来我们想计算1+2+5的值:
  • 按照普通函数,我们会这么做,第一次执行add(1,2,3),第二次执行add(1,2,5)
  • 按照柯里化,我们可以先拿到1+2,执行const res = curry(1,2),以后无论想+5还是+8,只需要调用res(n)就可以了。
  1. 提前返回:当函数分批次的接收参数时,它会先把一部分参数进行判断,然后返回对应的函数,下次调用时就不需要判断这部分参数了。

  2. 延迟执行:通过柯里化返回的函数,都不会立即执行,而是在我们需要调用时才会执行,这种就叫做延迟执行

55. 实现instanceof操作符

  1. 每个对象都有一个__proto__属性;
  2. 构造函数都有一个prototype属性;
  3. 由构造函数构造出来的对象的__proto__都指向构造函数的prototype
  4. 因此判断一个对象是不是某个构造函数构造出来的,只需要判断它的原型链上有没有一个对象指向构造函数的prototype,如果有,则返回true,没有返回false。
function instanceOf(a, b) {
    let A = a.__proto__
    const B = b.prototype
    while (A !== B) {
      if (A === null) return false
      A = A.__proto__
    }

    return true
}
const a = []
console.log(instanceOf(a, Array)) // true
console.log(instanceOf(a, Map))  // false

56. JS模块化

(1)模块化有什么优点

在没有模块化的时候,通常我们会拆分多个js文件,然后通过script标签引入,这种方式十分容易造成变量污染,比如在别的js文件中声明了一个和当前页面相同名称的变量,这就会造成许多意想不到的问题,而有了模块化之后,每个模块都有自己独立的作用域,别人想使用你的代码,直接将你的模块进行导入,并且不会产生变量污染的问题,极大的增加了开发的便捷性和安全性。

(2)模块化都有哪几种

js中的模块化有多种方案,但是它们一开始都不是官方方案,比如CommonJsAMDUMDCMD等,直到ES6模块化的出现,才有了一个官方的模块化解决方案

(3)CommonJs

  • CommonJs一般用于Node环境,也是Node.js中默认的模块化规范,因为它是同步加载的,在浏览器环境同步加载的方式可能会导致很多任务阻塞;
  • 每个文件都是一个模块,每个模块都有自己的作用域
  • CommonJs通过module.exports来导出模块,通过require来导入模块;
  • 模块多次被加载时,首次加载会对加载结果进行缓存,再次加载时会使用缓存;

(4)AMD

  • AMD"Asynchronous Module Definition"的缩写,意思就是"异步模块定义"
  • AMD通过define(function(){})来定义模块,define([otherModule], function(){})来定义依赖其他模块的模块,通过require([module], function(){})的方式来加载模块;
  • AMD推崇依赖前置提前执行(在文件一开始时就声明所需要的模块,然后加载模块,执行回调函数);
  • require.js中实现了AMD的规范;

(5)CMD

  • CMD使用按需加载,不需要在一开始就对模块进行加载;
  • CMD推崇依赖就近延迟执行(在需要用到模块的地方进行导入模块,不需要在一开始就声明);
  • CMDAMD类似,通过define方式定义模块,不过定义模块函数中有三个参数require、export、module,require用于加载其它模块,export用于导出方法,module是一个对象,存储着与当前模块关联的一些属性和方法;
  • sea.js中实现了CMD的规范;

(6)ES6模块化

  • ES6模块是可以实现静态分析的,也就是说,通过编译代码时,浏览器会识别import,然后进行静态分析,就能确定各个模块之间的依赖关系,这一点是其它模块化方式做不到的;
  • ES6模块化使用export导出模块,使用import导入模块;

(7)各个模块化的区别

  1. AMDCMD的区别:
  • AMD推崇依赖前置,也就是说要把当前模块使用到的模块提前声明,而CMD则推崇依赖就近,使用到哪个模块时再进行导入
  • AMD推崇提前执行,在加载完模块之后会立即执行模块,而CMD加载完模块并不会立即执行,而是等到require语句才去执行相应的模块;
  • CMD区分了require、export、module等,各自的分工不同,而AMD没区分这么清楚;
  1. CommonJsES6模块化的区别
  • CommonJs的模块是运行时加载整个模块,然后生成一个对象,而ES6模块不是一个对象,它是在编译时静态分析然后加载特定的值
  • CommonJs模块输出的是一个值的拷贝,而ES6模块输出的是一个值的引用
  • CommonJsrequire同步加载,而ES6模块的import命令是异步加载

(8)CommonJs中的module.exports和exprots

CommonJS中,有两种导出模块的方法,一个是module.exports,另一个是exports,它们有什么不同呢?

其实modules.exports就是exports,因为在源码中有一行代码就是类似:

const exports = modules.exports

也就是说,它们两个指向同一块内存地址,但是如果给modules.exports重新赋值,那情况就不一样了。

let modules.exports = {} // 比如此时modules.exports的内存地址是0x100
const exports = modules.exports // 此时exports的内存地址也是0x100

modules.exports = {} // 这相当于将modules.exports重新赋值了一个对象,内存地址变成了0x110

// 而此时exports对象的内存地址还是0x100,它们就不相等了。

如果将exports重新指向一个其它值,那就完了,就会丢失所有和modules.exports的联系。

(9)ES6模块化导入导出的方式

1. 导出某个变量

export const a = 1

2. 统一导出

const a = 1

const b = function () {
  console.log('b')
}

const c = {
  name: 'c'
}

// 可以起别名
export { a as a1, b, c }

3. 默认导出

export default {
  a: 1,
  b: 2
}

4. 导出其它文件所有内容

export * from './index'

5. 导入某个变量

import { a } from './index'
import { b as b1 } from './index'

6. 导入所有

import * as module from './index'

7. 导入默认

import module1 from './module1'
// 只允许将default重命名,不能直接将default解构导入
import { default as xx } from './module1'