JavaScript中的“Event Loop”应该是JavaScript中一个非常重要的一个知识点,至少在我以往的面试过程中被问到很多次。初次了解“Event Loop”相关知识的时候我想好多同学,特别是初学或者经验不是太丰富的同学大概是这样一个过程:(实际上,不仅仅限于“Event Loop”稍微有深度的知识也大概是这样)
言归正传,的确我们可能会被那些枯燥无味的概念、原理和理论知识所击败,越往下读越觉得枯燥。概念性的描述无法像代码那样直观的理解一个知识点,但是JavaScript中代码是怎样正确执行的恰好就需要“Event Loop”机制做支撑,所以想要完整的掌握这个知识点,确实需要耐心的了解各种涉及到的理论知识,当把一些概念理解清楚时,我们才能懂得JavaScript Runtime是如何实现”Event Loop“。届时,我们才能够完成从“看懂代码”--->”代码是如何执行“这个过程的转化。
了解完JavaScript Runtime中“Event Loop”的运行机制,我们可以很好的理解以下几个问题:
OK,让我们来一步步探究这些问题。
浏览器也是一个应用程序,当打开浏览器时,OS就会为其分配虚拟地址空间,创建对应的进程。我们打开一个浏览器,并不是创建了一个进程而是多个。
一般地,浏览器的主要进程包含以下几个:
①Browser进程 浏览器的主进程,我们简单的理解为它是一个“Leader”统领着各项工作,如:浏览器界面显示,页面管理等等。
②GPU进程 顾名思义是负责图形绘制渲染的工作。
③插件进程
④渲染进程 我们重点关注下这个,渲染进程即浏览器内核,主要用于控制页面渲染、JavaScript执行、各种event处理等等。
当我们每打开一个Tab时,就会对应创建新的渲染进程(当Tab是空白时,也就是没有打开任何网页的时候,会做优化,不会单独开辟新的进程)。
每个Tab在单独的进程中运行。很好理解,因为每个进程具有独立的地址空间,一个进程不能访问另一个进程的代码和数据,也不能直接的操作OS内核的代码和数据。在浏览器的层面去看,那就可以避免一个Tab中访问另一个Tab内的数据,同时当一个Tab发生崩溃时,也不会去影响其他Tab。
比如:打开一个Tab的console输入”while(true){}“,显然当前Tab显示的页面会被block,你无法去点击和选中页面上的任何内容。当你切换到其他Tab时,完全没有受到影响。
我们都知道,线程是隶属进程之中的,线程间共享进程中分配的资源,那么浏览器内核或者说是渲染进程中都会维护一组线程来进行工作,主要包含以下几个线程:
当我们接触JavaScript这门语言的时候,我们或多或少都听过或者看到过“JavaScript是线程的”、“JavaScript语言是单线程的”这样的话。那么到底该如何去理解呢?我觉得不能说JavaScript这门语言设计是单线程的,这句话原本就让人读起来就有点别扭,“线程”这个术语就不属于“JS语言”的单独范畴。通过前面的描述我们都知道,JS的解析和运行是在JS引擎线程中进行的,JS引擎线程存在且唯一,所以我觉得正确的理解应该是“JavaScript是单线程运行的“或者说是”JavaScript在浏览器中运行是单线程的”,这与它的运行环境有关。
JavaScript主要职责就是处理页面与用户的交互、操作DOM树、操作CSS样式以及相关逻辑处理。我们以操作DOM来说,假设有两个线程同时去操作一个DOM元素,那么这个DOM元素就会成为线程间竞争的资源,我们就需要去处理线程同步,那么事情将变得一步步复杂起来,所以JavaScript要单线程执行。即使后续引入Web Worker来提高CPU的利用率,子线程受控于主线程,子线程依然不能操作DOM元素。
Ok,JS引擎线程跟其他线程一样,有一个分配内存空间的共享堆和一个栈,栈中存储传递给方法的局部变量、实参以及记录栈中方法执行位置的一个地址。
我们简单来说一下,JS中方法是如何在Call Stack中执行的。
function func1() {
return func2();
}
function func2() {
return func3();
}
function func3() {
console.log('over!');
}
func1();
我想上述的过程都不陌生,至少在软件工程课上好多同学都画过,整个执行过程很简单,当有方法需要执行时入栈,执行完毕出栈,直到执行栈清空。如果遇到递归没有出口时,整个执行栈将会迅速被压满溢出,浏览器会抛出如下异常:
当我们使用XMLHTTPRequest发送一个请求时,我们依旧可以在页面上选择文本;当我们设置一个setTimeout后,页面并没有发生阻塞,而是在某个地方为我们默默地倒计时;既然我们前文已经说明了Javascript是单线程运行的,那上述的情况背后又是如何工作的呢?我们的JavaScript代码能够按照某种“特定”的顺序执行,实际上就是“Event Loop”机制在起到关键性作用。
"Event Loop"一般我们称之为“事件循环”(也有称之为“事件轮询”),是一种以事件驱动为思想的执行模型或者运行机制。不同JavaScript运行环境对“Event Loop”机制有不同的具体实现,比如”浏览器环境“、”Node环境“等等。HTML5标准规范中关于“Event Loop”的相关介绍
后续所讨论的“Event Loop”的相关内容,都是基于浏览器运行环境下实现的。
我们围绕以下几个话题进行讨论:
实际上前文我们已经图文并茂的介绍了JS引擎线程中的Call Stack,当一个个function被调用时,按照顺序入栈执行,执行完毕之后依次出栈。我们的JavaScript同步代码直接在调用栈中执行,如:变量赋值,console.log等等,异步代码如setTimeout、send XMLHttpRequest则在调用栈中触发,然后调用浏览器的某个webapi将对应的任务放到某个地方(background threads)中去执行,待执行完毕或者达到某种状态时将该任务对应的callback加入到某个对应队列中,待callstack清空后,对应的callback入栈执行。
队列,Queue是“Event Loop”机制中一个非常重要的组成部分,里面存储了对应task的callback。当call stack清空时,会读取任务队列中的callback加入到stack中等待执行。
实际上,整个运行机制中一共维护了三个队列,分别是:
三个不同的queue有着不同的读取优先级,同时在每轮“Event Loop”中不同队列中的callback入栈的情况也是不同的。
以下几种任务会进入宏队列:
以下几种任务会进入宏队列:
RAF(requestAnimationFrame)回调及Render流程
需要注意的点:
至此,根据上述大篇幅的介绍和分析,我想现在我们应该可以很好的理解上述我画的这幅图。
首先我们要知道,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(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毫秒后执行。
点击按钮后,页面block,文字不能选中,按钮不能点击,因为同步代码引发死循环,stack一直不为空,阻塞了UI render。
貌似看来这段代码也是一个递归导致的死循环,但是当我们点击按钮之后,页面就好像什么都没有发生一样,并不会阻塞页面渲染,文字可以选中,按钮可以正常点击。
实际上我们稍加分析便知,前文说明了队列读取的优先级,渲染队列的读取优先级是高于宏队列的,所以虽然有源源不断的宏任务产生,入队列然后入栈执行。但整个过程中如果遇到UI Render要执行(60HZ屏幕,一般是16.67ms render一次),则UI Render正常立即执行,所以整个过程并不阻塞页面渲染。
我们继续将上述案例给改写成如下形式:
当我们点击按钮发现页面被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
故以上代码片输出顺序为: 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“这个重要的知识点牢牢掌握。花了数天时间推敲和揣摩这篇文章,整个写作过程也让自己对这个知识点掌握的更加牢固,同时也希望能给大家带来帮助,我相信如果能够耐心把这篇文章看完,我相信多多少少都会有自己的收获。