前端经典面试题合集
事件循环
默认代码从上到下执行,执行环境通过
script
来执行(宏任务)在代码执行过程中,调用定时器
promise
click
事件...不会立即执行,需要等待当前代码全部执行完毕给异步方法划分队列,分别存放到微任务(立即存放)和宏任务(时间到了或事情发生了才存放)到队列中
script
执行完毕后,会清空所有的微任务微任务执行完毕后,会渲染页面(不是每次都调用)
再去宏任务队列中看有没有到达时间的,拿出来其中一个执行
执行完毕后,按照上述步骤不停的循环
例子
自动执行的情况 会输出 listener1 listener2 task1 task2
如果手动点击 click 会一个宏任务取出来一个个执行,先执行 click 的宏任务,取出微任务去执行。会输出 listener1 task1 listener2 task2
1. 浏览器事件循环
涉及面试题:异步代码执行顺序?解释一下什么是
Event Loop
?
JavaScript 的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定 JavaScript 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?所以,为了避免复杂性,从一诞生,JavaScript 就是单线程,这已经成了这门语言的核心特征,将来也不会改变
js 代码执行过程中会有很多任务,这些任务总的分成两类:
同步任务
异步任务
当我们打开网站时,网页的渲染过程就是一大堆同步任务,比如页面骨架和页面元素的渲染。而像加载图片音乐之类占用资源大耗时久的任务,就是异步任务。,我们用导图来说明:
我们解释一下这张图:
同步和异步任务分别进入不同的执行"场所",同步的进入主线程,异步的进入 Event Table 并注册函数。
当指定的事情完成时,Event Table 会将这个函数移入 Event Queue。
主线程内的任务执行完毕为空,会去 Event Queue 读取对应的函数,进入主线程执行。
上述过程会不断重复,也就是常说的 Event Loop(事件循环)。
那主线程执行栈何时为空呢?js 引擎存在 monitoring process 进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去 Event Queue 那里检查是否有等待被调用的函数
以上就是 js 运行的整体流程
面试中该如何回答呢? 下面是我个人推荐的回答:
首先 js 是单线程运行的,在代码执行的时候,通过将不同函数的执行上下文压入执行栈中来保证代码的有序执行
在执行同步代码的时候,如果遇到了异步事件,js 引擎并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务
当同步事件执行完毕后,再将异步事件对应的回调加入到与当前执行栈中不同的另一个任务队列中等待执行
任务队列可以分为宏任务对列和微任务对列,当当前执行栈中的事件执行完毕后,js 引擎首先会判断微任务对列中是否有任务可以执行,如果有就将微任务队首的事件压入栈中执行
当微任务对列中的任务都执行完成后再去判断宏任务对列中的任务。
第一轮:主线程开始执行,遇到
setTimeout
,将 setTimeout 的回调函数丢到宏任务队列中,在往下执行new Promise
立即执行,输出 2,then 的回调函数丢到微任务队列中,再继续执行,遇到process.nextTick
,同样将回调函数扔到微任务队列,再继续执行,输出 5,当所有同步任务执行完成后看有没有可以执行的微任务,发现有 then 函数和nextTick
两个微任务,先执行哪个呢?process.nextTick
指定的异步任务总是发生在所有异步任务之前,因此先执行 process.nextTick 输出 4 然后执行 then 函数输出 3,第一轮执行结束。第二轮:从宏任务队列开始,发现 setTimeout 回调,输出 1 执行完毕,因此结果是 25431
JS
在执行的过程中会产生执行环境,这些执行环境会被顺序的加入到执行栈中。如果遇到异步的代码,会被挂起并加入到Task
(有多种task
) 队列中。一旦执行栈为空,Event
Loop
就会从Task
队列中拿出需要执行的代码并放入执行栈中执行,所以本质上来说JS
中的异步还是同步行为
不同的任务源会被分配到不同的
Task
队列中,任务源可以分为 微任务(microtask
) 和 宏任务(macrotask
)。在ES6
规范中,microtask
称为jobs
,macrotask
称为task
以上代码虽然
setTimeout
写在Promise
之前,但是因为Promise
属于微任务而setTimeout
属于宏任务
微任务
process.nextTick
promise
Object.observe
MutationObserver
宏任务
script
setTimeout
setInterval
setImmediate
I/O
网络请求完成、文件读写完成事件UI rendering
用户交互事件(比如鼠标点击、滚动页面、放大缩小等)
宏任务中包括了
script
,浏览器会先执行一个宏任务,接下来有异步代码的话就先执行微任务
所以正确的一次 Event loop 顺序是这样的
执行同步代码,这属于宏任务
执行栈为空,查询是否有微任务需要执行
执行所有微任务
必要的话渲染 UI
然后开始下一轮
Event loop
,执行宏任务中的异步代码
通过上述的
Event loop
顺序可知,如果宏任务中的异步代码有大量的计算并且需要操作DOM
的话,为了更快的响应界面响应,我们可以把操作DOM
放入微任务中
JavaScript 引擎首先从宏任务队列(macrotask queue)中取出第一个任务
执行完毕后,再将微任务(microtask queue)中的所有任务取出,按照顺序分别全部执行(这里包括不仅指开始执行时队列里的微任务),如果在这一步过程中产生新的微任务,也需要执行;
然后再从宏任务队列中取下一个,执行完毕后,再次将 microtask queue 中的全部取出,循环往复,直到两个 queue 中的任务都取完。
总结起来就是:
一次 Eventloop 循环会处理一个宏任务和所有这次循环中产生的微任务
。
2. Node 中的 Event loop
当 Node.js 开始启动时,会初始化一个 Eventloop,处理输入的代码脚本,这些脚本会进行 API 异步调用,
process.nextTick()
方法会开始处理事件循环。下面就是 Node.js 官网提供的Eventloop
事件循环参考流程
Node
中的Event loop
和浏览器中的不相同。Node
的Event loop
分为6
个阶段,它们会按照顺序反复运行
每次执行执行一个宏任务后会清空微任务(执行顺序和浏览器一致,在 node11 版本以上)
process.nextTick
node 中的微任务,当前执行栈的底部,优先级比promise
要高
整个流程分为六个阶段,当这六个阶段执行完一次之后,才可以算得上执行了一次 Eventloop 的循环过程。我们来分别看下这六个阶段都做了哪些事情。
Timers 阶段 :这个阶段执行
setTimeout
和setInterval
的回调函数,简单理解就是由这两个函数启动的回调函数。I/O callbacks 阶段 :这个阶段主要执行系统级别的回调函数,比如 TCP 连接失败的回调。
idle,prepare 阶段 :仅系统内部使用,你只需要知道有这 2 个阶段就可以。
poll 阶段 :
poll
阶段是一个重要且复杂的阶段,几乎所有I/O
相关的回调,都在这个阶段执行(除了setTimeout
、setInterval
、setImmediate
以及一些因为exception
意外关闭产生的回调)。检索新的 I/O 事件,执行与 I/O 相关的回调
,其他情况 Node.js 将在适当的时候在此阻塞。这也是最复杂的一个阶段,所有的事件循环以及回调处理都在这个阶段执行。这个阶段的主要流程如下图所示。
check 阶段 :
setImmediate()
回调函数在这里执行,setImmediate
并不是立马执行,而是当事件循环poll 中没有新的事件处理时就执行该部分
,如下代码所示。
在这一代码中有一个非常奇特的地方,就是 setImmediate
会在 setTimeout
之后输出。有以下几点原因:
setTimeout
如果不设置时间或者设置时间为0
,则会默认为1ms
主流程执行完成后,超过
1ms
时,会将setTimeout
回调函数逻辑插入到待执行回调函数poll
队列中;由于当前
poll
队列中存在可执行回调函数,因此需要先执行完,待完全执行完成后,才会执行check:setImmediate
。
因此这也验证了这句话,
先执行回调函数,再执行 setImmediate
close callbacks 阶段 :执行一些关闭的回调函数,如
socket.on('close', ...)
除了把 Eventloop 的宏任务细分到不同阶段外。node 还引入了一个新的任务队列
Process.nextTick()
可以认为,Process.nextTick()
会在上述各个阶段结束时,在进入下一个阶段之前立即执行
(优先级甚至超过 microtask
队列)
事件循环的主要包含微任务和宏任务。具体是怎么进行循环的呢
微任务 :在 Node.js 中微任务包含 2 种——
process.nextTick
和Promise
。微任务在事件循环中优先级是最高的
,因此在同一个事件循环中有其他任务存在时,优先执行微任务队列。并且process.nextTick 和 Promise
也存在优先级,process.nextTick
高于Promise
宏任务 :在 Node.js 中宏任务包含 4 种——
setTimeout
、setInterval
、setImmediate
和I/O
。宏任务在微任务执行之后执行,因此在同一个事件循环周期内,如果既存在微任务队列又存在宏任务队列,那么优先将微任务队列清空,再执行宏任务队列
我们可以看到有一个核心的主线程,它的执行阶段主要处理三个核心逻辑。
同步代码。
将异步任务插入到微任务队列或者宏任务队列中。
执行微任务或者宏任务的回调函数。在主线程处理回调函数的同时,也需要判断是否插入微任务和宏任务。根据优先级,先判断微任务队列是否存在任务,存在则先执行微任务,不存在则判断在宏任务队列是否有任务,有则执行。
分析下上面代码的执行过程
第一个事件循环主线程发起,因此先执行同步代码,所以先输出 start,然后输出 end
第一个事件循环主线程发起,因此先执行同步代码,所以先输出 start,然后输出 end;
再从上往下分析,遇到微任务,插入微任务队列,遇到宏任务,插入宏任务队列,分析完成后,微任务队列包含:
Promise.resolve 和 process.nextTick
,宏任务队列包含:fs.readFile 和 setTimeout
;先执行微任务队列,但是根据优先级,先执行
process.nextTick 再执行 Promise.resolve
,所以先输出nextTick callback
再输出Promise callback
;再执行宏任务队列,根据
宏任务插入先后顺序执行 setTimeout 再执行 fs.readFile
,这里需要注意,先执行setTimeout
由于其回调时间较短,因此回调也先执行,并非是setTimeout
先执行所以才先执行回调函数,但是它执行需要时间肯定大于1ms
,所以虽然fs.readFile
先于setTimeout
执行,但是setTimeout
执行更快,所以先输出setTimeout
,最后输出read file success
。
当微任务和宏任务又产生新的微任务和宏任务时,又应该如何处理呢?如下代码所示:
在上面代码中,有 2 个宏任务和 1 个微任务,宏任务是 setTimeout 和 fs.readFile
,微任务是 Promise.resolve
。
整个过程优先执行主线程的第一个事件循环过程,所以先执行同步逻辑,先输出 2。
接下来执行微任务,输出
poll callback
。再执行宏任务中的
fs.readFile 和 setTimeout
,由于fs.readFile
优先级高,先执行fs.readFile
。但是处理时间长于1ms
,因此会先执行setTimeout
的回调函数,输出1
。这个阶段在执行过程中又会产生新的宏任务fs.readFile
,因此又将该fs.readFile 插入宏任务队列
最后由于只剩下宏任务了
fs.readFile
,因此执行该宏任务,并等待处理完成后的回调,输出read file sync success
。
Process.nextick() 和 Vue 的 nextick
Node.js
和浏览器端宏任务队列的另一个很重要的不同点是,浏览器端任务队列每轮事件循环仅出队一个回调函数接着去执行微任务队列;而Node.js
端只要轮到执行某个宏任务队列,则会执行完队列中所有的当前任务,但是当前轮次新添加到队尾的任务则会等到下一轮次才会执行。
上面介绍的都是
macrotask
的执行情况,microtask
会在以上每个阶段完成后立即执行
Node
中的process.nextTick
会先于其他microtask
执行
对于
microtask
来说,它会在以上每个阶段完成前清空microtask
队列,下图中的Tick
就代表了microtask
谁来启动这个循环过程,循环条件是什么?
当 Node.js 启动后,会初始化事件循环,处理已提供的输入脚本,它可能会先调用一些异步的 API、调度定时器,或者
process.nextTick()
,然后再开始处理事件循环。因此可以这样理解,Node.js 进程启动后,就发起了一个新的事件循环,也就是事件循环的起点。
总结来说,Node.js 事件循环的发起点有 4 个:
Node.js
启动后;setTimeout
回调函数;setInterval
回调函数;也可能是一次
I/O
后的回调函数。
无限循环有没有终点
当所有的微任务和宏任务都清空的时候,虽然当前没有任务可执行了,但是也并不能代表循环结束了。因为可能存在当前还未回调的异步 I/O,所以这个循环是没有终点的,只要进程在,并且有新的任务存在,就会去执行
Node.js 是单线程的还是多线程的?
主线程是单线程执行的
,但是 Node.js存在多线程执行
,多线程包括setTimeout 和异步 I/O 事件
。其实 Node.js 还存在其他的线程,包括垃圾回收、内存优化
等
EventLoop 对渲染的影响
想必你之前在业务开发中也遇到过
requestIdlecallback 和 requestAnimationFrame
,这两个函数在我们之前的内容中没有讲过,但是当你开始考虑它们在 Eventloop 的生命周期的哪一步触发,或者这两个方法的回调会在微任务队列还是宏任务队列执行的时候,才发现好像没有想象中那么简单。这两个方法其实也并不属于 JS 的原生方法,而是浏览器宿主环境提供的方法,因为它们牵扯到另一个问题:渲染。我们知道浏览器作为一个复杂的应用是多线程工作的,除了运行 JS 的线程外,还有渲染线程、定时器触发线程、HTTP 请求线程,等等。JS 线程可以读取并且修改 DOM,而渲染线程也需要读取 DOM,这是一个典型的多线程竞争临界资源的问题。所以浏览器就把这两个线程设计成互斥的,即同时只能有一个线程在执行
渲染原本就不应该出现在 Eventloop 相关的知识体系里,但是因为 Eventloop 显然是在讨论 JS 如何运行的问题,而渲染则是浏览器另外一个线程的工作。但是
requestAnimationFrame
的出现却把这两件事情给关联起来通过调用
requestAnimationFrame
我们可以在下次渲染之前执行回调函数。那下次渲染具体是哪个时间点呢?渲染和 Eventloop 有什么关系呢?简单来说,就是在每一次
Eventloop
的末尾,判断当前页面是否处于渲染时机,就是重新渲染
有屏幕的硬件限制,比如 60Hz 刷新率,简而言之就是 1 秒刷新了 60 次,16.6ms 刷新一次。这个时候浏览器的渲染间隔时间就没必要小于
16.6ms
,因为就算渲染了屏幕上也看不到。当然浏览器也不能保证一定会每 16.6ms 会渲染一次,因为还会受到处理器的性能、JavaScript 执行效率等其他因素影响。回到
requestAnimationFrame
,这个 API 保证在下次浏览器渲染之前一定会被调用,实际上我们完全可以把它看成是一个高级版的setInterval
。它们都是在一段时间后执行回调,但是前者的间隔时间是由浏览器自己不断调整的,而后者只能由用户指定。这样的特性也决定了requestAnimationFrame
更适合用来做针对每一帧来修改的动画效果当然
requestAnimationFrame
不是Eventloop
里的宏任务,或者说它并不在Eventloop
的生命周期里,只是浏览器又开放的一个在渲染之前发生的新的 hook。另外需要注意的是微任务的认知概念也需要更新,在执行 animation callback 时也有可能产生微任务(比如 promise 的 callback),会放到 animation queue 处理完后再执行。所以微任务并不是像之前说的那样在每一轮 Eventloop 后处理,而是在 JS 的函数调用栈清空后处理
但是 requestIdlecallback
却是一个更好理解的概念。当宏任务队列中没有任务可以处理时,浏览器可能存在“空闲状态”。这段空闲时间可以被 requestIdlecallback
利用起来执行一些优先级不高、不必立即执行的任务,如下图所示:
执行上下文
当执行 JS 代码时,会产生三种执行上下文
全局执行上下文
函数执行上下文
eval
执行上下文
每个执行上下文中都有三个重要的属性
变量对象(
VO
),包含变量、函数声明和函数的形参,该属性只能在全局上下文中访问作用域链(
JS
采用词法作用域,也就是说变量的作用域是在定义时就决定了)this
对于上述代码,执行栈中有两个上下文:全局上下文和函数 foo 上下文。
对于全局上下文来说,
VO
大概是这样的
对于函数
foo
来说,VO
不能访问,只能访问到活动对象(AO
)
对于作用域链,可以把它理解成包含自身变量对象和上级变量对象的列表,通过
[[Scope]]
属性查找上级变量
接下来让我们看一个老生常谈的例子,
var
想必以上的输出大家肯定都已经明白了,这是因为函数和变量提升的原因。通常提升的解释是说将声明的代码移动到了顶部,这其实没有什么错误,便于大家理解。但是更准确的解释应该是:在生成执行上下文时,会有两个阶段。第一个阶段是创建的阶段(具体步骤是创建
VO
),JS
解释器会找出需要提升的变量和函数,并且给他们提前在内存中开辟好空间,函数的话会将整个函数存入内存中,变量只声明并且赋值为undefined
,所以在第二个阶段,也就是代码执行阶段,我们可以直接提前使用。
在提升的过程中,相同的函数会覆盖上一个函数,并且函数优先于变量提升
var
会产生很多错误,所以在ES6
中引入了let
。let
不能在声明前使用,但是这并不是常说的let
不会提升,let
提升了声明但没有赋值,因为临时死区导致了并不能在声明前使用。
对于非匿名的立即执行函数需要注意以下一点
因为当
JS
解释器在遇到非匿名的立即执行函数时,会创建一个辅助的特定对象,然后将函数名称作为这个对象的属性,因此函数内部才可以访问到foo
,但是这个值又是只读的,所以对它的赋值并不生效,所以打印的结果还是这个函数,并且外部的值也没有发生更改。
总结
执行上下文可以简单理解为一个对象:
它包含三个部分:
变量对象(
VO
)作用域链(词法作用域)
this
指向
它的类型:
全局执行上下文
函数执行上下文
eval
执行上下文
代码执行过程:
创建 全局上下文 (
global EC
)全局执行上下文 (
caller
) 逐行 自上而下 执行。遇到函数时,函数执行上下文 (callee
) 被push
到执行栈顶层函数执行上下文被激活,成为
active EC
, 开始执行函数中的代码,caller
被挂起函数执行完后,
callee
被pop
移除出执行栈,控制权交还全局上下文 (caller
),继续执行
同步和异步的区别
同步指的是当一个进程在执行某个请求时,如果这个请求需要等待一段时间才能返回,那么这个进程会一直等待下去,直到消息返回为止再继续向下执行。
异步指的是当一个进程在执行某个请求时,如果这个请求需要等待一段时间才能返回,这个时候进程会继续往下执行,不会阻塞等待消息的返回,当消息返回时系统再通知进程进行处理。
作用域
作用域: 作用域是定义变量的区域,它有一套访问变量的规则,这套规则来管理浏览器引擎如何在当前作用域以及嵌套的作用域中根据变量(标识符)进行变量查找
作用域链: 作用域链的作用是保证对执行环境有权访问的所有变量和函数的有序访问,通过作用域链,我们可以访问到外层环境的变量和 函数。
作用域链的本质上是一个指向变量对象的指针列表。变量对象是一个包含了执行环境中所有变量和函数的对象。作用域链的前 端始终都是当前执行上下文的变量对象。全局执行上下文的变量对象(也就是全局对象)始终是作用域链的最后一个对象。
当我们查找一个变量时,如果当前执行环境中没有找到,我们可以沿着作用域链向后查找
作用域链的创建过程跟执行上下文的建立有关....
作用域可以理解为变量的可访问性,总共分为三种类型,分别为:
全局作用域
函数作用域
块级作用域,ES6 中的
let
、const
就可以产生该作用域
其实看完前面的闭包、this
这部分内部的话,应该基本能了解作用域的一些应用。
一旦我们将这些作用域嵌套起来,就变成了另外一个重要的知识点「作用域链」,也就是 JS 到底是如何访问需要的变量或者函数的。
首先作用域链是在定义时就被确定下来的,和箭头函数里的 this 一样,后续不会改变,JS 会一层层往上寻找需要的内容。
其实作用域链这个东西我们在闭包小结中已经看到过它的实体了:
[[Scopes]]
图中的 [[Scopes]]
是个数组,作用域的一层层往上寻找就等同于遍历 [[Scopes]]
。
1. 全局作用域
全局变量是挂载在 window 对象下的变量,所以在网页中的任何位置你都可以使用并且访问到这个全局变量
从这段代码中我们可以看到,globalName 这个变量无论在什么地方都是可以被访问到的,所以它就是全局变量。而在 getName 函数中作为局部变量的 name 变量是不具备这种能力的
当然全局作用域有相应的缺点,我们定义很多全局变量的时候,会容易引起变量命名的冲突,所以在定义变量的时候应该注意作用域的问题。
2. 函数作用域
函数中定义的变量叫作函数变量,这个时候只能在函数内部才能访问到它,所以它的作用域也就是函数的内部,称为函数作用域
除了这个函数内部,其他地方都是不能访问到它的。同时,当这个函数被执行完之后,这个局部变量也相应会被销毁。所以你会看到在 getName 函数外面的 name 是访问不到的
3. 块级作用域
ES6 中新增了块级作用域,最直接的表现就是新增的 let 关键词,使用 let 关键词定义的变量只能在块级作用域中被访问,有“暂时性死区”的特点,也就是说这个变量在定义之前是不能被使用的。
在 JS 编码过程中 if 语句
及 for
语句后面 {...}
这里面所包括的,就是块级作用域
从这段代码可以看出,变量 a 是在
if 语句{...}
中由let 关键词
进行定义的变量,所以它的作用域是 if 语句括号中的那部分,而在外面进行访问 a 变量是会报错的,因为这里不是它的作用域。所以在 if 代码块的前后输出 a 这个变量的结果,控制台会显示 a 并没有定义
迭代查询与递归查询
实际上,DNS 解析是一个包含迭代查询和递归查询的过程。
递归查询指的是查询请求发出后,域名服务器代为向下一级域名服务器发出请求,最后向用户返回查询的最终结果。使用递归 查询,用户只需要发出一次查询请求。
迭代查询指的是查询请求后,域名服务器返回单次查询的结果。下一级的查询由用户自己请求。使用迭代查询,用户需要发出 多次的查询请求。
一般我们向本地 DNS 服务器发送请求的方式就是递归查询,因为我们只需要发出一次请求,然后本地 DNS 服务器返回给我 们最终的请求结果。而本地 DNS 服务器向其他域名服务器请求的过程是迭代查询的过程,因为每一次域名服务器只返回单次 查询的结果,下一级的查询由本地 DNS 服务器自己进行。
解析 URL 参数为对象
数字证书是什么?
现在的方法也不一定是安全的,因为没有办法确定得到的公钥就一定是安全的公钥。可能存在一个中间人,截取了对方发给我们的公钥,然后将他自己的公钥发送给我们,当我们使用他的公钥加密后发送的信息,就可以被他用自己的私钥解密。然后他伪装成我们以同样的方法向对方发送信息,这样我们的信息就被窃取了,然而自己还不知道。为了解决这样的问题,可以使用数字证书。
首先使用一种 Hash 算法来对公钥和其他信息进行加密,生成一个信息摘要,然后让有公信力的认证中心(简称 CA )用它的私钥对消息摘要加密,形成签名。最后将原始的信息和签名合在一起,称为数字证书。当接收方收到数字证书的时候,先根据原始信息使用同样的 Hash 算法生成一个摘要,然后使用公证处的公钥来对数字证书中的摘要进行解密,最后将解密的摘要和生成的摘要进行对比,就能发现得到的信息是否被更改了。
这个方法最要的是认证中心的可靠性,一般浏览器里会内置一些顶层的认证中心的证书,相当于我们自动信任了他们,只有这样才能保证数据的安全。
介绍一下 webpack scope hosting
作用域提升,将分散的模块划分到同一个作用域中,避免了代码的重复引入,有效减少打包后的代码体积和运行时的内存损耗;
说一下 SPA 单页面有什么优缺点?
参考:前端进阶面试题详细解答
寄生组合继承
题目描述:实现一个你认为不错的 js 继承方式
实现代码如下:
事件总线(发布订阅模式)
合成事件原理
为了解决跨浏览器兼容性问题,
React
会将浏览器原生事件(Browser Native Event
)封装为合成事件(SyntheticEvent
)传入设置的事件处理器中。这里的合成事件提供了与原生事件相同的接口,不过它们屏蔽了底层浏览器的细节差异,保证了行为的一致性。另外有意思的是,React
并没有直接将事件附着到子元素上,而是以单一事件监听器的方式将所有的事件发送到顶层进行处理。这样React
在更新DOM
的时候就不需要考虑如何去处理附着在DOM
上的事件监听器,最终达到优化性能的目的
所有的事件挂在 document 上,DOM 事件触发后冒泡到 document;React 找到对应的组件,造出一个合成事件出来;并按组件树模拟一遍事件冒泡。
event 不是原生的,是 SyntheticEvent 合成事件对象
和 Vue 事件不同,和 DOM 事件也不同
React 17 之前的事件冒泡流程图
所以这就造成了,在一个页面中,只能有一个版本的 React。如果有多个版本,事件就乱套了。值得一提的是,这个问题在 React 17 中得到了解决,事件委托不再挂在 document 上,而是挂在 DOM 容器上,也就是
ReactDom.Render
所调用的节点上。
React 17 后的事件冒泡流程图
那到底哪些事件会被捕获生成合成事件呢?可以从 React 的源码测试文件中一探究竟。下面的测试快照中罗列了大量的事件名,也只有在这份快照中的事件,才会被捕获生成合成事件。
如果 DOM 上绑定了过多的事件处理函数,整个页面响应以及内存占用可能都会受到影响。React 为了避免这类 DOM 事件滥用,同时屏蔽底层不同浏览器之间的事件系统的差异,实现了一个中间层 - SyntheticEvent
当用户在为 onClick 添加函数时,React 并没有将 Click 绑定到 DOM 上面
而是在 document 处监听所有支持的事件,当事件发生并冒泡至 document 处时,React 将事件内容封装交给中间层 SyntheticEvent (负责所有事件合成)
所以当事件触发的时候, 对使用统一的分发函数 dispatchEvent 将指定函数执行
为何要合成事件
兼容性和跨平台
挂在统一的 document 上,减少内存消耗,避免频繁解绑
方便事件的统一管理(事务机制)
dispatchEvent 事件机制
DNS 同时使用 TCP 和 UDP 协议?
DNS 占用 53 号端口,同时使用 TCP 和 UDP 协议。 (1)在区域传输的时候使用 TCP 协议
辅域名服务器会定时(一般 3 小时)向主域名服务器进行查询以便了解数据是否有变动。如有变动,会执行一次区域传送,进行数据同步。区域传送使用 TCP 而不是 UDP,因为数据同步传送的数据量比一个请求应答的数据量要多得多。
TCP 是一种可靠连接,保证了数据的准确性。
(2)在域名解析的时候使用 UDP 协议
客户端向 DNS 服务器查询域名,一般返回的内容都不超过 512 字节,用 UDP 传输即可。不用经过三次握手,这样 DNS 服务器负载更低,响应更快。理论上说,客户端也可以指定向 DNS 服务器查询时用 TCP,但事实上,很多 DNS 服务器进行配置的时候,仅支持 UDP 查询包。
Promise
这里你谈
promise
的时候,除了将他解决的痛点以及常用的API
之外,最好进行拓展把eventloop
带进来好好讲一下,microtask
(微任务)、macrotask
(任务) 的执行顺序,如果看过promise
源码,最好可以谈一谈 原生Promise
是如何实现的。Promise
的关键点在于callback
的两个参数,一个是resovle
,一个是reject
。还有就是Promise
的链式调用(Promise.then()
,每一个then
都是一个责任人)
Promise
是ES6
新增的语法,解决了回调地狱的问题。可以把
Promise
看成一个状态机。初始是pending
状态,可以通过函数resolve
和reject
,将状态转变为resolved
或者rejected
状态,状态一旦改变就不能再次变化。then
函数会返回一个Promise
实例,并且该返回值是一个新的实例而不是之前的实例。因为Promise
规范规定除了pending
状态,其他状态是不可以改变的,如果返回的是一个相同实例的话,多个then
调用就失去意义了。 对于then
来说,本质上可以把它看成是flatMap
1. Promise 的基本情况
简单来说它就是一个容器,里面保存着某个未来才会结束的事件(通常是异步操作)的结果。从语法上说,Promise 是一个对象,从它可以获取异步操作的消息
一般 Promise 在执行过程中,必然会处于以下几种状态之一。
待定(
pending
):初始状态,既没有被完成,也没有被拒绝。已完成(
fulfilled
):操作成功完成。已拒绝(
rejected
):操作失败。
待定状态的
Promise
对象执行的话,最后要么会通过一个值完成,要么会通过一个原因被拒绝。当其中一种情况发生时,我们用Promise
的then
方法排列起来的相关处理程序就会被调用。因为最后Promise.prototype.then
和Promise.prototype.catch
方法返回的是一个Promise
, 所以它们可以继续被链式调用
关于 Promise 的状态流转情况,有一点值得注意的是,内部状态改变之后不可逆,你需要在编程过程中加以注意。文字描述比较晦涩,我们直接通过一张图就能很清晰地看出 Promise 内部状态流转的情况
从上图可以看出,我们最开始创建一个新的 Promise
返回给 p1
,然后开始执行,状态是 pending,当执行 resolve
之后状态就切换为 fulfilled
,执行 reject
之后就变为 rejected
的状态
2. Promise 的静态方法
all 方法
语法:
Promise.all(iterable)
参数: 一个可迭代对象,如
Array
。描述: 此方法对于汇总多个
promise
的结果很有用,在 ES6 中可以将多个Promise.all
异步请求并行操作,返回结果一般有下面两种情况。当所有结果成功返回时按照请求顺序返回成功结果。
当其中有一个失败方法时,则进入失败方法
我们来看下业务的场景,对于下面这个业务场景页面的加载,将多个请求合并到一起,用 all 来实现可能效果会更好,请看代码片段
allSettled
方法Promise.allSettled
的语法及参数跟Promise.all
类似,其参数接受一个Promise
的数组,返回一个新的Promise
。唯一的不同在于,执行完之后不会失败
,也就是说当Promise.allSettled
全部处理完成后,我们可以拿到每个Promise
的状态,而不管其是否处理成功我们来看一下用
allSettled
实现的一段代码
从上面代码中可以看到,
Promise.allSettled
最后返回的是一个数组,记录传进来的参数中每个 Promise 的返回值,这就是和 all 方法不太一样的地方。
any
方法语法:
Promise.any(iterable)
参数:
iterable
可迭代的对象,例如Array
。描述:
any
方法返回一个Promise
,只要参数Promise
实例有一个变成fulfilled
状态,最后any
返回的实例就会变成fulfilled
状态;如果所有参数Promise
实例都变成rejected
状态,包装实例就会变成rejected
状态。
从改造后的代码中可以看出,只要其中一个
Promise
变成fulfilled
状态,那么any
最后就返回这个p romise
。由于上面resolved
这个 Promise 已经是resolve
的了,故最后返回结果为2
race
方法语法:
Promise.race(iterable)
参数:
iterable
可迭代的对象,例如Array
。描述:
race
方法返回一个Promise
,只要参数的Promise
之中有一个实例率先改变状态,则race
方法的返回状态就跟着改变。那个率先改变的Promise
实例的返回值,就传递给race
方法的回调函数我们来看一下这个业务场景,对于图片的加载,特别适合用 race 方法来解决,将图片请求和超时判断放到一起,用 race 来实现图片的超时判断。请看代码片段。
promise 手写实现,面试够用版:
深浅拷贝
浅拷贝:只考虑对象类型。
简单版深拷贝:只考虑普通对象属性,不考虑内置对象和函数。
复杂版深克隆:基于简单版的基础上,还考虑了内置对象比如 Date、RegExp 等对象和函数以及解决了循环引用的问题。
发布订阅模式和观察者模式
1. 发布/订阅模式
发布/订阅模式
订阅者
发布者
信号中心
我们假定,存在一个"信号中心",某个任务执行完成,就向信号中心"发布"(publish)一个信 号,其他任务可以向信号中心"订阅"(subscribe)这个信号,从而知道什么时候自己可以开始执 行。这就叫做"发布/订阅模式"(publish-subscribe pattern)
Vue 的自定义事件
兄弟组件通信过程
模拟 Vue 自定义事件的实现
2. 观察者模式
观察者(订阅者) --
Watcher
update()
:当事件发生时,具体要做的事情目标(发布者) --
Dep
subs
数组:存储所有的观察者addSub()
:添加观察者notify()
:当事件发生,调用所有观察者的update()
方法没有事件中心
3. 总结
观察者模式是由具体目标调度,比如当事件触发,
Dep
就会去调用观察者的方法,所以观察者模 式的订阅者与发布者之间是存在依赖的发布/订阅模式由统一调度中心调用,因此发布者和订阅者不需要知道对方的存在
手写题:实现柯里化
预先设置一些参数
柯里化是什么:是指这样一个函数,它接收函数 A,并且能返回一个新的函数,这个新的函数能够处理函数 A 的剩余参数
New 操作符做了什么事情?
组件之间通信
父子组件通信
自定义事件
redux 和 context
context 如何运用
父组件向其下所有子孙组件传递信息
如一些简单的信息:主题、语言
复杂的公共信息用 redux
在跨层级通信中,主要分为一层或多层的情况
如果只有一层,那么按照 React 的树形结构进行分类的话,主要有以下三种情况:
父组件向子组件通信
,子组件向父组件通信
以及平级的兄弟组件间互相通信
。在父与子的情况下 ,因为 React 的设计实际上就是传递
Props
即可。那么场景体现在容器组件与展示组件之间,通过Props
传递state
,让展示组件受控。在子与父的情况下 ,有两种方式,分别是回调函数与实例函数。回调函数,比如输入框向父级组件返回输入内容,按钮向父级组件传递点击事件等。实例函数的情况有些特别,主要是在父组件中
通过 React 的 ref API 获取子组件的实例
,然后是通过实例调用子组件的实例函数
。这种方式在过去常见于 Modal 框的显示与隐藏多层级间的数据通信,有两种情况 。第一种是一个容器中包含了多层子组件,需要最底部的子组件与顶部组件进行通信。在这种情况下,如果不断透传 Props 或回调函数,不仅代码层级太深,后续也很不好维护。第二种是两个组件不相关,在整个 React 的组件树的两侧,完全不相交。那么基于多层级间的通信一般有三个方案。
第一个是使用 React 的
Context API
,最常见的用途是做语言包国际化第二个是使用全局变量与事件。
第三个是使用状态管理框架,比如 Flux、Redux 及 Mobx。优点是由于引入了状态管理,使得项目的开发模式与代码结构得以约束,缺点是学习成本相对较高
如何设计 React 组件
React 组件应从设计与工程实践
两个方向进行探讨
从设计上而言,社区主流分类的方案是展示组件与灵巧组件
展示组件内部没有状态管理,仅仅用于最简单的展示表达
。展示组件中最基础的一类组件称作代理组件。代理组件常用于封装常用属性、减少重复代码。很经典的场景就是引入 Antd 的 Button 时,你再自己封一层。如果未来需要替换掉 Antd 或者需要在所有的 Button 上添加一个属性,都会非常方便。基于代理组件的思想还可以继续分类,分为样式组件与布局组件两种,分别是将样式与布局内聚在自己组件内部。从工程实践而言,通过文件夹划分的方式切分代码。我初步常用的分割方式是将页面单独建立一个目录,将复用性略高的 components 建立一个目录,在下面分别建立 basic、container 和 hoc 三类。这样可以保证无法复用的业务逻辑代码尽量留在 Page 中,而可以抽象复用的部分放入 components 中。其中 basic 文件夹放展示组件,由于展示组件本身与业务关联性较低,所以可以使用 Storybook 进行组件的开发管理,提升项目的工程化管理能力
评论