这一次,彻底弄懂 事件机制 Event Loop
最近一直在补事件机制,也就是常说的Javascript 执行机制,翻阅了大量书籍,以及博客,想静下心来好好的整理归纳。
定义
event loop翻译出来就是事件循环,可以理解为实现异步的一种方式,我们来看看event loop在HTML Standard中的定义章节:
第一句话:
为了协调事件,用户交互,脚本,渲染,网络等,用户代理必须使用本节所述的event loop。
事件,用户交互,脚本,渲染,网络这些都是我们所熟悉的东西,他们都是由event loop协调的。触发一个click事件,进行一次ajax请求,背后都有event loop在运作。
基础知识
执行栈
javaScript是单线程,也就是说只有一个主线程,主线程有一个栈,每一个函数执行的时候,都会生成新的execution context(执行上下文),执行上下文会包含一些当前函数的参数、局部变量之类的信息,它会被推入栈中, running execution context(正在执行的上下文)始终处于栈的顶部。当函数执行完后,它的执行上下文会从栈弹出。
宏任务与微任务
宏任务,macrotask,也叫tasks,一些异步任务的回调会依次进入macrotask queue
等待后续被调用,这些异步任务包括:
- setTimeout
- setInterval
- setImmediate(Node独有)
- requestAnimationFrame(浏览器独有)
- I/O 文件读取 网络请求
- UI rendering(浏览器独有)
微任务,microtask,也叫jobs。另一些异步任务的回调会依次进入macrotask queue,等待后续被调用,这些异步任务包括:
- process.nextTick(Node独有)
- Promise.then()
- Object.observe()
- MutationObserver (注:只针对浏览器和Nodejs)
注意:Promise构造函数里的代码是同步执行的
以上就是需要记忆的内容。
从图开始讲起
导图要表达的内容用文字来表述的话:
- 一开始执行栈空,我们可以把执行栈认为是一个存储函数调用的栈结构,遵循先进后出的原则。micro 队列空,macro 队列里有且只有一个 script 脚本(整体代码)。
- 全局上下文(script 标签)被推入执行栈,同步代码执行。在执行的过程中,会判断是同步任务还是异步任务,通过对一些接口的调用,可以产生新的 macro-task 与 micro-task,它们会分别被推入各自的任务队列里。同步代码执行完了,script 脚本会被移出 macro 队列,这个过程本质上是队列的 macro-task 的执行和出队的过程。
- 上一步我们出队的是一个 macro-task,这一步我们处理的是 micro-task。但需要注意的是:当 macro-task 出队时,任务是一个一个执行的;而 micro-task 出队时,任务是一队一队执行的。因此,我们处理 micro 队列这一步,会逐个执行队列中的任务并把它出队,直到队列被清空。
- 执行渲染操作,更新界面
- 检查是否存在 Web worker 任务,如果有,则对其进行处理
- 上述过程循环往复,直到两个队列都清空
我们总结一下,每一次循环都是一个这样的过程:
当某个宏任务执行完后,会查看是否有微任务队列。如果有,先执行微任务队列中的所有任务,如果没有,会读取宏任务队列中排在最前的任务,执行宏任务的过程中,遇到微任务,依次加入微任务队列。栈空后,再次读取微任务队列里的任务,依次类推。
我们不禁要问了,那怎么知道主线程执行栈为空啊?js引擎存在monitoring process进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去Event Queue那里检查是否有等待被调用的函数。
我们从代码开始:
console.log('start')
setTimeout( function () {
console.log('setTimeout')
}, 0 )
Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
console.log('end')
分析:
- 全局上下文(script标签)被推入执行栈,先执行所有的同步任务,也就是打印start,end,通过setTimeout1被推入宏任务(macrotask Queue),Promise.then()被推入微任务(microtask Queue)队列
- 执行栈为空,先去微任务(microtask Queue)队列取任务,依次放入执行栈中,先输出promise1,放回值是undefined,紧接着又将Promise.then()推入微任务(microtask Queue)队列,此时执行栈为空,再去微任务队列中找,找到还有一个微任务,将它放进执行栈中,输出promise2,接着所有的微任务执行完。
- 宏任务队列中存在任务为执行,所以就取其中一个任务,放进执行栈中,输出setTimeout,接着栈为空。
- 接着再去微任务队列中找是否有未执行的任务,依次全部推入执行栈。
- 然后再去宏任务队列中取,只取一个,如此反复循环,直到最后执行栈为空。
Event Loop 处理模型
前面简单介绍了 JavaScript Runtime 的整个运行流程,而 Event Loop 作为其中的重要一环,它的每一次循环过程也相当复杂,因此将它单独拿出来介绍。下面我会尽量保持 HTML 标准中对处理模型(Processing Model)的定义,并尽量简化,步骤如下(3 步):
- 执行 Task:从 Task Queue 中取出最老的一个 Task 并执行;如果没有 Task,直接跳过。
- 执行 Microtasks:遍历 Microtask Queue 并执行所有 Microtask(参考 Perform a microtask checkpoint)。
- 进入 Update the rendering(更新渲染)阶段:
- 设置 Performance API 中 now() 的返回值。Performance API 属于 W3C High Resolution Time API 的一部分,用于前端性能测量,能够细粒度的测量首次渲染、首次渲染内容等的各项绘制指标,是前端性能追踪的重要技术手段,感兴趣的同学可关注。
- 遍历本次 Event Loop 相关的 Documents,执行更新渲染。在迭代执行过程中,浏览器会根据各种因素判断是否要跳过本次更新。
- 当浏览器确认继续本次更新后,处理更新渲染相关工作:
3.1 触发各种事件:Resize、Scroll、Media Queries、CSS Animations、Fullscreen API。
3.2 执行 animation frame callbacks,window.requestAnimationFrame 就在这里。
3.3 更新 intersection observations,也就是 Intersection Observer API(可用于图片懒加载)。更新渲染和 UI,将最终结果提交到界面上。
至此,Event Loop 的一次循环结束。。。还是用一张简图来描述吧(process.nextTick 被特意标注出来以示区别)。
Node 中的 Event Loop
node简介
Node 中的 Event Loop 和浏览器中的是完全不相同的东西。Node.js 采用 V8 作为 js 的解析引擎,而 I/O 处理方面使用了自己设计的 libuv,libuv 是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的 API,事件循环机制也是它里面的实现(下文会详细介绍)。
Node.js 的运行机制如下:
- V8 引擎解析 JavaScript 脚本。
- 解析后的代码,调用 Node API。
- libuv 库负责 Node API 的执行。它将不同的任务分配给不同的线程,形成一个 Event Loop(事件循环),以异步的方式将任务的执行结果返回给 V8 引擎。
- V8 引擎再将结果返回给用户。
6个阶段
上述的五个阶段都是按照先进先出的规则执行回调函数。按顺序执行每个阶段的回调函数队列,直至队列为空或是该阶段执行的回调函数达到该阶段所允许一次执行回调函数的最大限制后,才会将操作权移交给下一阶段。
每个阶段的简单概要:
- timers: 执行setTimeout() 和 setInterval() 预先设定的回调函数。
- I/O callbacks: 大部分执行都是timers 阶段或是setImmediate() 预先设定的并且出现异常的回调函数事件。
- idle, prepare: nodejs 内部函数调用。
- poll: 搜寻I/O事件,nodejs进程在这个阶段会选择在该阶段适当的阻塞一段时间。
- check: setImmediate() 函数会在这个阶段执行。
- close callbacks: 执行一些诸如关闭事件的回调函数,如socket.on(‘close’, …)
Node.js的Event Loop过程:
- 执行全局的Script代码
- 执行microtask微任务,先执行所有Next Tick Queue中所有任务,再执行Other Microtask Queue中所有的任务
- 开始实行macrotask宏任务,共6个阶段,从第1个阶段开始执行相应的每一个阶段macrotask中所有任务,(注意:这里是所有每个阶段宏任务的所有任务,在浏览器的Event Loop中只取宏任务的第一个任务执行) 每一阶段的macrotask任务执行完成后,开始执行微任务,也就是步骤二
- Timers Queue -> 步骤2 -> I/O Queue -> 步骤2 -> Check Queue -> 步骤2 -> Close Callback Queue -> 步骤2 -> Timers Queue
浏览器和Node中Event Loop有什么不同呢
- 浏览器和Node.js的Event Loop是不同的
- Node.js可以理解成4个宏任务队列和2个微任务队列,但是执行宏任务有6个阶段。
- Nodejs中,先执行全局的Script代码,执行完同步代码调用栈清空后,先从Next Tick Queue中依次取出所有的任务放入调用栈中执行,再Other Microtask Queue中依次取出所有的任务放入调用栈中所有的任务。然后开始宏任务的6个阶段,每个阶段将取出宏任务队列中所有任务来执行(浏览器每次只取一个),每个宏任务阶段执行完毕后,开始执行微任务,再开始执行下一个阶段宏任务,以此构成事件循环。
接下我们通过一个例子来说明两者区别:
setTimeout(()=>{
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(()=>{
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
浏览器端运行结果:timer1=>promise1=>timer2=>promise2
浏览器端的处理过程如下:
Node 端运行结果:timer1=>timer2=>promise1=>promise2
- 全局脚本(main())执行,将 2 个 timer 依次放入 timer 队列,main()执行完毕,调用栈空闲,任务队列开始执行;
- 首先进入 timers 阶段,执行 timer1 的回调函数,打印 timer1,并将 promise1.then 回调放入 microtask 队列,同样的步骤执行 timer2,打印 timer2;
- 至此,timer 阶段执行结束,event loop 进入下一个阶段之前,执行 microtask 队列的所有任务,依次打印 promise1、promise2
Node 端的处理过程如下:
参考链接
- 深入理解 JavaScript Event Loop
- 浏览器与Node的事件循环(Event Loop)有何区别?
- 彻底吃透 JavaScript 执行机制