你真的了解JS的事件循环吗
一.前言
JS 作为前端开发的核心,开发者很有必要了解浏览器是怎么解析 JS 代码的,如果不了解的话,在开发时常常会因为不理解原理而导致对一些错误的认识只停留在表面,却无法从本质去知道它出现的原因,在解决起来也就没有那么顺利了。
二.进程与线程
关于操作系统为什么要引入进程和线程的概念以及它们解决了什么样的问题,这里不展开介绍,可以详细阅读《操作系统(第三版)罗宇等编著》书中的介绍,以下只做简单的介绍
1.进程
在操作系统中,程序以进程的方式使用系统资源,包括程序和数据所用的内存空间、系统外设、文件等程序运行时所需的系统资源,并且以分时共享的方式使用处理机资源。在进程运行过程中,操作系统不断的将系统资源以独占的方式或者与其他进程共享的方式分配给进程。
2.线程
线程是进程的更小的单位,在一个进程中可以包含多个并发的执行的线程。系统将按进程分配所有除 CPU 以外的系统资源(如主存、外设、文件等),而程序则依赖于线程运行,系统按照线程分配 CPU 资源,引入线程之后,进程概念内涵变了,进程只做除 CPU 以外的系统资源的分配单位。
3.进程与线程的关系
一个进程中至少要包含一个线程,线程的引入可以使进程细化为可以并发执行的部分。
例如,一个实现天气预报的程序可以将处理北京地区、上海地区、广州地区天气数据的过程并行地开展。如果还沿用传统的进程实现天气预报程序执行,那么因为进程内程序执行的顺序性,则不可能实现不同地区数据处理的并行执行。
三.同步和异步
1.同步
同步就是按照顺序先做完一件事情才能做下一件事情。这里的事情常常可以理解为某一个程序,或者某一个代码块,函数等。那怎么样才算是做完呢?从编程的角度来说,可以理解为一个程序的代码都执行完了或者有了返回结果,这就算是完成了。
2.异步
简单理解就是:两件事情同时进行。从编程的角度来说,可以理解为两个程序同时进行。既然是两个程序同时在进行,那么就必然会出现:
- 各自结束的时间(两者结束的时间可能相同也可能不同)
- 各自结束得到的结果
四.浏览器的进程
前面说程序以进程的方式使用系统资源,因此浏览器是以进程的方式使用系统资源,并且浏览器是多进程的。
1.浏览器主要包含哪些进程
- Browser 进程:浏览器的主进程,只有一个,负责协调、控制和管理其他进程。它还处理用户界面、地址栏、书签栏和菜单等元素。
- GPU 进程:用于 3D 绘制等,最多一个。
- 插件进程:每种类型的插件对应一个进程,仅当使用该插件时才创建。
- 负责处理网络请求,如 HTTP 请求和安全套接字层(SSL)请求。
- 渲染进程(浏览器内核):内部是多线程的,每打开一个新网页就会创建一个进程,主要用于页面渲染,脚本执行,事件处理等。
2.浏览器的进程个数是怎么计算的
- 不管打开一个或多个新的空白窗口,都只计算为打开了一个 Browser 进程
- 在新的空白窗口中加载一个网页,计算为一个浏览器渲染进程
- 其他进程如果有用到,则相应计数加一
五.浏览器渲染进程
渲染进程启动后,会开启一个渲染主线程,主要负责执行 HTML,CSS,JS 代码。默认情况下,浏览器会为每个标签页开启一个新的渲染进程,保证不同的标签页之间互不影响。
1、主线程
浏览器主线程要做的事情有哪些?
负责处理用户界面和 JavaScript 代码执行等任务,主要包含:
- 解析 HTML
- 解析 CSS
- 计算样式
- 布局
- 处理图层
- 每秒把页面画 60 次
- 执行全局 js 代码
- 执行事件处理函数
- 执行计时器的回调函数
- …
2、还包含哪些线程
- 样式线程:负责计算元素的样式信息。
- 布局线程:负责计算元素的位置和大小。
- 绘制线程:负责将渲染树绘制到屏幕上。
- JavaScript 引擎线程:负责处理 JavaScript 代码的执行。
- 异步 http 请求线程:在请求连接后通过浏览器新开一个请求线程,接下来检测状态变更,如果相应的状态下设置有回调函数,异步线程就会产生状态变更事件,将这个回调通知主线程
- 事件触发线程:监听用户与浏览器之间的交互,例如处理鼠标事件、键盘事件等。当事件被触发时,该线程会把事件通知主线程
- 定时器线程:定时器触发后,开始计时,时间到了通知主线
- 其他线程…
这时候你是不是有点懵逼,觉得主线程处理的任务为何会和别的线程处理的任务重复了,那实际是谁来处理?你可以这样理解,主线程在执行过程中会让其他专门的线程来协助处理,比如主线程在执行 js 代码时,遇到了一个定时器,通知定时器线程去执行,定时器线程开始计时,时间一到通知主线程去执行回调
那问题来了
第一、定时器线程在计时的这段时间,主线程是一直处于等待状态吗?主线程一旦进入等待,则会阻塞其他的任务执行,比如每秒把画面画 60 次,这样页面进入了卡顿状态。
第二、主线程执行时,其他线程也在并行执行并且执行结束产生了新的任务,这个时候主线程又应该怎么去调度这个新的任务呢?比如:
- 我正在执行一个 js 函数,执行到一半时候用户点击了按钮,我该立即去执行该点击事件吗?
- 我正在执行一个 js 函数,执行到一半时候某个计时器时间到了,我该立即去执行它的回调吗?
- 浏览器进程通知我用户点击了按钮,同时某个计时器时间也到了,我该去处理谁先呢?
这时候,需要借助一个机制来解决:1、消息队列 2、异步处理。
出现异步任务,开启对应线程处理。
所有产生的任务要在消息队列中排队。
六、消息队列
1、在最开始的时候,渲染主线程会进入一个无限循环
2、每一次循环会检查消息队列中是否有任务存在,如有,就取出第一个任务执行,执行完一个后进入下一次循环;如没有,则进入休眠状态。
3、如果主线程执行的是一个需要等待时间的任务,比如异步请求任务,定时器任务,会让对应的异步请求线程,定时器线程单独处理,主线程继续往后执行,并不会等待,否则页面阻塞。
3、其他所有线程可以随时向消息队列中添加任务。新任务会加到消息队列的末尾。在添加新任务时,如果主线程是休眠状态,则会唤醒继续循环取出任务。
大致流程是这样:
从上面的分析来看,我们可以得到这样的结论:
1、js 在一门单线程执行的语言
2、浏览器渲染进程工作方式是多线程异步的
七、如何理解 js 是一门单线程语言
js 是一门单线程语言,因为所有的 js 代码都是在浏览器的渲染主线程中被执行的,而渲染主线程只有一个。
八、如何理解 js 的同步和异步
简单地把 js 理解成同步或者异步,都是不正确的。
- js 代码在渲染主线程中是以同步的方式被执行的。
举个例子:
假设在执行的过程中是以异步的方式进行的,这时候你写了两句代码分别是添加某个节点,和删除该节点。这两句代码同时被执行,那最后该节点是添加的还是删除了?为了避免出现既添加又删除,既删除又添加的,薛定谔的猫的状态,所以 js 代码在主线程中执行时是同步方式,按顺序执行的。
因此,这就可能会出现一个问题:
for (;;) {
// 进入死循环
}
一旦页面进入了死循环,就意味着主线程直接阻塞,页面直接卡死,因此我们在实际的代码编写中,必须要避免出现死循环。
- js 支持异步操作
js 支持开启异步操作,比如声明一个异步请求函数,声明一个定时器函数,渲染主线程在遇到这种情况,会让异步请求线程,定时器线程去监听,主线程继续往下执行 js 代码,不会等待。一旦异步请求响应了或者定时器时间到了,会将对应的回调函数作为一个新的任务添加到消息队列的队尾,然后等待被执行。
九、事件循环机制
从上面的分析可以知道:
浏览器渲染主线程在执行 js 代码过程中,可能会出现异步操作,为了避免异步等待造成主线程阻塞,浏览器设置了一个消息队列,所有需要主线程去处理的事情,以任务为单位在消息队列中进行排队,主线程会依次从消息队列中获取任务去执行,每从消息队列中取出一个任务去执行,就是一次循环,以这种模式保证了单线程的流畅运行。
这就是事件循环机制。
使用事件循环来解释以下现象:
var h1 = document.querySelector('h1')
var btn = document.querySelector('button')
// 睡眠函数
function delay(duration) {
var start = Date.now()
while(Date.now() - start < duration)
}
btn.onclick = function() {
h1.textContent = 'hello world'
delay(3000)
}
你会发现执行以上代码,点击按钮,页面会在三秒后才显示 hello world
上面代码的执行是这样子的:
1、消息队列中当前只有一个任务就是全局的 js 代码
2、主线程取出全局 js 代码去执行,消息队列为空
3、遇到点击事件,让事件触发线程进行监听,全局代码执行完毕,此时消息队列依旧为空
4、用户点击了按钮,事件触发线程将点击回调函数作为一个任务加入消息队列中,此时消息队列中有一个任务
5、主线程从消息队列中取出该任务去执行,此时消息队列为空
6、主线程执行遇到h1.textContent = 'hello world'
,在队列中加入一个任务,修改文字,此时消息队列中有一个任务
7、主线程继续执行,遇到 delay 函数,主线程等待三秒,三秒后从消息队列中取出修改文字的任务执行
十、任务队列
看完以上十点分析,你是不是认为,由于 js 支持异步带来的渲染主线程阻塞的问题已经得到了完美的解决。
如果是这样的话,你不用往下看了,回去切图…
问题来了,消息队列保证了任务执行的顺序,那会不会出现某一个排在队尾的任务很紧急,能不能插个队?
基于现有的机制,不能!
为何不能?假设有这样一个场景:
主线程在执行 js 代码时,遇到了一个点击函数,于是让事件触发线程进行监听,如果用户点击了,则将对应的回调作为一个任务放到消息队列队尾,接着主线程继续执行,遇到了 100 定时器函数,这时又让定时器线程去分别计时,如果时间一到,分别将对应的回调函数作为任务加入到队尾中。
如果,这一百个定时器产生的回调任务优先到达队列,排在用户点击响应后的回调任务之前,那么,按照执行的先后顺序,就会出现明明用户先是点击了,回调任务但却是最后执行的,这就意味着,页面要等很久才能响应用户的点击操作。这明显是有问题的。
为了解决这个问题,W3C 制定了新的一个标准:
- 每个任务都有一个任务类型,同一个类型的任务必须在同一个队列,不同类型的任务分属于不同的队列。在每一次事件循环中,浏览器可以根据实际情况从不同的队列中取出任务执行
- 浏览器必须准备好一个微队列,微队列的任务优先其他所有的任务执行
- 不再使用过去宏任务的说法
根据标准,在浏览器中一般会有以下的几种类型的队列
- 微队列:用于存放需要最快执行的任务,Promise.then、MutaionObserver、process.nextTick(Node.js 环境)等,优先级最高
- 交互队列:用于存放用户操作后产生的事件处理任务,优先级高
- 延时队列:用于存放计时器到达后的回调任务,优先级中
十一、什么是事件循环
事件循环又叫做消息循环,是浏览器渲染主线程的工作方式。每次循环从消息队列中取出一个任务执行,而其他的线程只需要在合适的时候将任务加入到队列末尾即可。过去把消息队列简单分为宏队列和微队列,这种说法已经无法满足复杂的浏览器环境,根据 W3C 的规范,每个任务有不同的类型,同类型的任务必须在同一个队列中,不同的任务可以属于不同的队列,不同的任务队列的优先级不同,在一次事件循环中,由浏览器自行决定取哪一个队列的任务。但浏览器必须有一个微队列,微队列的任务一定具有最高的优先级,必须优先调度执行。
十二、举一些例子
1、for 循环中的异步执行
//打印1-10
for (var i = 1; i <= 10; i++) {
setTimeout(function () {
console.log(i);
}, 1000);
}
//结果打印了10个11
1、for 循环在主线程中是以同步的方式执行的,i 从 1-10,依次遍历
2、i 从 1-10,依次遍历过程中,都会遇到setTimeout
,则浏览器渲染进程新开一个定时器触发线程,这时,for 循环继续执行
3、setTimeout
里面的回调函数将被加入到任务源为setTimeout
的任务队列中,等待执行
4、for 循环中当 i=11 时,退出 for 循环,此时,主线程空闲,于是开始依次从任务源为setTimeout
的任务队列中读取任务(回调函数)并加入到主线程执行栈中执行,因此,打印了 10 个 11
2、Promise/then、catch 中的任务怎么执行
Promise 的then
和catch
中回调会被加入到微任务队列中,该队列的优先级最高
3、async/await 中的任务怎么执行
async/await 与 Promise 的作用相同,async/await 本身就是 promise+generator 的语法糖。
async 中的代码是被当做同步任务来执行的,在主线程的执行栈中,一旦轮到它执行了就会立即执行。
await 实际上是一个让出线程的标志,await 后面的表达式会先执行一遍,将 await 后面的代码加入到微任务队列中,然后就会跳出整个 async 函数来执行后面的代码。
4、setTimeout 中的任务怎么执行
setTimeout 中的回调函数将加入 setTimeout 队列里面,等主线程处理完微任务队列,交互队列的任务空闲之后才会依次从该队列取出任务去执行
5、分析例子
//请写出输出内容
async function async1() {
console.log("async1 start");
await async2();
console.log("async1 end");
}
async function async2() {
console.log("async2");
}
console.log("script start");
setTimeout(function () {
console.log("setTimeout");
}, 0);
async1();
new Promise(function (resolve) {
console.log("promise1");
resolve();
}).then(function () {
console.log("promise2");
});
console.log("script end");
/*
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
*/
- 上面是一段 JS 代码,渲染主线程开始解析,依次执行代码
- 然后我们看到首先定义了两个 async 函数,接着往下看,然后遇到了
console
语句,直接输出script start
。输出之后,script 任务继续往下执行,遇到setTimeout
,则将回调添加到 setTimeout 队列中 - script 任务继续往下执行,执行了 async1()函数,前面讲过 async 函数中在 await 之前的代码是立即执行的,所以会立即输出
async1 start
。 遇到了 await 时,会将 await 后面的表达式执行一遍,所以就紧接着输出async2
,然后将 await 后面的代码也就是console.log('async1 end')
加入到 microtask 中的 Promise 队列中,接着跳出 async1 函数来执行后面的代码。 - script 任务继续往下执行,遇到 Promise 实例。由于 Promise 中的函数是立即执行的,而后续的
.then
则会被分发到微队列中去。所以会先输出promise1
,然后执行resolve
,将promise2
分配到对应队列。 - script 任务继续往下执行,最后只有一句输出了
script end
,至此,全局任务就执行完毕了。 - 在 script 任务执行完毕之后,开始查找清空微任务队列。此时,微任务中,
Promise
队列有的两个任务async1 end
和promise2
,因此按先后顺序输出async1 end,promise2
。 - 最后从
setTimeout
队列中,依次取出任务执行,至此整个流程结束。
参考资料