JavaScript中的事件循环 Event Loop

ECMAScript 中没有 Event Loops 规范的定义,Event Loops 是在 HTML Strandard 中定义的,规范中定义了浏览器何时进行渲染更新,了解Event Loop有助于性能优化

先来看一道题,说出输出结果

new Promise(resolve => {
resolve(1);
Promise.resolve().then(() => console.log(2));
console.log(4)
}).then(t => console.log(t));
console.log(3);

什么是事件驱动?

所谓的事件驱动,就是将一切抽象为事件。IO操作完成是一个事件,用户点击一次鼠标是事件,Ajax 完成了是一个事件,一个图片加载完成是一个事件

“任务队列”中的事件,除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入”任务队列”,等待主线程读取

事件驱动的的实现过程主要靠事件循环完成。进程启动后就进入主循环。主循环的过程就是不停的从事件队列里读取事件。如果事件有关联的handle(也就是注册的callback),就执行handle。一个事件并不一定有callback

上图中,主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在”任务队列”中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取”任务队列”,依次执行那些事件所对应的回调函数。

执行栈中的代码,总是在读取”任务队列”之前执行

事件循环 Event Loop

JS 是单线程的,同一时间,只能做同一件事,为了协调事件、用户交互、脚本、UI渲染和网络处理等行为,防止主线程阻塞,Event Loop方案应运而生,事件循环是JS实现异步的方式,也是JS的执行机制。

触发一次click事件,进行一次ajax请求,背后都有 event loop 在运作

任务队列 Task Queue

事物循环通过任务队列的机制进行协调,一个任务列表(task queue) 包含多个任务(task),每个任务都有一个任务源(task source),源自同一个任务源的 task 必须放到同一个任务队列,遵循先进先出的原则

task 任务源举例

  • DOM操作任务源:用来操作相应的DOM,例如一个元素以非阻塞的方式插入文档
  • 用户交互任务源:对用户的交互做出反应,例如键盘鼠标的输入,响应用户操作的事件(如click)
  • 网络任务源:用来响应网络活动
  • History traversal任务源:当调用 history.back()等类似API时,将任务插进队列

task 任务源非常宽泛,比如 ajax 的 onload,click 事件,基本上我们经常绑定的各种事件都是task任务源,还有数据库操作(IndexedDB ),需要注意的是setTimeout、setInterval、setImmediate也是task任务源。

JS的异步运行机制

  1. 所有任务都在主线程上执行,形成一个执行栈(execution context stack)。
  2. 主线程之外,还存在一个”任务队列”(task queue)。系统把异步任务放到”任务队列”之中,然后继续执行后续的任务。
  3. 一旦”执行栈”中的所有任务执行完毕,系统就会读取”任务队列”。如果这个时候,异步任务已经结束了等待状态,就会从”任务队列”进入执行栈,恢复执行。
  4. 主线程不断重复上面的第三步。

Tick

事件循环中,每一次循环称为 tick, 每一次tick的任务如下:

  1. 选择最先进入队列的任务 task,如果有则执行
  2. 检查是否存在 Microtask,如果存在则不停的执行,直至清空 microtask 队列
  3. 更新render
  4. 重复以上步骤

宏任务 -> 所有微任务 -> 宏任务

宏任务 macrotask 与 微任务 microtask

异步任务分为 宏任务task(macrotask) 与 微任务 microtask,不同的API注册的任务会依次进入自身对应的队列中,然后等待 Event Loop 将它们依次压入执行栈中执行

  • task 宏任务: script代码执行、setTimeout、setInterval、I/O、UI交互事件、postMessage、requestAnimationFrame、MessageChannel、setImmediate(Node.js环境)

  • microtask 微任务: Promise.then、MutaionObserver、process.nextTick(Node.js环境)

为什么 Promise 属于 microtask ?

Promise 的定义是在ECMAScript规范中定义的,而不在HTML规范中,所以 Promise 可以采用宏任务机制或微任务机制来实现,但使用微任务机制实现已经是一个普遍的共识

console.log('script start');

setTimeout(function() {
console.log('timeout1');
}, 10);

new Promise(resolve => {
console.log('promise1');
resolve();
setTimeout(() => console.log('timeout2'), 10);
}).then(function() {
console.log('then1')
})

