EventLoop 简介
JavaScript是一门单线程的语言。单线程是指JavaScript在运行阶段(注意,是在运行阶段
)一直在单个栈中执行。
这个栈被称为
JavaScript栈
,因为闭包的存在,该栈和OS栈
不能合并。JS栈中栈帧和OS的栈帧有很大的差异,JS的栈帧保存的是引用,引用的对象中包含变量,OS栈的栈帧中储存的是变量,所以合并起来比较困难。
多个线程同时操作DOM会产生并发,无疑会增加操作DOM的难度。
虽然单个线程在运行JavaScript,但是JavaScript是一门非阻塞的语言。
之所以设计为非阻塞的语言,是因为JavaScript在设计的时候为了适应用户在不同环境下和浏览器交互。 例如:用户上传文件,读取文件等。
非阻塞是指JavaScript遇到阻塞调用的时候不会等待结果的返回,立刻执行栈中的下一步。
JavaScript还可以执行异步操作。
非阻塞和异步是截然不同的两个概念:非阻塞针对单线程,异步针对多线程。
接下来我们通过一个Demo来描述非阻塞和异步之间的关系。
setTimeout(() => console.log('a'));
console.log('b')
上例中首先输出b,然后输出a。
过程描述:
- JavaScript线程调用setTimeout函数。
除了setTimeout函数JavaScript还有很多异步API,例如:readFile、XMLHTTPRequest等。这些API是由JavaSript平台提供的。我们平时写的promise等异步函数归根结底也要依靠平台提供的异步API实现异步操作。用户不能自定义异步API。
- 调用了原生的异步API之后,由V8引擎将异步API翻译给JavaScript平台,调用平台对应的API。控制权一直在该JavaScrip主线程中,继续向下执行代码,就像从未调用异步API一样。
JavaScript代码的运行一直是在JavaScript栈中进行的,该栈是单线程栈。
-
异步操作执行完毕之后,平台会在事件队列中添加一个任务,每个线程都有自己的队列,异步操作的结果通过队列发回主线程。任务中有关于调用的元信息,还有主线程中回调函数的引用。
-
主线程的调用堆栈清空后,平台将检查事件队列中有没有待处理的任务。如果有等待处理的任务,平台将着手处理,触发一个函数调用,把控制权返回给主线程中的那个函数。调用那个函数之后,如果主线程中的栈又变空了,平台再次检查事件队列中有没有可以处理的任务,这个循环会一直运转下去,直到调用堆栈和事件队列都为空,而且所有原生的异步API调用都已结束。
非阻塞是在单线程中而言,异步是在多线程中而言。异步+非阻塞机制帮助JavaScript完成高并发。
Browser EventLoop(重绘,重排,composite竟然是这样~)
首先推荐大家阅读这篇文章,了解重绘,重排,合成。
规范中,EventLoop过程中包括渲染事件,详情参考本文,下面展开详细描述。
一般在浏览器中rendering被划分为以下几个阶段:
但是实际上:
- 每个厂商有自己的规定过程。例如在chrome中render过程包括但不限于下面的阶段:
- 规范中只是提供了定义,但是没有提供实现。所以实际情况中每家浏览器厂商都有自己渲染过程的内部实现。
task分解
参考事件循环规范理解在EventLoop中什么是task。
下文是根据事件循环规范和NodeJS事件循环给出的浏览器事件循环机制的猜测。一定不会百分百正确。
前端编程过程
前端工程师在编写网页的时候通过<html>
标签将代码包括起来,代码一般编辑如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body></body>
</html>
当需要与网页互动的时候,我们需要引入<script>
标签加入JavaScript代码。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body></body>
<script></script>
</html>
我们将写好的代码保存起来整理成****.html
文件,双击打开,在浏览器中就能查看我们写出的效果。
1、浏览器加载html文件过程中有一个主线程Main负责解析运行html文件。所以需要首先准备好解析器、JavaScript执行入口(可以类比Java的Call_Stub的实现)等前提工作。
// test1.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body></body>
<script>
function fn() {
for (var i = 0; i < 1000000; i++) {
i = i + 1;
}
}
fn();
</script>
</html>
test1.html
运行过程图示:
-
这里的每一个task代表一轮Event Loop。
-
渲染线程和JavaScript执行线程同属于一个线程。
如上图所示:解析HTML和执行自定义JavaScript函数fn在同一个Task中,首先进行的解析HTML,生成DOM树,遇到<script>
节点会将对该节点特殊处理。开始Evaluate Script阶段。
如上图所示:浏览器会前我们编写的代码进行解析,也就是上面所说的Parse HTML阶段,当Parse HTML遇到script对应的Node节点时,找到浏览器厂商DOM类中对应的Native Code,开始Evaluate Script过程。执行过程中中断Parse HTML(如果使用async等关键字则不会打断Parse HTML的过程),Evaluate Script结束以后,还会继续解析没有解析的HTML标签。
执行JavaScript的过程可以类比执行Java的过程,C语言通过函数指针可以直接对内存操作,call_Stub函数是Java代码和C代码(JVM虚拟机)之间的桥梁,所以这里我的猜想是V8通过函数指针实现了对JavaScript代码的执行。这里的类比不一定正确,但是这种类比能想通V8是怎么执行JavaScript的,就不会对Parse HTML(无论该阶段是C语言编写或者是JavaScript编写)转化到Evaluate Script感到不可思议了。
现在流行的SPA应用,也是HTML标签和JavaScript语法的应用,组件式编程原理是通过Class或者Function等JavaScript代码来修改DOM。JSX则是HTML标签和JavaScript的语法糖,原理还是通过Function/Class的实例来修改DOM。
仔细观察会发现函数fn是由函数anonymous调用的。这里anonymous是谁呢?
事实上,chrome浏览器会把我们在单个<script>标签中的所有JavaScript代码打包成一个anonymous函数。然后被当做宏任务的一个task。
合并现象不仅出现在<script>中,还出现在其他的同类宏任务中。如下面的代码所示:
setTimeout(() => {
console.log("sto")
requestAnimationFrame(() => console.log("rAF"))
})
setTimeout(() => {
console.log("sto")
requestAnimationFrame(() => console.log("rAF"))
})
queueMicrotask(() => console.log("mic"))
queueMicrotask(() => console.log("mic"))
//mic
//mic
//sto
//sto
//rAF
//rAF
2、进入每一个task之后会根据浏览器的事件循环机制来从task queue中取出task并执行。
猜测这里事件循环的触发实际应该有1、每隔几毫秒触发一次。2、运行特殊任务(例如打开浏览器)。
task的执行过程为:
遍历Macro Task queue的时候,不同类别的Macro Task会放在不同的queue中。每次将一种类别的Macro Task执行完之后都会遍历Micro Task queue。所以micro task的执行算是一种见缝插针机制。事件循环机制是 V8 提供的,所以NodeJS和浏览器在task和microTask之间对任务的切换是一样的过程。
事件循环中可能会有一个或多个任务队列,这些队列分别是为了处理:
鼠标和键盘事件
其他的一些 Task
浏览器会在保持任务顺序的前提下,可能分配四分之三的优先权给鼠标和键盘事件,保证用户的输入得到最高优先级的响应,而剩下的优先级交给其他 Task,并且保证不会“饿死”它们。
宏任务
- setTimeout
- setInterval
- setImmediate
- I/O
- UI rendering
微任务
- process.nextTick
- promises
- Object.observe
- MutationObserver