答题大纲
-
先说基本知识点,宏任务、微任务有哪些
-
说事件循环机制过程,边说边画图出来
-
说async/await执行顺序注意,可以把 chrome 的优化,做法其实是违法了规范的,V8 团队的PR这些自信点说出来,显得你很好学,理解得很详细,很透彻。
-
把node的事件循环也说一下,重复1、2、3点,node中的第3点要说的是node11前后的事件循环变动点
avaScript代码的执行过程中,除了依靠函数调用栈来搞定函数的执行顺序外,还依靠任务队列(task queue)来搞定另外一些代码的执行。整个执行过程,我们称为事件循环过程。一个线程中,事件循环是唯一的,但是任务队列可以拥有多个。任务队列又分为macro-task(宏任务)与micro-task(微任务),在最新标准中,它们被分别称为task与jobs。
macro-task大概包括:
- script(整体代码)
- setTimeout
- setInterval
- setImmediate
- I/O
- UI render
micro-task大概包括:
-
process.nextTick
-
Promise
-
Async/Await(实际就是promise)
-
MutationObserver(html5新特性)
总的结论就是,执行宏任务,然后执行该宏任务产生的微任务,若微任务在执行过程中产生了新的微任务,则继续执行微任务,微任务执行完毕后,再回到宏任务中进行下一轮循环
async/await执行顺序
我们知道async隐式返回 Promise 作为结果的函数,那么可以简单理解为,await后面的函数执行完毕时,await会产生一个微任务(Promise.then是微任务)。但是我们要注意这个微任务产生的时机,它是执行完await之后,直接跳出async函数,执行其他代码(此处就是协程的运作,A暂停执行,控制权交给B)。其他代码执行完毕后,再回到async函数去执行剩下的代码,然后把await后面的代码注册到微任务队列当中。
-
如果await 后面直接跟的为一个变量,比如:await 1;这种情况的话相当于直接把await后面的代码注册为一个微任务,可以简单理解为promise.then(await下面的代码)。然后跳出async1函数,执行其他代码,当遇到promise函数的时候,会注册promise.then()函数到微任务队列,注意此时微任务队列里面已经存在await后面的微任务。所以这种情况会先执行await后面的代码(async1 end),再执行async1函数后面注册的微任务代码(promise1,promise2)。
-
如果await后面跟的是一个异步函数的调用,比如上面的代码,将代码改成这样:
chrome优化 对 await后跟 同步任务做了优化,之前是按照一个异步任务。现在是如果是同步任务则按同步执行,但await语句之后算异步
node 中的事件循环
浏览器中有事件循环,node 中也有,事件循环是 node 处理非阻塞 I/O 操作的机制,node中事件循环的实现是依靠的libuv引擎。由于 node 11 之后,事件循环的一些原理发生了变化,这里就以新的标准去讲,最后再列上变化点让大家了解前因后果。
宏任务和微任务
node 中也有宏任务和微任务,与浏览器中的事件循环类似,其中,
macro-task 大概包括:
- setTimeout
- setInterval
- setImmediate
- script(整体代码)
- I/O 操作等。
micro-task 大概包括:
-
process.nextTick(与普通微任务有区别,在微任务队列执行之前执行)
-
new Promise().then(回调)等。
node事件循环整体理解
图中的每个框被称为事件循环机制的一个阶段,每个阶段都有一个 FIFO 队列来执行回调。虽然每个阶段都是特殊的,但通常情况下,当事件循环进入给定的阶段时,它将执行特定于该阶段的任何操作,然后执行该阶段队列中的回调,直到队列用尽或最大回调数已执行。当该队列已用尽或达到回调限制,事件循环将移动到下一阶段。
因此,从上面这个简化图中,我们可以分析出 node 的事件循环的阶段顺序为:
输入数据阶段(incoming data)->轮询阶段(poll)->检查阶段(check)->关闭事件回调阶段(close callback)->定时器检测阶段(timers)->I/O事件回调阶段(I/O callbacks)->闲置阶段(idle, prepare)->轮询阶段...
阶段概述
- 定时器检测阶段(timers):本阶段执行 timer 的回调,即 setTimeout、setInterval 里面的回调函数。
- I/O事件回调阶段(I/O callbacks):执行延迟到下一个循环迭代的 I/O 回调,即上一轮循环中未被执行的一些I/O回调。
- 闲置阶段(idle, prepare):仅系统内部使用。
- 轮询阶段(poll):检索新的 I/O 事件;执行与 I/O 相关的回调(几乎所有情况下,除了关闭的回调函数,那些由计时器和 setImmediate() 调度的之外),其余情况 node 将在适当的时候在此阻塞。
- 检查阶段(check):setImmediate() 回调函数在这里执行
- 关闭事件回调阶段(close callback):一些关闭的回调函数,如:socket.on('close', ...)。
三大重点阶段
日常开发中的绝大部分异步任务都是在 poll、check、timers 这3个阶段处理的,所以我们来重点看看。
timers
timers 阶段会执行 setTimeout 和 setInterval 回调,并且是由 poll 阶段控制的。 同样,在 Node 中定时器指定的时间也不是准确时间,只能是尽快执行。
poll
poll 是一个至关重要的阶段,poll 阶段的执行逻辑流程图如下:
如果当前已经存在定时器,而且有定时器到时间了,拿出来执行,eventLoop 将回到 timers 阶段。
如果没有定时器, 会去看回调函数队列。
- 如果 poll 队列不为空,会遍历回调队列并同步执行,直到队列为空或者达到系统限制
- 如果 poll 队列为空时,会有两件事发生
-
- 如果有 setImmediate 回调需要执行,poll 阶段会停止并且进入到 check 阶段执行回调
- 如果没有 setImmediate 回调需要执行,会等待回调被加入到队列中并立即执行回调,这里同样会有个超时时间设置防止一直等待下去,一段时间后自动进入 check 阶段。
check
check 阶段。这是一个比较简单的阶段,直接执行 setImmdiate 的回调。
process.nextTick
process.nextTick 是一个独立于 eventLoop 的任务队列。
在每一个 eventLoop 阶段完成后会去检查 nextTick 队列,如果里面有任务,会让这部分任务优先于微任务执行。
看一个例子:
setImmediate(() => { console.log('timeout1') Promise.resolve().then(() => console.log('promise resolve')) process.nextTick(() => console.log('next tick1')) }); setImmediate(() => { console.log('timeout2') process.nextTick(() => console.log('next tick2')) }); setImmediate(() => console.log('timeout3')); setImmediate(() => console.log('timeout4')); 复制代码
- 在 node11 之前,因为每一个 eventLoop 阶段完成后会去检查 nextTick 队列,如果里面有任务,会让这部分任务优先于微任务执行,因此上述代码是先进入 check 阶段,执行所有 setImmediate,完成之后执行 nextTick 队列,最后执行微任务队列,因此输出为timeout1=>timeout2=>timeout3=>timeout4=>next tick1=>next tick2=>promise resolve
- 在 node11 之后,process.nextTick 是微任务的一种,因此上述代码是先进入 check 阶段,执行一个 setImmediate 宏任务,然后执行其微任务队列,再执行下一个宏任务及其微任务,因此输出为timeout1=>next tick1=>promise resolve=>timeout2=>next tick2=>timeout3=>timeout4
node 版本差异说明
这里主要说明的是 node11 前后的差异,因为 node11 之后一些特性已经向浏览器看齐了,总的变化一句话来说就是,如果是 node11 版本一旦执行一个阶段里的一个宏任务(setTimeout,setInterval和setImmediate)就立刻执行对应的微任务队列,一起来看看吧~
timers 阶段的执行时机变化
setTimeout(()=>{
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(()=>{
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
- 如果是 node11 版本一旦执行一个阶段里的一个宏任务(setTimeout,setInterval和setImmediate)就立刻执行微任务队列,这就跟浏览器端运行一致,最后的结果为timer1=>promise1=>timer2=>promise2
- 如果是 node10 及其之前版本要看第一个定时器执行完,第二个定时器是否在完成队列中.
-
- 如果是第二个定时器还未在完成队列中,最后的结果为timer1=>promise1=>timer2=>promise2
- 如果是第二个定时器已经在完成队列中,则最后的结果为timer1=>timer2=>promise1=>promise2
check 阶段的执行时机变化
setImmediate(() => console.log('immediate1'));
setImmediate(() => {
console.log('immediate2')
Promise.resolve().then(() => console.log('promise resolve'))
});
setImmediate(() => console.log('immediate3'));
setImmediate(() => console.log('immediate4'));
- 如果是 node11 后的版本,会输出immediate1=>immediate2=>promise resolve=>immediate3=>immediate4
- 如果是 node11 前的版本,会输出immediate1=>immediate2=>immediate3=>immediate4=>promise resolve
nextTick 队列的执行时机变化
setImmediate(() => console.log('timeout1'));
setImmediate(() => {
console.log('timeout2')
process.nextTick(() => console.log('next tick'))
});
setImmediate(() => console.log('timeout3'));
setImmediate(() => console.log('timeout4'));
- 如果是 node11 后的版本,会输出timeout1=>timeout2=>next tick=>timeout3=>timeout4
- 如果是 node11 前的版本,会输出timeout1=>timeout2=>timeout3=>timeout4=>next tick
以上几个例子,你应该就能清晰感受到它的变化了,反正记着一个结论,如果是 node11 版本一旦执行一个阶段里的一个宏任务(setTimeout,setInterval和setImmediate)就立刻执行对应的微任务队列。
node 和 浏览器 eventLoop的主要区别
两者最主要的区别在于浏览器中的微任务是在每个相应的宏任务中执行的,而nodejs中的微任务是在不同阶段之间执行的。
JavaScript的确是一门单线程语言,但是浏览器UI是多线程的,异步任务借助浏览器的线程和JavaScript的执行机制实现。 例如,setTimeout就借助浏览器定时器触发线程的计时功能来实现。
浏览器线程
- GUI渲染线程
-
- 绘制页面,解析HTML、CSS,构建DOM树等
- 页面的重绘和重排
- 与JS引擎互斥(JS引擎阻塞页面刷新)
- JS引擎线程
-
- js脚本代码执行
- 负责执行准备好的事件,例如定时器计时结束或异步请求成功且正确返回
- 与GUI渲染线程互斥
- 事件触发线程
-
- 当对应的事件满足触发条件,将事件添加到js的任务队列末尾
- 多个事件加入任务队列需要排队等待
- 定时器触发线程
-
- 负责执行异步的定时器类事件:setTimeout、setInterval等
- 浏览器定时计时由该线程完成,计时完毕后将事件添加至任务队列队尾
- HTTP请求线程
-
- 负责异步请求
- 当监听到异步请求状态变更时,如果存在回调函数,该线程会将回调函数加入到任务队列队尾
Event loop执行顺序
- 首先执行同步代码,这属于宏任务
- 当执行完所有同步代码后,执行栈为空,查询是否有异步代码需要执行
- 执行同步代码时遇到微任务则将其放入微任务队列,遇到宏任务则存入宏任务队列
- 执行所有微任务
- 当执行完所有微任务后,如有必要会渲染页面
- 然后开始下一轮 Event Loop,执行宏任务中的异步代码,也就是 setTimeout 中的回调函数
进程与线程区别?JS 单线程带来的好处?
进程描述了 CPU 在运行指令及加载和保存上下文所需的时间,放在应用上来说就代表了一个程序。
线程是进程中的更小单位,描述了执行一段指令所需的时间。
浏览器中来说,当你打开一个 Tab 页时,其实就是创建了一个进程,一个进程中可以有多个线程,比如渲染线程、JS 引擎线程、HTTP 请求线程等等。当你发起一个请求时,其实就是创建了一个线程,当请求结束后,该线程可能就会被销毁
在 JS 运行的时候可能会阻止 UI 渲染,这说明了两个线程是互斥的。这其中的原因是因为 JS 可以修改 DOM,如果在 JS 执行的时候 UI 线程还在工作,就可能导致不能安全的渲染 UI。这其实也是一个单线程的好处,得益于 JS 是单线程运行的,可以达到节省内存,节约上下文切换时间,没有锁的问题的好处。当然前面两点在服务端中更容易体现,对于锁的问题,形象的来说就是当我读取一个数字 15 的时候,同时有两个操作对数字进行了加减,这时候结果就出现了错误。解决这个问题也不难,只需要在读取的时候加锁,直到读取完毕之前都不能进行写入操作
什么是执行栈?
可以把执行栈认为是一个存储函数调用的栈结构,遵循先进后出的原则。
当我们使用递归的时候,因为栈可存放的函数是有限制的,一旦存放了过多的函数且没有得到释放的话,就会出现爆栈的问题
做题技巧
1、 new构造函数中的内容不属于异步时间
const p = new Promise((resolve)=>{
console.log(111) // 1
resolve(222) ///4
console.log(333) ///2
})
p.then((v)=>{
console.log(v)
})
console.log(333) ///3
2、async await
- 执行async函数,返回的是Promise对象
- await必须在async包裹之下执行
- await相当于Promise的then并且同一作用域下await下面的内容全部作为then中回调的内容
- try……catch可捕获异常,代替了Promise的catch
- 异步中先执行微任务,再执行宏任务
- await 中的 同步代码按照同步顺序执行,但是 await语句看成一个Promise 后面的语句需要等await执行完
-
- async 内的await 后面内容看成一个微任务
- 可以理解为 紧跟着await后面的语句相当于放到了new Promise中,下一行及之后的语句相当于放在Promise.then中
3、resolve, reject这两个参数其实也都是函数 内部值是一个微任务
4、then、catch中属于微任务
1、无论是then还是catch里的回调内容只要代码正常执行或者正常返回,则当前新的Promise实例为fulfilled状态。如果有报错或返回Promise.reject()则新的Promise实例为rejected状态。
2、fulfilled状态能够触发then回调
3、rejected状态能够触发catch回调
4、“紧跟Promise实例的then的参数等于resolve接受的参数;紧跟Promise实例的catch的参数等于reject接受的参数。”
什么是 Event Loop,为什么需要 Event Loop?
JavaScript 是单线程的,单线程意味着,所有任务都需要排队,前一个任务结束,才会执行后一个任务。
而这种 主线程从 “任务队列” 中读取执行事件,不断循环重复的过程,就被称为 事件循环(Event Loop)
如果前一个任务耗时很长,后一个任务就不得不一直等着,那么我们肯定要对这种情况做一些特殊处理,毕竟很多时候我们并不是完全希望它如此执行。
所以为了协调事件(event),用户交互(user interaction),脚本(script),渲染(rendering),网络(networking)等,用户代理(user agent)必须使用事件循环(event loops)。
- Node.js:Node.js 的 Event Loop 是基于 libuv。libuv 已经对 Event Loop 作出了实现。
- 浏览器:浏览器的 Event Loop 是基于 HTML5 规范的。而 HTML5 规范中只是定义了浏览器中的 Event Loop 的模型,具体实现留给了浏览器厂商
libuv 是一个多平台支持库,主要用于异步 I/O。它最初是为 Node.js 开发的,现在 Luvit、Julia、pyuv 和其他的框架也使用它。Github - libuv 仓库
浏览器 Event Loop
在讲解浏览器的 Event Loop 前,我们需要先了解一下 JavaScript 的运行机制:
- 所有同步任务都在主线程上执行,形成一个 “执行栈” (execution context stack)。
- 主线程之外,存在一个 “任务队列” (task queue),在走主流程的时候,如果碰到异步任务,那么就在 “任务队列” 中放置这个异步任务。
- 一旦 “执行栈” 中所有同步任务执行完毕,系统就会读取 “任务队列” ,看看里面存在哪些事件。那些对应的异步任务,结束等待状态,进入执行栈,开始执行。
- 主线程不断重复上面三个步骤。
而 JavaScript 的异步任务,还细分两种任务:
- 宏任务(Macrotask) :
script(整体代码)、setTimeout、setInterval、XMLHttpRequest.prototype.onload、I/O、UI 渲染 - 微任务(Microtask) :
Promise、MutationObserver、async await
Node.js Event Loop
Node.js 的 Event Loop 是基于 libuv。libuv 已经对 Event Loop 作出了实现。
Node 的 Event Loop 分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
- 定时器(timers) :本阶段执行已经被
setTimeout()和setInterval()的调度回调函数。 - 待定回调(pending callbacks) :执行延迟到下一个循环迭代的 I/O 回调。
- idle, prepare:仅系统内部使用。
- 轮询(poll) :检索新的 I/O 事件;执行与 I/O 相关的回调(几乎所有情况下,除了关闭的回调函数,那些由计时器和
setImmediate()调度的之外),其余情况Node将在适当的时候在此阻塞。 - 检测(check) :
setImmediate()回调函数在这里执行。 - 关闭的回调函数(close callbacks) :一些关闭的回调函数,如:
socket.on('close', ...)。
有些时候,前端面试官还是会跟你扯 setTimeout & setImmediate 和 process.nextTice()
node中的特殊事件
- setTimeout:众所周知,这是一个定时器,指定
n毫秒后执行定时器里面的内容。 - setImmediate:Node.js 发现使用
setTimeout和setInterval有些小弊端,所以设计了个setImmediate,该方法被设计为一旦在当前轮询阶段完成,就执行这个脚本。 - process.nextTick()
nextTick比较特殊,它存有自己的队列,并且它独立于 Event Loop,无论 Event Loop 处于何种阶段,都会在阶段结束的时候清空nextTick队列。
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
事实上这两个结局都是会存在的。
按照官网的解释:
- 执行计时器的顺序将根据调用它们的上下文而异。
- 如果两则都从主模块内调用,则计时器将受到进程性能的约束(这可能会受到计算机上其他正在运行应用程序的影响)。
- 如果你将这两个函数放入一个 I/O 循环内调用,
setImmediate总是被有限调用使用
setImmediate() 相对于 setTimeout 的主要优势是:如果 setImmediate() 是在 I/O 周期内被调度的,那么它将会在任何的定时器之前执行,跟这里存在多少个定时器无关出处。
浏览器和node的 异步任务区别
浏览器环境下,microtask的任务队列是每个macrotask执行完之后执行。而在Node.js中,microtask会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行microtask队列的任务。
浏览器和Node 环境下,microtask 任务队列的执行时机不同
- Node端,microtask 在事件循环的各个阶段之间执行
- 浏览器端,microtask 在事件循环的 macrotask 执行完之后执行
实际例总结
- 注意setTime中有没有倒计时
- 注意then后面的then 属于一个新的微任务
- 注意new Promise 中没有return 之前都属于外部任务内的一部分