console.log('script end');
  • JS在执行过程中是单线程的,所有任务可以看做是放在两个队列中,执行队列 与 事件队列
  • 执行队列是同步的,事件列列是异步的,而微任务 microtask 是处在两个列表之间
  • 当JS执行时,优先执行同步代码,遇到异步代码,会根据任务类型存到对应的异步事件队列中(宏任务放入事件列表,微任务放入执行队列之后,事件队列之前),当执行完同步代码之后,就会执行位于执行列表之后的微任务,然后再执行事件列表中的宏任务

  • 一个浏览器上下文总有一个 event loop 去协调

  • 浏览器可以有多个 event loop,browsing contexts和web workers就是相互独立的
  • 所有同源的browsing contexts可以共用event loop

什么是浏览器上下文 Browsing contexts

浏览器上下文是一个将 Document 对象呈现给用户的环境。在一个 Web 浏览器内,一个标签页或窗口常包含一个浏览上下文,如一个 iframe 或一个 frameset 内的若干 frame。

执行环境(Runtime) 与 执行引擎(Engine)

执行引擎

执行引擎指虚拟机,对于 Node和Chrome来说是V8,对于Safari来说是 JavaScript Core,对于Firefox来说是SpiderMonkey,对于执行引擎来说,他们需要实现ECMAScript标准

执行环境

执行环境是指浏览器、node、Ringo等,而Event loop是执行环境的执行机制

什么是异步与同步?

一般操作分为发出调用和得到结果两步,发出调用立即得到结果为同步,发出调用,不能立即得到结果,需要额外的操作才能得到结果为异步

同步就是调用之后一直等待,直到返回结果
异步则是调用之后不能直接拿到结果,通过一系列手段才能拿到结果,等待结果的时间可以介入其它任务

event loop、轮询、事件 就是实现异步的方法

为什么JS是单线程的?

为了保证交互效果的一致性,如一个线程在某个DOM节点上添加内容,另一个线程同时将这个DOM节点删除,这里浏览器无法判断优先级

为了利用多核CPU的计算能力,H5提出了Web Worker标准,允许JS创建多个线程,但子线程完全受主线程控制,且不能操作DOM,所以这个新标准并没有改变JS是单线程的本质。

更新渲染(Update the rendering)

每一次事件循环,浏览器都可能会去更新渲染,渲染的基本流程如下:

  1. 处理 HTML 标记并构建DOM树
  2. 处理 CSS 标记并构建CSSOM树,将DOM树与CSSOS树合并成一个渲染树
  3. 根据渲染树来布局,计算每个节点的几何信息
  4. 将各个节点绘制在屏幕上

绘制是在等待css样式全部加载完成之后进行的,所以样式的加载快慢是首屏呈现快慢的关键点之一

小结

  • 渲染时机是在 microtask 之后,事件队列之前执行
  • 在一次事件循环中多次修改同一dom,只有最后一次才进行绘制
  • 如果希望在每轮事件循环中呈现变动,可以使用 requestAnimationFrame

Nodejs环境JS运行机制

  • Node的Event Loop分阶段,阶段有先后,依次是
    • expired timers and intervals,即到期的setTimeout/setInterval
    • I/O events,包含文件,网络等等
    • immediates,通过setImmediate注册的函数
    • close handlers,close事件的回调,比如TCP连接断开
  • 同步任务及每个阶段之后都会清空microtask队列
    • 优先清空next tick queue,即通过process.nextTick注册的函数
    • 再清空other queue,常见的如Promise
  1. V8引擎解析JavaScript脚本。
  2. 解析后的代码,调用Node API。
  3. libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎。
  4. V8引擎再将结果返回给用户。

执行栈 -> process.nextTick -> 任务队列 -> setImmediate

process.nextTick(function A() {
console.log(1);
process.nextTick(function B(){console.log(2);});
});

setTimeout(function timeout() {
console.log('TIMEOUT FIRED');
}, 0)
// 1
// 2
// TIMEOUT FIRED
setImmediate(function (){
setImmediate(function A() {
console.log(1);
setImmediate(function B(){console.log(2);});
});

setTimeout(function timeout() {
console.log('TIMEOUT FIRED');
}, 0);
});
// 1
// TIMEOUT FIRED
// 2

一次事件循环中,如果有多个 process.nextTick 会在执行栈之后,一次性全部执行,多个setImmediate 则可能需要多次循环依次执行

process.nextTick 是在本次事件循环之初触发,且不用检查任务队列,所以在时间上更快,执行效率更高,但是如果process.nextTick 事件太多,执行时长过长也会阻塞事件循环

console.log(1)

setTimeout(() => {
console.log(2)
new Promise(resolve => {
console.log(4)
resolve()
}).then(() => {
console.log(5)
})
process.nextTick(() => {
console.log(3)
})
})

new Promise(resolve => {
console.log(7)
resolve()
}).then(() => {
console.log(8)
})

process.nextTick(() => {
console.log(6)
})

阅读参考: