先简单聊一聊
JavaScript中的“Event Loop”应该是JavaScript中一个非常重要的一个知识点,至少在我以往的面试过程中被问到很多次。初次了解“Event Loop”相关知识的时候我想好多同学,特别是初学或者经验不是太丰富的同学大概是这样一个过程:(实际上,不仅仅限于“Event Loop”稍微有深度的知识也大概是这样)
- 打开浏览器,跳转到百度
- 搜索关键字Event Loop
- 点击第一条搜索结果
- 下拉滚动条,目测文章长度(此处有可能直接跳到第五步),试着阅读几分钟
- 当头皮感到发麻时,关闭页面,关闭浏览器, 整个学习过程结束,Cool!
言归正传,的确我们可能会被那些枯燥无味的概念、原理和理论知识所击败,越往下读越觉得枯燥。概念性的描述无法像代码那样直观的理解一个知识点,但是JavaScript中代码是怎样正确执行的恰好就需要“Event Loop”机制做支撑,所以想要完整的掌握这个知识点,确实需要耐心的了解各种涉及到的理论知识,当把一些概念理解清楚时,我们才能懂得JavaScript Runtime是如何实现”Event Loop“。届时,我们才能够完成从“看懂代码”--->”代码是如何执行“这个过程的转化。
了解完JavaScript Runtime中“Event Loop”的运行机制,我们可以很好的理解以下几个问题:
- 当用户点击页面上按钮到发出响应这个过程中JavaScript做了哪些工作
- setTimeout(callback)是如何工作的
- 某些情况下可能引发页面block的一些产生原因
- JavaScript中的异步是如何实现的
OK,让我们来一步步探究这些问题。
首先要了解的几个问题
- 浏览器的多进程和多线程
- 常说的JavaScript单线程是何含意
- 为什么JavaScript必须单线程执行
浏览器的多进程和多线程
浏览器的多进程
浏览器也是一个应用程序,当打开浏览器时,OS就会为其分配虚拟地址空间,创建对应的进程。我们打开一个浏览器,并不是创建了一个进程而是多个。
一般地,浏览器的主要进程包含以下几个:
①Browser进程 浏览器的主进程,我们简单的理解为它是一个“Leader”统领着各项工作,如:浏览器界面显示,页面管理等等。
②GPU进程 顾名思义是负责图形绘制渲染的工作。
③插件进程
④渲染进程 我们重点关注下这个,渲染进程即浏览器内核,主要用于控制页面渲染、JavaScript执行、各种event处理等等。
当我们每打开一个Tab时,就会对应创建新的渲染进程(当Tab是空白时,也就是没有打开任何网页的时候,会做优化,不会单独开辟新的进程)。
每个Tab在单独的进程中运行。很好理解,因为每个进程具有独立的地址空间,一个进程不能访问另一个进程的代码和数据,也不能直接的操作OS内核的代码和数据。在浏览器的层面去看,那就可以避免一个Tab中访问另一个Tab内的数据,同时当一个Tab发生崩溃时,也不会去影响其他Tab。
比如:打开一个Tab的console输入”while(true){}“,显然当前Tab显示的页面会被block,你无法去点击和选中页面上的任何内容。当你切换到其他Tab时,完全没有受到影响。
浏览器内核中的多线程
我们都知道,线程是隶属进程之中的,线程间共享进程中分配的资源,那么浏览器内核或者说是渲染进程中都会维护一组线程来进行工作,主要包含以下几个线程:
- GUI渲染线程 主要负责页面的渲染,包括解析Html解析和计算CSS,构建渲染树,布局和绘制,渲染时也会维护一个Queue,我们后面再去展开讨论。
- JavaScript引擎线程 顾名思义,解析JavaScript,所有的JavaScript相关代码都会在JavaScript引擎上去执行,后续详细讨论JavaScript引擎线程。
- 事件触发线程 当对应的事件触发,比如用户点击、发送Ajax等等,事件触发线程会把对应的事件的callback加入到Tasks Queue中,等到JavaScript线程Stack清空时,会把事件对应的callback压入JavaScript引擎线程Stack中去执行。
- Http请求线程 通过浏览器发送Http请求,当请求的Status发送变化时,比如请求成功或者请求失败,会将对应的callback加入到Tasks Queue中,等到JavaScript线程Stack清空时,会把事件对应的callback压入JavaScript引擎线程Stack中去执行。
- 定时触发器线程 因为JS引擎是单线程的,如果我们把定时器的执行过程直接放在JS引擎中处理,当遇到Stack中有大量Function堆积时,那么定时器或者延时器的计时是不准确的。现在当我们触发setTimeout之后,事件触发线程会将对应的任务单独添加到定时器触发线程中去执行等待,当等待结束后,会将对应的callback加入到Tasks Queue中,等到JavaScript线程Stack清空时,会把事件对应的callback压入JavaScript引擎线程Stack中去执行。
常说的JavaScript单线程是何含意
当我们接触JavaScript这门语言的时候,我们或多或少都听过或者看到过“JavaScript是线程的”、“JavaScript语言是单线程的”这样的话。那么到底该如何去理解呢?我觉得不能说JavaScript这门语言设计是单线程的,这句话原本就让人读起来就有点别扭,“线程”这个术语就不属于“JS语言”的单独范畴。通过前面的描述我们都知道,JS的解析和运行是在JS引擎线程中进行的,JS引擎线程存在且唯一,所以我觉得正确的理解应该是“JavaScript是单线程运行的“或者说是”JavaScript在浏览器中运行是单线程的”,这与它的运行环境有关。
为什么JavaScript必须单线程执行
JavaScript主要职责就是处理页面与用户的交互、操作DOM树、操作CSS样式以及相关逻辑处理。我们以操作DOM来说,假设有两个线程同时去操作一个DOM元素,那么这个DOM元素就会成为线程间竞争的资源,我们就需要去处理线程同步,那么事情将变得一步步复杂起来,所以JavaScript要单线程执行。即使后续引入Web Worker来提高CPU的利用率,子线程受控于主线程,子线程依然不能操作DOM元素。
JavaScript引擎线程
Ok,JS引擎线程跟其他线程一样,有一个分配内存空间的共享堆和一个栈,栈中存储传递给方法的局部变量、实参以及记录栈中方法执行位置的一个地址。
我们简单来说一下,JS中方法是如何在Call Stack中执行的。
function func1() {
return func2();
}
function func2() {
return func3();
}
function func3() {
console.log('over!');
}
func1();
我想上述的过程都不陌生,至少在软件工程课上好多同学都画过,整个执行过程很简单,当有方法需要执行时入栈,执行完毕出栈,直到执行栈清空。如果遇到递归没有出口时,整个执行栈将会迅速被压满溢出,浏览器会抛出如下异常:
Event Loop
当我们使用XMLHTTPRequest发送一个请求时,我们依旧可以在页面上选择文本;当我们设置一个setTimeout后,页面并没有发生阻塞,而是在某个地方为我们默默地倒计时;既然我们前文已经说明了Javascript是单线程运行的,那上述的情况背后又是如何工作的呢?我们的JavaScript代码能够按照某种“特定”的顺序执行,实际上就是“Event Loop”机制在起到关键性作用。
简单认识下“Event Loop”
"Event Loop"一般我们称之为“事件循环”(也有称之为“事件轮询”),是一种以事件驱动为思想的执行模型或者运行机制。不同JavaScript运行环境对“Event Loop”机制有不同的具体实现,比如”浏览器环境“、”Node环境“等等。HTML5标准规范中关于“Event Loop”的相关介绍
后续所讨论的“Event Loop”的相关内容,都是基于浏览器运行环境下实现的。
我们围绕以下几个话题进行讨论:
- Call Stack
- Queue
- Event loop的整个事件驱动流程
Call Stack
实际上前文我们已经图文并茂的介绍了JS引擎线程中的Call Stack,当一个个function被调用时,按照顺序入栈执行,执行完毕之后依次出栈。我们的JavaScript同步代码直接在调用栈中执行,如:变量赋值,console.log等等,异步代码如setTimeout、send XMLHttpRequest则在调用栈中触发,然后调用浏览器的某个webapi将对应的任务放到某个地方(background threads)中去执行,待执行完毕或者达到某种状态时将该任务对应的callback加入到某个对应队列中,待callstack清空后,对应的callback入栈执行。
Queue
队列,Queue是“Event Loop”机制中一个非常重要的组成部分,里面存储了对应task的callback。当call stack清空时,会读取任务队列中的callback加入到stack中等待执行。
实际上,整个运行机制中一共维护了三个队列,分别是:
- Macro queue / Tasks queue 宏队列
- Micro queue 微队列
- Render queue / Animation callbacks 渲染队列
三个不同的queue有着不同的读取优先级,同时在每轮“Event Loop”中不同队列中的callback入栈的情况也是不同的。
Macro queue / Tasks queue 宏队列
以下几种任务会进入宏队列:
- setTimeout
- setInterval
- 事件触发
- I/O
Micro queue 微队列
以下几种任务会进入宏队列:
- Promise
- MutationObserver
Render queue / Animation callbacks 渲染队列
RAF(requestAnimationFrame)回调及Render流程
需要注意的点:
- 无论是哪个队列都必须等到栈清空后才能读取队列中的callback入栈执行
- 三个队列的优先级分别是:微队列 > 渲染队列 >宏队列
- 若Micro queue不为空,每轮事件循环会依次将Micro queue中的所有callback入栈执行直到Micro queue清空。若期间又有新的微任务产生,则会继续入队列,等待压入栈中执行,直到Micro queue队列清空,进入下一个流程。若微任务的生成速度大于微任务的执行速度,那么Micro queue的出队列入栈过程将一直持续下去,阻塞整个“Event Loop”
- 若Render queue / Animation callbacks不为空,每轮事件循环会依次将Render queue中的所有callback入栈执行直到Render queue清空。若回调中有新的RAF回调,则会进入Render queue在下次事件循环中执行
- 若Tasks queue不为空,每轮事件循环从Tasks queue中读取队头的callback入栈执行,期间新添加的task从Tasks queue队尾进队列等待后续事件循环执行
Event loop完整流程
- 同步代码在call stack中执行,直到所有同步代码执行完毕,stack清空。(同步代码并不说明等同于同步任务,比如同步设置一个4s的setTimeout,实际等待过程是异步的;期间根据不同的任务会加入到不同的队列中,比如Promise、sendHttp分别进入到微队列和宏队列中)
- 读取微队列micro queue,若微队列长度不为0,则从队首依次读取队中元素到stack中执行callback,直到微队列清空,此步骤才算结束;若期间微任务不断产生则一直持续入栈执行。
- 微队列处理完成后,读取render queue或者是RAF callbacks,若渲染队列长度不为0,则从队首依次读取队中元素到stack中执行callback,直到渲染队列清空,此步骤才算结束;若期间又有RAF回调不断产生,则放到下一轮“Event Loop”执行。【RAF callback是在每次重绘(Style、Layout、Paint)之前更新动画】
- 最后读取宏队列macro queue,若微队列长度不为0,则只读取队首元素到stack中执行,queue长度-1,期间有新的宏任务产生则入队列,等待后续“Event Loop”执行。
- 等待stack中function执行完毕清空,本轮“Event Loop”结束。
- 循环往复Step2-5
至此,根据上述大篇幅的介绍和分析,我想现在我们应该可以很好的理解上述我画的这幅图。
关于setTimeout的探讨
- setTimeout(fn,0)的分析
- setTimeout并没有想象的那么“准确”
- setTimeout非阻塞
setTimeout(fn,0)的分析
首先我们要知道,setTImeout、setInterval是浏览器提供给我们的webapi(Web API 接口参考),我们可以用JavaScript代码去调用这些api进行使用。
那么我们先看下面的代码片
console.log('hello')
setTimeout(function () {
console.log('timer')
}, 0)
console.log('js')
最最开始的时候学习JS的时候,对“setTimeout”的定义应该是“在n毫秒之后执行fn”,那么就觉得第二参数传0应该就是立即执行啊,实则不然,根据我们前面所讲述的,应该很容易分析出答案。
我把上述代码的运行过程再次画出来,进一步让大家理解的更加清晰一点。
所以说setTimeout(fn,0)不能准确的表示fn立即执行,而表示尽快的执行。
因为setTimeout会创建一个异步任务,等待结束后callback加入tasks queue等待入栈执行。而tasks queue入栈的前提就是stack必须为空,所以即使设置为0,当有大量同步代码在主线程中执行时,就必须等它们执行结束,stack清空后才能去执行setTimeout的回调。
要强调的是:即使setTimeout的ms参数设置为0,在w3c的标准规范下,也会延长到4ms左右(大概是4.7ms)。
setTimeout并没有想象的那么“准确”
setTimeout(function () {
console.log('s1')
}, 1000)
setTimeout(function () {
console.log('s2')
}, 1000)
setTimeout(function () {
console.log('s3')
}, 1000)
上述代码设置了三个延时器,那么根据我们上述的分析很容易知道,这三个宏任务会并行等待1s后依次加入tasks queue。又根据我们前文所说的tasks queue的入栈执行情况(每次读取队头入栈执行)可知,callback2入栈执行必须等待callback1执行完毕栈清空,callback3入栈执行必须等待callback2执行完毕栈清空。那么callback1在执行时callback2只能等待,那么对于第二个setTimeout来说,整个过程肯定不止等了1000ms,第三个setTimeout则等了更长。
所以,“setTimeout(fn,n)”应该是fn最快在n毫秒后执行。
setTimeout非阻塞
<button id="btn" style="width: 300px;height: 50px;">while(true)</button>
<script>
document.querySelector('#btn').addEventListener('click',function(){
while(true){}
})
</script>
点击按钮后,页面block,文字不能选中,按钮不能点击,因为同步代码引发死循环,stack一直不为空,阻塞了UI render。
<button id="btn" style="width: 300px;height: 50px;">while(true)</button>
<script>
document.querySelector('#btn').addEventListener('click', function () {
let foo = () => {
setTimeout(foo, 0);
};
foo();
})
</script>
貌似看来这段代码也是一个递归导致的死循环,但是当我们点击按钮之后,页面就好像什么都没有发生一样,并不会阻塞页面渲染,文字可以选中,按钮可以正常点击。
实际上我们稍加分析便知,前文说明了队列读取的优先级,渲染队列的读取优先级是高于宏队列的,所以虽然有源源不断的宏任务产生,入队列然后入栈执行。但整个过程中如果遇到UI Render要执行(60HZ屏幕,一般是16.67ms render一次),则UI Render正常立即执行,所以整个过程并不阻塞页面渲染。
“Event Loop”对页面渲染可能带来的影响
尽量不要阻塞“Event Loop"
我们继续将上述案例给改写成如下形式:
<button id="btn" style="width: 300px;height: 50px;">while(true)</button>
<script>
document.querySelector('#btn').addEventListener('click', function () {
let foo = () => {
Promise.resolve().then(foo)
}
foo()
})
</script>
当我们点击按钮发现页面被block,文字无法选中,按钮无法点击。在我们前面的分析得出,不断产生任务不是没有阻塞JS线程的stack吗?怎么还是会阻塞页面渲染?
因为Promise是会产生微任务,进入微队列。我们也说过微队列的读取顺序的优先级是最高的在渲染之前,同时也说过微队列在读取时一直会把队列清空后才能进入下一环节,上述案例显然微任务在不断产生,micro queue永远不能被清空,一直在阻塞整个“Event Loop”,导致后续UI Render不能被执行,页面不能重绘。
综合案例
案例一
el.addEventListener('click', () => {
console.log(1);
new Promise((resolve, _) => {
console.log(2);
resolve()
}).then(() => {
console.log(3);
})
})
el.addEventListener('click', () => {
console.log(4);
setTimeout(() => {
console.log(5);
}, 0);
Promise.resolve().then(() => {
console.log(6);
})
})
页面手动点击按钮后,console该如何输出?
请先认真思考一下再看正确结果。
应该很容易理解上述输出,我们简单理一下执行过程。
首先同步代码逻辑:
1.获取dom元素
2.为按钮添加一个事件监听listener1
3.为按钮添加一个事件监听listener2
按钮点击:
目前tasks queue分别存放listener1的 callback、listener2的 callback;micro queue length = 0
- 首先读取micro queue为空,读取task queue,取队头callback1入栈执行
- log(1)执行->出栈 输出 1
- 设置Promise,同步执行log(2),log出栈[Promise中then callback、catch callback才是异步回调的],同时向micro queue 中加入promise callback1,此时栈清空。输出 2
- 栈清空,优先读取micr oqueue,log(3)入栈执行,log(3)出栈,此时栈清空。输出 3
- 栈清空,micro queue为空,tasks queue中队头为listener2的 callback,入栈执行,tasks queue清空。
- log(4)入栈执行,log(4)出栈,设置setTimeout,tasks queue中添加setTimeout callback。输出 4
- 设置Promise,micro queue中添加promise callback2,listener2的callback执行完毕,此时栈清空。
- 优先读取micro queue,promise callback2入栈执行,log(6)入栈,log(6)执行完毕出栈。输出 6
- 再读取tasks queue,setTimeout callback入栈执行,log(5)入栈,log(5)执行完毕出栈。输出 5
故以上代码片输出顺序为: 1 2 3 4 6 5
案例二
const el1 = document.querySelector('#btn1')
el.addEventListener('click', () => {
console.log(1);
new Promise((resolve, _) => {
console.log(2);
resolve()
}).then(() => {
console.log(3);
})
})
el.addEventListener('click', () => {
console.log(4);
setTimeout(() => {
console.log(5);
}, 0);
Promise.resolve().then(() => {
console.log(6);
})
})
el.click();
请先认真思考一下再看正确结果。
乍一看,代码逻辑好像是没有发生变化,一个是手动点击按钮,一个是JS代码触发点击事件,那么为什么输出结果就发生变化了呢?
对比完两个输出结果可以发现,listener1 callback同步代码执行完之后,立即执行了listener2 callback而不是micro queue。
是因为,在读去任何队列的前提一定是调用栈为空,那么最后一句el.click()始终在调用栈上还未出栈,所以说listener1 callback同步代码执行完之后不能立即读取队列,而应该继续执行click触发的listener2,然后继续执行,直到listener2 callback执行完毕后,click出栈,开始读取队列,目前micro queue中有两个元素:promise1 callback、promise2 callback,tasks queue中有一个元素:setTimeout callback,按照“Event Loop”的执行顺序最后的输出结果为:1 2 4 3 6 5
写在最后
至此,基本上把浏览器的“Event Loop”实现给阐述清楚了,从浏览器的多进程、多线程到JS引擎中的堆栈调用再到”Event Loop“的引出,同时又讲述了”setTimeout的那些事“、阻塞”Event Loop“可能带来的不良影响等问题,在一些原理性较深,较难理解的问题,我都通过手工绘图的方式复现整个执行过程可视化的分析,帮助大家快速理解相关的问题,最后给出了两个综合案例帮助大家进一步巩固”Event Loop“的运行机制,循序渐进,希望能从浅入深把”Event Loop“这个重要的知识点牢牢掌握。花了数天时间推敲和揣摩这篇文章,整个写作过程也让自己对这个知识点掌握的更加牢固,同时也希望能给大家带来帮助,我相信如果能够耐心把这篇文章看完,我相信多多少少都会有自己的收获。