浏览器中的Event Loop(事件循环)机制

211 阅读7分钟

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调用都已结束。

image.png

非阻塞是在单线程中而言,异步是在多线程中而言。异步+非阻塞机制帮助JavaScript完成高并发。

Browser EventLoop(重绘,重排,composite竟然是这样~)

首先推荐大家阅读这篇文章,了解重绘,重排,合成。

规范中,EventLoop过程中包括渲染事件,详情参考本文,下面展开详细描述。

一般在浏览器中rendering被划分为以下几个阶段:

image.png

但是实际上:

  • 每个厂商有自己的规定过程。例如在chrome中render过程包括但不限于下面的阶段:

image.png

  • 规范中只是提供了定义,但是没有提供实现。所以实际情况中每家浏览器厂商都有自己渲染过程的内部实现。

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运行过程图示:

image.png

  • 这里的每一个task代表一轮Event Loop。

  • 渲染线程和JavaScript执行线程同属于一个线程。

如上图所示:解析HTML和执行自定义JavaScript函数fn在同一个Task中,首先进行的解析HTML,生成DOM树,遇到<script>节点会将对该节点特殊处理。开始Evaluate Script阶段。

image.png

如上图所示:浏览器会前我们编写的代码进行解析,也就是上面所说的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的执行过程为:

eventloop-1.png

遍历Macro Task queue的时候,不同类别的Macro Task会放在不同的queue中。每次将一种类别的Macro Task执行完之后都会遍历Micro Task queue。所以micro task的执行算是一种见缝插针机制。事件循环机制是 V8 提供的,所以NodeJS和浏览器在task和microTask之间对任务的切换是一样的过程。

image.png

事件循环中可能会有一个或多个任务队列,这些队列分别是为了处理:

  • 鼠标和键盘事件

  • 其他的一些 Task

浏览器会在保持任务顺序的前提下,可能分配四分之三的优先权给鼠标和键盘事件,保证用户的输入得到最高优先级的响应,而剩下的优先级交给其他 Task,并且保证不会“饿死”它们。

宏任务

  • setTimeout
  • setInterval
  • setImmediate
  • I/O
  • UI rendering

微任务

  • process.nextTick
  • promises
  • Object.observe
  • MutationObserver

参考文章