前言
几年前写的一篇整理常见前端 JS 面试题的文章,现在回看当初还是有些青涩,但仍然对准备面试的人有一定帮助(比如说对于在校大学生),正巧今日注册了掘金账号,便发文一篇。
正文
P5 是前端在互联网大厂最低的等级(旧阿里职级体系的论法),超一线年包在 30w 以上(大概吧),常被调侃为大厂白菜价。
题目并未按照逻辑顺序整理,按照以下顺序看是最好的:
- 基础数据类型与结构
- 作用域与 this 指向
- 原型链、继承与 JS 的面向对象
- JS 的异步操作、事件循环
- 浏览器、线程、高级调试
题号后面的数字是我给题目打的难度分,如果能够通过大部分 4 分以下的问题,就可以通过面试。
所谓 ES6 是指 JS 在 2015 年提出的规范,由于前两年 IE 死了,所以说不支持 ES6 的情景变得相当少见,掌握 ES6 从原来的加分项就变成了必备技能;当然这并不是什么坏事,因为如果要是有人硬考你用 ES5 的语法规范来完成什么功能,你可以认为是找茬,因为现在实际生产已经很少用了。
1(1). 原始值和引用值类型及区别
首先得知道 JS 原始数据类型与引用数据类型有多少种,如何区分,才能回答这个问题。
原始数据类型:
ES5: number、string、boolean、undefined、null
ES6 & ES6+: Symbol、BigInt
引用数据类型:
除了原始类型剩下全是引用类型,而且他们都继承于 Object;在内存处理上,原始类型直接存储于栈内存,而引用类型是存了一个指针在栈里,实际的数据存在堆内存
2(1). 判断数据类型
typeof:JS 类型判断糟粕之一,只能判断基础类型,返回的是小写的字符串,但是 null 和其他所有引用类型(除了函数)都会返回 'object',唯独函数会返回 'function'
instanceof:JS 类型判断糟粕之二,只能判断引用类型,因为他的原理是查找实例是继承于谁的,而且原型链上所有的继承关系都可以被判断为 true,例如如果想判断一个数据是不是数组还是对象,那得先判断 Array,因为 Array 对象在 instance Object 也会返回 true。
Object.prototype.toString.call():还算靠谱的糟粕,这个语法太复杂了,纯粹是因为其他判断类型太拉垮了,导致他们硬找了一个能够判断类型的函数来顶一下,这个函数就是 Object 这个类的原型方法 toString,由于基本上各种对象或者基础类型都以自己的 toString 方法,想要稳定执行 Object 的 toString 方法就只能用「原方法.call」的形式。
他总会返回 '[object Xxxxxx]' 后面是类型的名字。
这个方法也不能完全判断类型,比如它分不清 1 和 new Number(1),都会显示 '[object Number]'
constructor 其实就没啥可说的,也不靠谱,这个方法判断的是实例里存储的 constructor 信息,这本身没毛病,但是 JS 的这种信息可以改,所以就不靠谱。
可以说 JS 类型如此拉垮,也是 TS 备受追捧的一大原因。如果你真的想好好搞类型,那就用 TS。
如果你没 TS 可用,那 ES6+ 提供了 Array.isArray() 这样的静态方法去判断类型。
3(1+). 类数组与数组的区别与转换
类数组是指有数字索引,以及自然数迭代器的数据类型,常见的类数组:dom 列表(Element list)、参数列表(function 的内置对象 arguments,在 'use strict' 模式下是不让用的)
ES5 之前的话,如果要对类数组执行数组方法,建议用 call
ES6: Array.from(arrayLike)、[...arrayLike]
4(1+). 数组的常见 API
第一层:从头插入弹出、从尾插入弹出,遍历,连接多个数组,可能需要你对他的返回值有所掌握,最容易错的点是,push 这种插入操作,返回的是新数组的长度;pop 这种弹出操作,返回的是被弹出的元素。
let arr = [1, 2, 3, 4, 5]
console.log(arr.push(6), arr) // 6, '[1, 2, 3, 4, 5, 6]'
第二层:常见的高阶函数,比如 map、filter、some、foreach 这一类参数是函数的函数,熟练使用它们可以让你看起来很专业(实际上也很专业)还有 join
第三层:reduce 这个可以被看作是「语法盐」,它玩法太多了,但是哪种语法看起来都太难了,难以理解,属于自嗨型 API,如果面试官执意考你 reduce 的花样,建议你承认你知道这些花样但是你不愿使用,尽量给对方留点面子。
5(2). bind、call、apply 的区别
call 和 aplly 作用相同,几乎就是同一个 API,共同点是他们第一个参数都是被指定的新 this,区别就是 call 后面可以接无数的参数,这些参数是实际被调用的函数的原参数;而 apply 是把原函数的所有参数放在一个数组里,也就是说,apply 只有固定两个参数。
bind 和前两者是天差地别,比如 let a = b.bind(newThis) 则 a 是一个 this 被「永久」强制绑定为 newThis 之后的与 b 函数逻辑相同的函数。call 和 apply 是即刻调用的,而 bind 是保留了一个被改变了 this 的函数。注意:bind 改变this 指向后返回的函数,是不能再被 bind、call、apply 改变 this 指向的。
这玩意其实是会被考手撕的。之后可以弄个手撕专题。简单来说就是把要改变 this 的函数给新的 this 对象安上去,执行完再删掉就行了,因为过程中会占用新 this 的某个属性,所以最好弄一个 100% 不会覆盖掉原有的键的方法,那就是 symbol,当然你检测原来你要覆盖的键有没有值,暂存起来,然后再复原也是一样的。
6(3). new 的原理
这个会在面试里要求你手撕,例如写个函数来代替 new 之类的,你需要知道 new 都干了什么。
-
new 会调用后面你提供的构造函数和参数,来运行一次构造函数
-
如果呢你这个构造函数返回的是对象,那么不做处理,如果不是对象,就封装成对象
-
最后不管怎样,把对象的 proto 赋值为构造函数的 prototype
这里就不给源码了,自己去搜,很容易写。
7(2+). 如何正确判断 this?
-
先判断表面上的调用者和调用的函数,比如
a.b.c.d()就是调用者为a.b.c,被调用的函数是a.b.c.d -
不出意外的情况下,
this就是a.b.c,当然执行的函数本身是不会变的还是a.b.c.d,什么算是意外呢?a.b.c.call(newThis)如果最后一位函数是 call、bind、apply,那其实实际执行的函数就是a.b.c,而 newThis 是新的 this,也可以说是被强制指定的新 this。例如:Object.prototype.toString.call()- 如果 d 本身是个箭头函数,那么 this 就需要看调用当时的外面一层的 this 是谁了,这就没法不结合具体情况说明了,同样如果外面一层又是箭头函数,就还得再向外找一层,直至找到根对象,Window 之类的。
-
如果这个函数是在当前作用域直接执行的,例如
a()这种情况,执行的函数就是 a,执行者是当前作用域的根对象,默认浏览器环境下是 window
8(2+). 闭包及其作用
当内部作用域,依赖外部作用域某个变量时,即使外部作用域应该被销毁了(比如它职责已尽),但是这个变量仍会被保存下来,这就是闭包。优点在于存住了变量,可以记录更丰富的状态;缺点就是搞不好容易内存泄漏。
注意:内存泄漏,不正确的使用闭包是一个典型的起因,但不是所有内存泄漏都是闭包产生的。
const useCounter = () => {
let count = 0
return () => console.log(count++) // 此处函数依赖了外层作用域的 count,会产生闭包
}
const counter1 = useCounter()
counter1() // 0
counter1() // 1
counter1() // 2
9(3). 原型和原型链
首先在了解「原型链」之前,我们需要先了解什么是面向对象?面向对象的特点是什么?
面向对象,缩写为 OOP,其实也可以理解为面向「类与实例」,实例是程序运行时真正用于计算与处理的对象,类是在编写代码时抽象出的一群实例所共有的「概念」。类是实例的属性与行为的抽象,它是个概念,不能直接(加减乘除)运算。也就是说,面向对象编程(OOP),是一个在编程时,试图抽取有相似特征的实例的属性与行为,最终用类的形式来表达,在编写与运行过程中,通过类来进行实例的创建,以达到一些封装与复用的效果。
面向对象属于一种「编程范式」,所谓编程范式就是编程的「文风」,它只是用于组织代码的一种方式,在 JS 这种多范式语言中,写同样一个功能的程序,OOP 只是其中选择的一种,而「原型链」是 JS 实现 OOP 的手段。
面向对象的特点是:继承、封装、多态。不管是什么语言来实现这种范式,这几点都不会变。
那么回到原型链本身,首先我们讲 JS 里面所有的引用类型都是对象,然后我们所知的例如 class 这样的关键字是 ES6 加入的,但是显然 ES5 以及之前,JS 就支持面向对象,那么 JS 的面向对象到底是怎么搞的呢?
首先有一个疑点,类似于 C++ 这样的语言,它的类与实例区分得很开,类主要在 .h 文件里,实例与运算在 .cpp 文件里,这也算是确保了类不能参与加减乘除这样的运算。然而 JS 就算在 ES6 之后,我们也知道 JS 里面是没有这样的区分的,所以 JS 里作为「类」存在的数据结构,没有与正常能够参与运算的数据结构进行区分,也就是 JS 的类,如果你想的话,真的能加减乘除。那么说了半天,JS 里面被当作「类」的数据结构是谁呢?
JS 的类本质上是由 Function 来替代的。
function Student() {}
var s = new Student() // 这一段正宗的 ES5 写法,除了 new 是 ES6 的,这里只是示意一下
类呢,最大的功能就是创造实例,不能创造实例的类最终就可能没法投入使用(除了抽象类、静态类),创造实例这个过程呢,被称为「实例化」,而类想要创造实例就必须有「构造函数」,JS 直接把充当类的函数本身当作构造函数了,当然有一个规定,就是在 JS 里如果你对一个函数进行首字母大写(大驼峰),那么就意味着这是个「类」,反之它就只是个函数而已(实际上并没有严格要求,JS 可以把随便写的函数当作类,只要是 function 关键字声明的,箭头函数不能成为类)。
那么 JS 既然把函数当作类来用了,那么它怎么实现 OOP 需要实现的特性呢?「封装」倒是有了,那「继承」和「多态」怎么办呢?所以函数作为类,类则必须有能够继承给实例的内容,这就是「原型 prototype」
JS 中类继承给实例或者子类的属性和方法,放置于类(或称为构造函数)的 prototype 属性中。
注意:由于在 JS 里面只有 Function 可以当作类来用,所以 prototype 这个属性呢,只有 Function 才有。除了箭头函数,除了箭头函数,除了箭头函数,箭头函数没法成为类。
即便是随手写的一个 function,它也会有 prototype,没有任何继承价值的「空 prototype」实际上也不是空的,prototype 本身至少有两个属性,一个叫做 constructor,它应该始终指向 prototype 所属的函数,另一个属性叫 proto,它和当前 function 的 proto 相同,它有什么用我们一会就提到。
向下传递需要继承的内容解决了,那么一个被实例化出来的实例,或者一个继承于父类的类,如何知道它继承于谁?JS 里面所有的实例(原始类型、引用类型、能够见到的所有的值)都有一个共同的属性,叫做 __proto__,这个属性用于查询「当前实例继承了什么属性与方法」,比如我手里有一个 arr 是一个数组的实例,当我去访问 map 方法时,它首先会找是否存在 arr.map 这个方法,但实际上绝大部分时间,这个方法都是不存在的,实际上它访问的是 arr.proto.map 这个方法,因为 JS 里的实例一旦找不到自己身上有合适的方法,就会去查询自己有没有继承到什么符合条件的方法,就会顺着 __proto__ 一直找。
其实呢,刚才这个过程中,arr.proto 拿到的是什么呢?就是 Array.prototype,所以 JS 的继承写作语句只有一句话。
JS 继承的核心语句:child.proto = Parent.prototype
而且这个语句,由于 prototype 里面装了所属函数的 proto,所以 JS 在「找爸爸」这件事上可以连续访问 proto。
要注意 arr.proto.proto .proto === null 这种写法并不是原型链本身,它只是在访问原型链时的一种体现,在这个过程中真正的原型链应该是 Object.prototype -> Array.prototype -> arr。
10(3-). prototype 与 **proto** 的关系与区别
有什么关系呢?没啥关系,他俩就是同一个东西的不同访问方式。还是那句话
child.proto = Parent.prototype
11(3~4). 继承的实现方式及比较
懒得讲,因为核心其实就是记好「什么该继承、什么不该继承」
-
直属于构造函数的属性和方法,不需要继承,那些被称为静态属性与静态方法
-
隶属于构造函数的 prototype 中的属性与方法,需要继承,要让子类或实例的 proto 与该类的 prototype 相等
-
构造函数内部使用的属性与方法,不需要继承
搞清楚上边的原则,自己写继承就可以了,大差不差。
至于 ES6 的 class 语法,在 ES5 中的具体实现,这个方法一般被称为「组合寄生式继承」,具体实现看别人博客。(虽然实际上这里面猫腻还挺多的,比如说 class 关键字的成员中 function 声明的函数会被放入原型,而箭头函数不会被放入原型,也就是每次实例化都会被克隆一份)
12(2). 深拷贝与浅拷贝
这个题,很爱考,也很实用,不过讲起来一言难尽。
JS 这个语言没有所谓的深拷贝,或者说官方库没提供深拷贝方法,引用类型进行复制都是取址,而非创造一份副本。但是像 JS 这种有可能有异步类似的操作的情况,是经常出现需要做复杂数据的「快照」的情况的,这就需要深复制。我们讲解这个问题从实际需要出发,按照选型的优先级来选择方案。
先明确一下何时需要对数据进行快照,比如说我要打印一个数据,这个数据在运行过程中是有内部值的,但是在我打印的时候内部的这个值就没有了,或者改变了,打印的结果与预期不一致,这是正常现象,因为浏览器打印的时候取地址里面的值,可能已经错过你想要记录值的那个时期了,具体原因应该从事件循环下手。
-
优先看看当前项目有没有引入 lodash 之类的工具库,如果有就用其中的深复制方法,比如 lodash 的 cloneDeep,虽然如果去研究的话 lodash 的 cloneDeep 我评价为几乎不可读
-
JSON.stringify 与 JSON.parse 配套使用是否可以适用(不适用的场景有:正则对象、循环引用等),如果只是一个单纯的嵌套多层,内部是对象与数组的数据结构,JSON 的这两个静态方法是完全可以应对的。
-
如果要是上面两个方法都不行,那就得你手写了 cloneDeep 了
手写的注意事项:
-
要知道这个过程肯定是需要递归的(迭代也行,DFS)
-
每一次判断一个层级或者当前的数据结构时,都要做好多的类型判断,比如是否是数组,是否是对象等等,尤其是遇到可迭代的类型,不同的类型的迭代方式不一样;额外还有可能是不可迭代类型或者基础类型。
-
如果让你手写深拷贝,那至少你得能处理 JSON 那两个静态方法应付不来的情景:
-
遇到 JSON 处理不了的数据结构,例如正则对象,这个就得识别它的类型单独再做复制了
-
遇到循环引用这种情况,可以预先做好内部所有键的指针的记录,比如可以用 WeakSet 或者 WeakMap,因为这两个数据结构是「使用指针作为键」的 Set 和 Map
-
具体手写这里就不展示了,笔者也是现问现写。因为已经好久没有人关心笔者会不会这个了。
13(3). 防抖和节流
需要手撕,但是我不想现撕,自己先练练吧,随便拿时间戳或者计时器或者 setTimeOut 搓一个试试,重点还是装饰器设计模式和闭包。
14(3+). 作用域和作用域链、执行期上下文
去看 www.bilibili.com/video/BV1AJ…
15(2). DOM常见的操作方式
- document.querySelector 这个方法支持绝大多数的 CSS 选择器
- document.getElementById 或者 document.getElementsByClassName
- document.remove
- document.append
- document.createElement
- etc...
16(-1). Array.prototype.sort() 方法与实现机制
你以为是快排,其实比快排恶心多了~
length < 40 用的应该是冒泡排序;
length < 200 用的是快排;
length >= 200 用的是堆排;
17(-1). Ajax 的请求过程
如果问的是 JQ 那个年代的那个库,那就让他入土;
如果问的是从 URL 到浏览器渲染,那就干脆转到那个题去
18(2). JS 的垃圾回收机制
这个一般问的是 V8 的垃圾回收机制:
-
首先是不被访问的变量会被标记为「孤岛」在下一次循环中被清除,从而回收内存。这个过程涉及到一些例如标记计数法之类的算法,但都是些统计内存常用的算法。额外不得不提,V8 或者说 JS 的垃圾回收,肯定是不怕环形引用的。
-
为了避免回收内存的过程中把内存搞的过于碎片化,V8 采取了一些措施:
- 比如为当前内存使用区分「新生代」与「老生代」
- 按情况做一些碎片整理类似的算法
-
详细了解见下文:
暂时无法在飞书文档外展示此内容
19(2). JS 中的 String、Array 和 Math 方法
String 最常用的 slice、splice,Array 的 join 也能算是 string 方法吧
Array 说过了
Math 不想多说,因为 floor、round、random、abs 除了这些常用的,还有不少真的数学运算才会用到的,然而哪个人拿 JS 做高性能的数学运算啊。
String 和 Array 的 slice 最常用于创建一个副本,而且 slice 的第一个参数支持负索引
Splice 尽量不要再遍历的时候用,因为它会改变元素长度使遍历变得不可预知。
String 的一些高级处理其实更倾向于正则表达式,所以切字符串能会就会吧,不会用模版字符串和正则也一样。
20(2+). addEventListener 和 onClick() 的区别
这不是一个很有营养的问题,因为在我看来这就是同一个 API 的不同设计,你只需要知道:
- addEventListener 是「添加一个事件」如果不用了要手动 remove 掉,也就是增加。
- 直接设置 onClick 是相当于清空当前所有的事件,再添加一个 click 事件,也就是覆盖。
21(3). new 和 Object.create 的区别
可能是之前笔者还记得,现在不记得了,很久没有用过 Object.create 了,只记得它是在 ES5 时起可以比较便捷的创建继承于某构造函数的实例的一个方法,算是 ES5 的 new 吧,不过细节上可能和 new 不同。
我建议有兴趣探究这个问题的,帮忙翻翻 MDN 然后评论给我,因为 Object.create 笔者没用过。
22(1+). BOM 的 location 对象
BOM 的 location 对象很容易被误解为是「坐标或者位置」之类的渲染属性,但其实不是。BOM 的 location 指的是浏览器对于当前页面访问历史等「路径」的记录的一个内置对象。使用这个对象你可以:
- 跳转页面
- 前进或后退
- 翻阅当前页面生命周期内的所有历史
- 从路由里面获取信息
23(3~4). 浏览器从输入URL到页面渲染的整个流程(涉及到计算机网络数据传输过程、浏览器解析渲染过程)
压缩文本警告
主要是:
- URI 本地解析(本地 host 解析,到 DNS 解析出 IP 为止)
- 计算机网络、各层协议(出了自己的电脑之后,各层网络协议如何运作,HTTP,TCP,IP,TLS...)
- 与服务器建立连接,服务访问流程,数据通信流程(对方的服务架构,可能会扯到网关、负载均衡啊、缓存啊,一直到给你传页面和相应接口的服务,以及这个服务如何返回)
- 浏览接收数据,数据就绪后如何渲染(渲染树构建啊,DOM 构建啊,定位啊,渲染啊总之是浏览器内部的事情)
24(3+). 跨域、同源策略及跨域实现方式和原理
一般如果前端看到有「CORS」字样的错误时,基本就是跨域没跑了。
跨域来源于浏览器的一个同源策略,具体内容是指不允许网站加载、访问、执行不受允许的其他域名下的脚本,好比说我在本地有一个 html 文件,要访问我本地的另一个 js,那很可能就访问不了,因为他们也不是同一个服务上的,域名当然也不一样,这就被看作是跨域了,为了安全不能够执行(src 属性一般被排除在外)
不过到头来,这个跨域或者说同源机制,是一个浏览器和服务端共同联合保护前端不出毛病的东西,和真正的前端开发者反倒没什么太大关系,因为想要避免跨域问题,一般不是取消浏览器的跨域检查,就是让服务端为目标域名设置白名单。
当然,如果可以的话,设置一个「正常」的 Content-Type 头,也有利于减少不必要的跨域的产生,因为只有脚本在跨域之列,如果你给一个 CSS 之类的资源文件不小心设置了一个 JS 的 Content-Type,那也会被跨域拦下来,但是一般前端也不会去干预这个事情。
除了关掉跨域,或者让服务端开白这两个正道,还有一些可以绕过跨域的方法,其中一个还算正派的是 iframe,比如我需要在当前网页嵌入一段别人的网页,iframe 可以实现这个功能,但是如果 iframe 内部的网页不是你写的,你无权干预 iframe 内部网页的任何事务,当然它也完全无法干预你就是了。
另外还有一些能够绕开跨域的手段,能叫得上名字的比如说 JSONP 和降域。
JSONP 实际上是利用了 src 不在跨域限制之内去做的文章,实际上不建议使用,有更规范的方法为什么要用可能被掐死的偏门呢(虽然大概率也比较难掐死)
至于降域,笔者不懂,不熟悉网络相关的事情
25(3). 浏览器的回流(Reflow)和重绘(Repaints)
浏览器是先构建好 DOM,此时还未渲染,然后进行布局的计算,之后才是具体的渲染。
如果一个区域,只是改变颜色之类的,没有改变位置,那么就不会触发重新布局,也就是「只重绘不重排」。
如果一个元素位置改变了,尤其是浏览器认为他会改变别的元素的位置,那么就会触发范围性的「重排」
至于重排的作用范围,这个得看 BFC(块级渲染区域),比如 position: relective 啊、absolute 啊,display:flex 啊,这些都会触发 BFC 的生成。
同样的,margin 塌陷问题也是只有在同一个 BFC 的老式的盒模型的那些元素才会发生。
26(1). JavaScript 中的 arguments
arguments 指的是 Function 的运行时上下文中的一个内置对象,存储了一个函数被调用时传递的所有参数,这个参数数量可能超过了本来需要的数量。
注意点:
'use strict' 不让用;arguments 是个类数组;箭头函数没有这个内置对象
27(3). EventLoop 事件循环
浏览器其实分给 JS 用于执行的线程只有 1 个,所以其实我们去执行一些例如像是「异步」或者说是「等到时机合适了再做」的事情,其实都是在同一个线程执行的,这与很多 Server 语言是不同的,由于资源和浏览器规则的限制,JS 是一门单线程模型的语言,在其进行异步操作时,实际上并不是「真异步」。
这里我们可以参考操作系统中「并行」这个概念,例如我在用我的 PC 播放音乐、玩游戏,我可以同时干好多事情,但是这些事情看起来是「同时」进行,其实未必是同时进行的,操作系统真正运行时是在飞快的轮换线程执行这些任务,让他们看起来是「同时」,这被称为「并行」。对于浏览器内部,也有类似于操作系统这种时间片的分片逻辑,不过一般我们都称其为渲染帧。简单来说,所谓像是浏览器卡顿掉帧,其实就是在一次渲染帧中没有执行完一次渲染流程,导致多个渲染帧只渲染了一帧。
那么在这样的一个渲染帧里面,JS 是至少要跑一遍循环的,这被称为事件循环,如果 JS 在一次渲染帧中,没有跑完一次事件循环,那么就叫做 JS 阻塞。这时候表现为 JS 引起的卡顿,此时所有点击交互事件都不可用,因为浏览器的交互主要依赖 JS。我们可以通过在 JS 里写死循环来创造 JS 阻塞的现场。
JS 本身执行一些事件或者处理一些数据的速度,虽然不及 C++ 之类的语言,但是还是十分快的,至少 1ms 执行 10 ^ 6 量级运算应该是有的(记不清了),只要你的 JS 一次事件循环的所有事情能够在 16ms 之内完成就不会掉帧,所以 JS 是一个对于性能相对要求宽松的语言(重点在于 JS 主要用于网页,而网页性能要求一般没那么离谱)
JS 在一轮事件循环中,主要执行的内容就被称为宏任务,例如直接由上至下写的如 console.log() 或正常运算之类的代码,都算是宏任务,可以认为宏任务就是 JS 循环的那个脚本本身。至于说 setTimeOut 是宏任务这个说法其实并不准确,准确来说,setTimeOut 是会把目标函数放入下一次符合延时的宏任务执行的函数,而有些延时函数,是干脆整体都走微任务的执行逻辑的。
微任务是什么?微任务是 JS 在执行过程中开发者指定的「不知道什么时候可以执行」的任务,例如:不知道什么时间会返回的请求,不知道什么时间会被按下的按钮之类的,像是 Promise 就会创建一个微任务,他会在将来某个「合适」的时机执行。
那浏览器的事件循环如何执行就比较明了了,首先是宏任务每次事件循环都在执行,执行完宏任务后,检查微任务队列有没有合适的可以执行的,有能执行的就执行,不合适的就推入下一次的微任务队列,总之微任务队列每一次事件循环是要清空的。并且这个过程中,新插入的宏任务与微任务,会自然放在下一次事件循环中。
28(3). 宏任务与微任务
关于宏任务与微任务最常考的就是各种 Promise、setTimeOut 混合的输出题,问输出顺序之类的,这玩意掘金一抓一把。
宏任务有关的:主 JS、setTimeOut、setInterval
微任务有关的:Promise、Generator
29(2+). BOM 属性对象方法
30(3+). 函数柯里化及其通用封装
31(2). JS 的 map() 和 reduce() 方法
数组方法说了,map 是一个接受一个「处理函数」,返回一个将现有数组每一项都「处理」后的数组
与 map 相近的还有 filter、some 这样的具有其他特定语义高阶函数。
至于 reduce,看名字是叫「减少」,但实质上 reduce 的核心在于其「累加器」机制,多的就不说了,这玩意玩法太花,自己看吧。
32(1). “==”和“===”的区别
前者是弱判断、后者是强判断,用后面那个就对了。
33(3). setTimeout 用作倒计时为何会产生误差?
操作系统不叫准都有时差,强实时性的操作系统屈指可数,何况是浏览器内部的事件循环呢?
34(2~3). let、const 和 var 的概念与区别
var 是 ES5 声明变量的唯一方法,let 是 ES6 出的用于替代 var 的方法,建议是不用 const 的时候全用 let
而 const 则是声明常量的意思,不过这三个关键字都有一些可以问的问题
for(var i = 0; i < 5; i++) {
setTimeOut(() => console.log(i), 0)
}
// 会输出 5 次 5
for(let i = 0; i < 5; i++) {
setTimeOut(() => console.log(i), 0)
}
// 依次输出 0 1 2 3 4
var 声明的变量会被挂在作用域的根对象上,比如在最外层 var 一个变量就会挂到 window 上,而 let 不会。
至于为什么第一个示例会输出 5 次 5,是因为 setTimeOut 是一个宏任务,需要在当前宏任务执行完成后再执行,这是事件循环的问题
至于 const 只会问一个比较重要的问题,就是 const 声明的引用类型,是否可变?
答案是最外侧的指针不可变,但是内部随便变
比如举个例子,react 的 useState 这个钩子是一个浅监听,如果我想监听一个对象内部一层的属性有变化,就渲染,那么应该用能够改变对象指针的方式来 setState 而不是用原对象改变值后 setState。
const [state, setState] = useState({a: 1, b: 2})
state.b = 3
setState(state) // 这样是不会生效的,不会重新渲染,因为 state 指针没变
setState({...state}) // 这样就会生效,因为创建了一个新对象,指针变了
35(3). 变量提升与暂时性死区
console.log(a) // a is not defined 这里会报错
console.log(a) // 输出 undefined,没有报错
var a = 1 // a 的定义被提升了
console.log(a) // 1
console.log(a) // 会报错,这里被称为 a 的暂时性死区
let a = 1 // a 的定义被提升了
其中 var 语法的顺序等价为以下写法
var a
console.log(a) // undefined
a = 1
36(2). 变量的解构赋值
其实就是一个用展开可迭代对象的一个快捷语法,这里只写一两个技巧,比如剔除某元素,或者覆盖某元素。
const {age, ...others} = {...props} // 得到的是 age 和除了 age 之外的 props
<Component {...props, key: myKey} /> // 先解构,再覆盖
额外解构语法其实本身并不复杂,只是将等号右边的元素与等号左边的元素进行索引的对齐,并且赋值
// 不论是数组解构,还是对象解构,都是索引对齐然后赋值
const [a,b,c,d] = [1,2,3,4] // a = 1,b = 2 以此类推
37(2). 箭头函数及其 this 问题
理论上是箭头函数依赖外侧环境的运行时上下文,this 的话也是用外侧的,不过实际处理起来可能更棘手,所以既然箭头函数很多,还是少用 this
38(-1). Symbol 概念及其作用
一言蔽之就是可以创建一个永不会重复的「键」,可以用作 JS 原型式类内部的私有成员的标识符,或者单纯是作为一个必须通过内存引用访问的键来使用,好比你在手撕 call 的时候大概会用到。
39(3). Set 和 Map 数据结构
Set 就是集合、Map 就是字典,这两种数据结构在实际编程中非常常用,因为他们的内部存储的数据不会重复
注意点就是,他们的底层是哈希表,并且 Set 可以随时被 Array.from() 转化为数组,同样 Set 还能接受数组作为初始化参数。数组去重就是 Array.from(new Set(要去重的数组))
40(3-). Proxy
好比说 Promise 是专门盯着一个「函数」的观察者,那么 Proxy 就是专门盯着一个「对象」的观察者,虽然观察对象的观察者应该叫「代理器」
Proxy 接收一个 target 对象,和一个具体要监控什么事件的设置对象,返回一个和 target 一模一样但是被监控了的对象,每当被监控的 target 触发了监控的事件,proxy 就能够捕获并且执行编写者分发给他的函数。
Proxy 支持的触发器有:
- get、set
- definePropoty、delete
- etc... 基本凡是能对对象进行的操作都可以监控
使用时要注意,Proxy 是返回了一个被监控的 target,改变这个 target 是可以同步改变源 target 并且触发触发器的;但是如果这个时候你去改源 target,是不会触发触发器的,也不会改变被监控的那个 target。因此处于 proxy 的语义,在一个对象被 proxy 代理后,就应该只用 proxy 返回的对象,而不是原对象,以免语义混乱。
41(3-). Reflect 对象
简化 Proxy 语法的,没啥可说的,看看 MDN
42(3~5). Promise(手撕 Promise A+ 规范、Promise.all、Promise 相关 API 和方法)
Promise 是一个前端必考数据类型,它本身涉及到设计模式有「代理模式」「观察者模式」,里面涉及的程序设计思想有「异步编程」「控制反转 IoC(API 设计有所体现)」
let p = new Promise((res, rej) => {
let result = doSomething() // 随便做点什么
if(result) res(result) // 标记何时 p 进入 fullfilled 状态
else rej("doSomething return false") // 标记何时 p 进入 rejected 状态
})
// then 的第一个参数是 p fullfilled 后进行的回调,第二个是 rejected 后进行的回调
p.then(res => console.log(res), rej => console.log(rej))
// 上面的写法写成下面这样也不要奇怪
p.then(console.log, console.log)
我们先来捋一下 Promise 本身的规则与构造:
-
Promise 的初始化参数是一个由用户(编写者)指定的「函数」,Promise 的作用是盯着这个函数的执行,待「时机合适」改变自己的状态与值。
-
Promise 的初始化参数的类型为 (res, rej) => void 其中 res 和 rej 是两个由 Promise 提供的回调函数,用于让用户在要执行的函数中,标记何时应该改变 promise 的状态,以及如何改变还有值是多少。
-
Promise 实例一旦改变状态,就再也不能改变到其他状态,而且只能是由 pending 转换到 fullfilled 或者 rejected 一次。
-
Promise 实例上的 then 方法,可用作 promise 状态改变后执行回调,then 的第一个参数是 fullfilled 后进行的回调,第二个是 rejected 后进行的回调。
-
Promise.prototype.then 返回的是个 promise 实例,所以可以写出 new Promise(() => {}).then().then() 这样的链式调用。
-
Promise 实例一经创建,任何外部方法无法改变其状态,只能等 excutor(编写者传入的函数)执行到合适的时机,promise 实例自行改变状态。
至于 Promise A+ 规范就是 ES 提出的在不同的 JS 运行时中可以共同遵守的一套,可以让 Promise 更加易用的规范。它包括 Promise 本身和 Promise 实例方法 then 两部分约束。
Promise A+:
-
如果在「excutor」也就是用户传入的函数,执行时抛错,那就以 rejcted 状态返回,值为错误的信息
-
如果 res 或 rej 没有使用任何参数(没传状态变化后的值),或者函数干脆没 res 或 rej,那么其值为 undefined
Promise.prototype.then A+:
-
then 内部回调函数的 return 值回作为新 promise 的值
-
then 的回调参数如果没有 return 值,则 promise 以 undfined 返回
-
then 内部回调函数如果抛错了,则以错误信息作为值,但不会改变 promise 的状态
总结一下:
-
Promise 实例是一个非常简单的数据结构,其向外暴露的就一个「状态」和一个「值」
-
Then 是用来决定 promise 实例状态改变后如何操作的方法
-
Promise 实例在初始化时,它要「盯住」的事情是开发者决定的,连状态改变的时机也是开发者决定。
-
Promise 作为一个「眼线」状态只能由自己所「盯住」的事触发,然后自己改变,并且只能改变一次。
43(4). Iterator 和 for...of(Iterator遍历器的实现)
详见 JS 数据结构的内置接口,除非是自己创造一个项目十分通用的数据结构,否则很难遇到手写迭代器的场景,详见 MDN。
44(2). 循环语法比较及使用场景(for、forEach、for...in、for...of)
简单来说,for...of 是一个比较推荐且通用的循环方式,它是依赖于数据结构本身的迭代器的,就比如说我用 for of 去迭代数组是不可能把 length、map、push 这样的东西迭代出来的,它只会迭代 0、1、2...
但是 for in 不是,for in 是真的迭代万物,他会用 Object 的迭代器强制迭代所有给他的东西,你用 for in 迭代数组,它可以把数组的方法和属性都迭代出来,所以你也可以知道,这玩意会爬原型链
但是 for in 也不是万能的,因为 Object 的迭代器是不会把 symbol 给迭代出来的,所以 symbol 还是迭代不到,除非你写的那个数据结构的默认迭代器就能把 symbol 迭代出来,正常访问 symbol 仍需要单独去做。
45(3). Generator 及其异步方面的应用
46(3). async 、await
其实没啥好说的,async 就是把一个正常的函数的返回值包裹成 promise 返回,如果它本来返回的就是 promise 那就不做处理。
也就是说,async 可以确保其后面的函数是一个「异步函数」,确保其返回是一个 promise 实例。可以看作是同步变异步。
await 正相反,await 只能在 async 或者异步函数内使用(没有顶层 await 的情况),它的作用是强制「等」后面的异步操作执行完才执行后面的代码,看起来就像是异步转同步。
47(2~4). 几种异步方式的比较(setTimeout、Promise、Generator、async)
- setTimeout,setInterval 是延时将要执行的函数放入宏任务
- Promise、Generator、async 就是微任务,没啥可说的
48(3~4). class 基本语法及继承
这个主要讲的是 ES6 的 class 语法,里面也没什么太多可说的,因为 ES6 的 class 仍旧有点奇怪,和像 C++ 和 Java 的比如 private、protect、public 这样的关键字还是搭不上边,所以我更推荐直接学 TS 的 class。
不过 JS 的 extends 还是比较正常的,除了对于父类构造函数 super 的调用需要留心一下。
class parentNum {
parentNum
constructor(num) {
this.parentNum = num
}
say() {
console.log(`I'm a parent, my num is ${this.parentNum}`)
}
}
let p = new parentNum(1)
p.say() // I'm a parent, my num is 1
class Child extends Parent {
childNum
constructor(num1, num2) {
// 这里要注意,super 代指的是父类构造函数,也就是 Parent 的 constructor
// 如果不先调用 super,this.parentNum 是 undefined
// OOP 一般要求构建子类实例需要先构建父类实例,JS 里没有做强制要求
// 但是不调用 super 还是会导致子类用不了父类继承的属性,毕竟你都没初始化父类实例
super(num1)
this.childNum = num2
}
say() {
console.log(`I'm a child, my num is ${this.childNum}, and my parentNum is ${this.parentNum}`)
}
}
let c = new Child(1, 2)
c.say() // I'm a child, my num is 2, and my parentNum is 1
49(3+). 模块加载方案比较(CommonJS 和 ES6 的 Module)
主要就说一下 ES Module 和 Common JS 的模块区别,与其说是这两个模块的区别,不如说是浏览器 JS 与 Server JS 的区别,前面或许有提及,不同环境的 JS 只能算是遵守同一套语法规范,看起来很相似的不同语言,所以比较这两个模块系统的加载方式,是一个很诡异的问题——难道不是在问脚本语言与 Server 语言的差异么?
-
ES Module 只用于浏览器中的 JS;Common JS 则是 Server 端 JS 端默认模块方式
-
ES Module 运行于解释型环境,模块是异步加载的,引入是不可以写在具体函数执行时的;Common JS 的引用与运行都是同步的,模块也是同步加载,并且可以在函数执行之类的运行时时机,加载模块
-
语法显著不同,例如导入导出语法,常见来看,比如 Webpack 这类写 Node.js 的程序里面的语法就是 Common JS;平常前端开发写的 JS 也好,Vue / React 也好,都是 ES Module
至于浏览器的 JS 其实也有不少模块系统,而且也有通用于 ES Module 于 Common JS 等其他模块系统的模块系统,比如说 UMD,如果你这个 JS 库没动 DOM 也没搞什么多线程这种必须浏览器或者必须 Server 端才能做的事,可以打包为 UMD 包,这样 Common JS 和 ES Module 都可以用。
除了 UMD,浏览器中的 JS 还有 AMD 之类的其他的包形式,再比如说全局包之类的,就比较杂乱,都是些历史产物,知道 npm 给你装的依赖是 ES Module 的就行了(不排除一些是 UMD)
50(3+). ES6 模块加载与 CommonJS 加载的原理
不会,跳过吧,不想说了,放篇文章在这。