作者:胡晓晓 职位:前端工程师
背景
众所周知,JavaScript 是一门单线程语言,同一时刻只能执行一个任务。同步任务都在主线程上执行,形成一个执行栈。遇到异步任务,如果未拿到结果一直等待的话,就会阻塞后续代码执行,这种结果是我们不想看到的。
那么是否存在这么一种机制:遇到异步代码,JavaScript 单线程运行时不会阻塞?答案是肯定的:Event Loop 事件循环就是解决异步阻塞的一种机制。
在进入本文的整体之前,我们先来回顾一些基本的概念。
JavaScript 执行和运行是两个概念:
执行:依赖于环境,如浏览器、node 等等,在不同环境执行机制不尽相同。
运行:JavaScript 的解析引擎。
浏览器内核是多线程的,在内核控制下各线程相互配合以保持同步,一个浏览器通常由以下常驻线程组成:
GUI 渲染线程
主要负责页面的渲染,解析 HTML、CSS,构建 DOM 树,布局和绘制等。
当界面需要重绘或者由于某种操作引发回流时,将执行该线程。
该线程与 JavaScript 引擎线程互斥,当执行 JavaScript 引擎线程时,GUI 渲染会被挂起,当任务队列空闲时,JavaScript 引擎才会去执行 GUI 渲染。
JavaScript 引擎线程
主要负责处理 JavaScript 脚本,执行代码。
也是主要负责执行准备好待执行的事件,即定时器计数结束或者异步请求成功并正确返回时,将依次进入任务队列,等待 JS 引擎线程的执行。
该线程与 GUI 渲染线程互斥,当 JavaScript 引擎线程执行 JavaScript 脚本时间过长,将导致页面渲染的阻塞。
定时触发器线程
负责执行异步定时器一类的线程,如:setTimeout,setInterval。
主线程依次执行代码时,遇到定时器,会将定时器交给该线程处理,当计数完毕后,事件触发线程会将计数完毕后的事件加入到任务队列的尾部,等待 JS 引擎线程执行。
事件触发线程
主要负责将准备好的事件交给 JavaScript 引擎线程执行。
比如 setTimeout 定时器计数结束, ajax 等异步请求成功并触发回调函数,或者用户触发点击事件时,该线程会将整装待发的事件依次加入到任务队列的队尾,等待 JS 引擎线程的执行。
异步 http 请求线程
负责执行异步请求一类的线程,如:Promise,axios,ajax 等。
主线程依次执行代码时,遇到异步请求,会将函数交给该线程处理,当监听到状态码变更,如果有回调函数,事件触发线程会将回调函数加入到任务队列的尾部,等待 JavaScript 引擎线程执行。
JavaScript 执行栈又叫调用栈(LIFO),用来存储代码运行时创建的所有执行上下文。
-->当 JavaScript 引擎第一次遇到脚本 script 标签
--> 创建一个全局的执行上下文并且压入当前执行栈
--> 当引擎遇到一个函数调用,它会为该函数创建一个新的执行上下文,压入栈的顶部
--> 该函数执行结束时,执行上下文从栈中弹出
--> 控制流程到达当前栈中的下一个上下文
... ...
众所周知,JavaScript 是一门单线程语言,有同步任务和异步任务之分。
异步任务回调函数是放在任务队列的。
任务队列有两种:宏任务(macro-task)队列和微任务( micro-task)队列。
宏任务队列是一次只执行一个任务
微任务是一次执行当前微任务队列中的所有任务,
常见的macro-task 如:setTimeout、setInterval、 setImmediate、script(整体代码)、 I/O 操作、UI 渲染等。 常见的 micro-task 如: process.nextTick、new Promise().then(回调)、MutationObserver 等。
Browser Context 的 Event Loop 解读
一开始我们的执行栈为空,全局上下文(script标签)被推入执行栈,代码被执行。
代码执行过程中,会判断是同步任务还是异步任务,同步任务会被放入执行栈,立刻执行;异步任务会交给对应的异步处理模块去处理,异步处理完成后,会把异步任务的回调放入任务队列,当前任务执行完后出栈;
如果主线程任务全部执行完成,执行栈为空,接下来我们会检查微任务队列,如果队列不为空,会把微任务队列的回调放入主线程去执行,直到当前微任务队列被清空;
然后执行宏任务队列,宏任务队列是一次只执行一个任务;
3和4循环往复,直到两个队列都清空。
在 index.html 中有这么一段代码
当我们点击 inner 时,输出什么?
如果 JavaScript 代码中加一行 inner.click(),又会输出什么呢?
答案是:
为什么加了一行代码,输出结果就改变了呢?
上文提到了浏览器内核是多线程的。我们上面这段代码用到了 JavaScript 引擎线程、定时器触发线程以及事件触发线程。
先来分析第一段代码
JavaScript 是单线程,顺序执行代码,所以,当 script 标签里的代码执行完成后,执行栈为空:
如上图所示:执行栈 Call Stack 为空,同时浏览器内核中事件触发线程队列中放入了两个任务。
当我们点击 inner 时,会发生什么呢?
点击 inner 时,事件触发线程会把 inner click callback 放入任务队列;发生冒泡,事件触发线程会把outer click callback 放入任务队列;
2. 检查微任务队列, MicroTask 为空,继续下一步;
3. 从 MactoTask 取出一个宏任务,放入主线程
3.1 执行 onClick
遇到同步代码顺序执行,输出 `click`
执行完出栈
3.2 遇到 setTimeout 放入定时器触发线程,当 setTimeout 的 callback 触发时,会把setTimeout callback 放入 MacroTask 队列
3.3 Promise.then 和 MutationObserver 放入微任务队列;
3.4 当前任务处理完成,onClick 出栈
4.2 继续清空微任务队列,重复上一步步骤,此时会输出 `mutate` 。
5. 取出宏任务队列中的一个执行,步骤同3;
6. 继续下一轮事件循环,清空微任务队列,同步骤4;
7. Macro-tasks 任务队列取出一个任务,放入任务队列
同步任务继续执行
执行完成出栈
8. 又一轮event loop 开始,此时微任务队列为空;
9. 从宏任务队列取出一个任务执行,同步骤7,执行完成后出栈,此时宏任务队列为空。
最终输出结果如上图所示。
inner.click() 发生了什么
与上一段代码不同,执行 inner.click 之后,Call Stack 队列不为空:
1. 执行栈 inner 的click callback先被执行
首先是 console.log('click'),同步代码立刻执行,输出`click`
执行到 setTimeout,异步代码,交给定时器触发线程去处理,处理完成后callback放入MacroTasks
执行到 Promise.then 异步代码,放入微任务队列 MicroTasks ;
执行到 MutationObserver 异步代码,放入微任务队列 MicroTasks ;
2.主线程执行栈不为空,继续执行Call Stack的下一个方法, outer 的click callback 方法被执行,同1的步骤,outer 的callback执行中有一点需要注意,微任务队列中最多只能有一个MutationObserver,所以执行完成后结果如下:
3. 此时 inner.click 代码执行完毕,出栈
4. 检查微任务队列,微任务队列不为空
4.1 取出一个微任务,放入执行栈,执行代码,此时会输出 `promise `,执行完出栈
4.2 继续清空微任务队列,重复上一步步骤,一直到清空微任务队列,此时会依次输出 mutaion promise
5. 宏任务队列不为空,取出第一个任务放入执行栈执行,此时输出 timeout
6. 重复4和5 进入下一次事件循环,此时微任务队列为空;
7. 宏任务队列不为空,取出第一个任务放入执行栈,同5
8. 继续重复4和5 ,直到宏任务队列和微任务队列都为空。
本文旨在帮助大家理解浏览器的 Event Loop ,以图文的形式呈现给大家,所以图会比较多,如有不同见解,欢迎大家一起探讨。