前端学习路线-3-浏览器及其运行机制

When you win, say nothing. When you lose, say less.
Paul Brown

本系列关注前端部分,根据学习路线图达到学习Vue.js的目的

developer路线图developer-roadmap/translations/chinese at master · kamranahmedse/developer-roadmap

快速跳转

下一节:what-is-the-dns

正文

本文由 简悦 SimpRead 转码, 原文地址 developer.mozilla.org

页面内容快速加载和流畅的交互是用户希望得到的 Web 体验,因此,开发者应力争实现这两个目标。

了解如何提升性能和感知性能,有助于了解浏览器的工作原理。

概述

快速响应的网站提供更好的用户体验。用户期待内容快速加载和交互流畅的 Web 体验

等待资源加载时间和大部分情况下的浏览器单线程执行是影响 Web 性能的两大主要原因。

等待时间是需要去克服来让浏览器快速加载资源的主要威胁. 为了实现快速加载,开发者的目标就是尽可能快的发送请求的信息,至少看起来相当快。网络等待时间是在链路上传送二进制到电脑端所消耗的链路传输时间。 Web 性能优化需要做的就是尽可能快的使页面加载完成。

大部分情况下,浏览器是单线程执行的。为了有流畅的交互 ,开发者的目标是确保网站从流畅的页面滚动到点击响应的交互性能。渲染时间是关键要素,确保主线程可以完成所有给它的任务并且仍然一直可以处理用户的交互。通过了解浏览器单线程的本质与最小化主线程的责任可以优化 Web 性能,来确保渲染的流畅和交互响应的及时。

导航

导航是加载 web 页面的第一步。它发生在以下情形:用户通过在地址栏输入一个 URL、点击一个链接、提交表单或者是其他的行为

web 性能优化的目标之一就是缩短导航完成所花费的时间,在理想情况下,它通常不会花费太多的时间,但是等待时间和带宽会导致它的延时。

DNS 查找

对于一个 web 页面来说导航的第一步是要去寻找页面资源的位置。如果导航到https://example.com, HTML 页面 被定为到 IP 地址为 93.184.216.34 的服务器。如果以前没有访问过这个网站,就需要进行 DNS 查找。

浏览器通过服务器名称请求 DNS 进行查找,最终返回一个 IP 地址,第一次初始化请求之后,这个 IP 地址可能会被缓存一段时间,这样可以通过从缓存里面检索 IP 地址而不是再通过域名服务器进行查找来加速后续的请求

通过主机名加载一个页面通常仅需要 DNS 查找一次.。但是, DNS 需要对不同的页面指向的主机名进行查找。如果 fonts, images, scripts, ads, and metrics 都不同的主机名,DNS 会对每一个进行查找。

latency.jpg

DNS 查找对于性能来说是一个问题,特别是对于移动网络。当一个用户用的是移动网络,每一个 DNS 查找必须从手机发送到信号塔,然后到达一个认证 DNS 服务器。手机、信号塔、域名服务器之间的距离可能是一个大的时间等待。

TCP Handshake

一旦获取到服务器 IP 地址,浏览器就会通过 TCP” 三次握手 “与服务器建立连接。这个机制的是用来让两端尝试进行通信—浏览器和服务器在发送数据之前,通过上层协议 Https 可以协商网络 TCP 套接字连接的一些参数。

TCP 的” 三次握手 “技术经常被称为”SYN-SYN-ACK“—更确切的说是 SYN, SYN-ACK, ACK—因为通过 TCP 首先发送了三个消息进行协商,开始一个 TCP 会话在两台电脑之间。 是的,这意味着每台服务器之间还要来回发送三条消息,而请求尚未发出。

TLS 协商

为了在 HTTPS 上建立安全连接,另一种握手是必须的。更确切的说是 TLS 协商 ,它决定了什么密码将会被用来加密通信,验证服务器,在进行真实的数据传输之前建立安全连接。在发送真正的请求内容之前还需要三次往返服务器。

ssl.jpg

虽然建立安全连接对增加了加载页面的等待时间,对于建立一个安全的连接来说,以增加等待时间为代价是值得的,因为在浏览器和 web 服务器之间传输的数据不可以被第三方解密。

经过 8 次往返,浏览器终于可以发出请求。

响应

一旦我们建立了到 web 服务器的连接,浏览器就代表用户发送一个初始的 HTTP GET 请求,对于网站来说,这个请求通常是一个 HTML 文件。 一旦服务器收到请求,它将使用相关的响应头和 HTML 的内容进行回复。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!doctype HTML>
<html>
<head>
<meta charset="UTF-8"/>
<title>My simple page</title>
<link rel="stylesheet" src="styles.css"/>
<script src="myscript.js"></script>
</head>
<body>
<h1 class="heading">My Page</h1>
<p>A paragraph with a <a href="https://example.com/about">link</a></p>
<div>
<img src="myimage.jpg" alt="image description"/>
</div>
<script src="anotherscript.js"></script>
</body>
</html>

初始请求的响应包含所接收数据的第一个字节。”Time to First Byte“(TTFB) 是用户通过点击链接进行请求与收到第一个 HTML 包之间的时间。第一块内容通常是 14kb 的数据。

上面的例子中,这个请求肯定是小于 14kb 的,但是直到浏览器在解析阶段遇到链接时才会去请求链接的资源,下面有进行描述。

TCP 慢开始 / 14kb 规则

第一个响应包是 14kb 大小。这是慢开始的一部分,慢开始是一种均衡网络连接速度的算法。慢开始逐渐增加发送数据的数量直到达到网络的最大带宽。

在 “TCP slow start” 中,在收到初始包之后, 服务器会将下一个包的大小加倍到大约 28kb。 后续的包依次是前一个包大小的二倍直到达到预定的阈值,或者遇到拥塞。

congestioncontrol.jpg

如果您听说过初始页面加载的 14Kb 规则,TCP 慢开始就是初始响应为 14Kb 的原因,也是为什么 web 性能优化需要将此初始 14Kb 响应作为优化重点的原因。TCP 慢开始逐渐建立适合网络能力的传输速度,以避免拥塞。

拥塞控制

当服务器用 TCP 包来发送数据时,客户端通过返回确认帧来确认传输。由于硬件和网络条件,连接的容量是有限的。 如果服务器太快地发送太多的包,它们可能会被丢弃。意味着,将不会有确认帧的返回。服务器把它们当做确认帧丢失。拥塞控制算法使用这个发送包和确认帧流来确定发送速率。

解析

一旦浏览器收到数据的第一块,它就可以开始解析收到的信息。“推测性解析”,“解析” 是浏览器将通过网络接收的数据转换为 DOM 和 CSSOM 的步骤,通过渲染器把 DOM 和 CSSOM 在屏幕上绘制成页面。

DOM 是浏览器标记的内部表示。DOM 也是被暴露的,可以通过 JavaScript 中的各种 API 进行 DOM 操作。

即使请求页面的 HTML 大于初始的 14KB 数据包,浏览器也将开始解析并尝试根据其拥有的数据进行渲染。这就是为什么在前 14Kb 中包含浏览器开始渲染页面所需的所有内容,或者至少包含页面模板(第一次渲染所需的 CSS 和 HTML)对于 web 性能优化来说是重要的。但是在渲染到屏幕上面之前,HTML、CSS、JavaScript 必须被解析完成。

构建 DOM 树

我们描述五个步骤在这篇文章中 critical rendering path.

第一步是处理 HTML 标记并构造 DOM 树。HTML 解析涉及到 tokenization 和树的构造。HTML 标记包括开始和结束标记,以及属性名和值。 如果文档格式良好,则解析它会简单而快速。解析器将标记化的输入解析到文档中,构建文档树。

DOM 树描述了文档的内容。 元素是第一个标签也是文档树的根节点。树反映了不同标记之间的关系和层次结构。嵌套在其他标记中的标记是子节点。DOM 节点的数量越多,构建 DOM 树所需的时间就越长。

DOM.gif

当解析器发现非阻塞资源,例如一张图片,浏览器会请求这些资源并且继续解析。当遇到一个 CSS 文件时,解析也可以继续进行,

但是对于 script 标签,特别是没有 async 或者 defer 属性.

1
2
3
<script>

</script>

会阻塞渲染并停止 HTML 的解析。尽管浏览器的预加载扫描器加速了这个过程,但过多的脚本仍然是一个重要的瓶颈。

预加载扫描器

浏览器构建 DOM 树时,这个过程占用了主线程。当这种情况发生时,预加载扫描仪将解析可用的内容并请求高优先级资源,如 CSS、JavaScript 和 web 字体。多亏了预加载扫描器,我们不必等到解析器找到对外部资源的引用来请求它。它将在后台检索资源,以便在主 HTML 解析器到达请求的资源时,它们可能已经在运行,或者已经被下载。预加载扫描仪提供的优化减少了阻塞。

1
2
3
4
<link rel="stylesheet" src="styles.css"/>
<script src="myscript.js" async></script>
<img src="myimage.jpg" alt="image description"/>
<script src="anotherscript.js" async></script>

在这个例子中,当主线程在解析 HTML 和 CSS 时,预加载扫描器将找到脚本和图像,并开始下载它们。为了确保脚本不会阻塞进程,当 JavaScript 解析和执行顺序不重要时,可以添加 async 属性或 defer 属性。

等待获取 CSS 不会阻塞 HTML 的解析或者下载,但是它的确阻塞 JavaScript,因为 JavaScript 经常用于查询元素的 CSS 属性。

构建 CSSOM 树

第二步是处理 CSS 并构建 CSSOM 树。CSS 对象模型和 DOM 是相似的。DOM 和 CSSOM 是两棵树. 它们是独立的数据结构。浏览器将 CSS 规则转换为可以理解和使用的样式映射。浏览器遍历 CSS 中的每个规则集,根据 CSS 选择器创建具有父、子和兄弟关系的节点树。

与 HTML 一样,浏览器需要将接收到的 CSS 规则转换为可以使用的内容。因此,它重复了 HTML 到对象的过程,但对于 CSS。

CSSOM 树包括来自用户代理样式表的样式。浏览器从适用于节点的最通用规则开始,并通过应用更具体的规则递归地优化计算的样式。换句话说,它级联属性值。

构建 CSSOM 非常非常快,并且在当前的开发工具中没有以独特的颜色显示。相反,开发人员工具中的 “重新计算样式” 显示解析 CSS、构造 CSSOM 树和递归计算计算样式所需的总时间。在 web 性能优化方面,它是可轻易实现的,因为创建 CSSOM 的总时间通常小于一次 DNS 查找所需的时间。

其他过程

JavaScript 编译

当 CSS 被解析并创建 CSSOM 时,其他资源,包括 JavaScript 文件正在下载(多亏了 preload scanner)。JavaScript 被解释、编译、解析和执行。脚本被解析为抽象语法树。一些浏览器引擎使用”Abstract Syntax Tree“并将其传递到解释器中,输出在主线程上执行的字节码。这就是所谓的 JavaScript 编译。

构建辅助功能树

浏览器还构建辅助设备用于分析和解释内容的辅助功能(accessibility )树。可访问性对象模型(AOM)类似于 DOM 的语义版本。当 DOM 更新时,浏览器会更新辅助功能树。辅助技术本身无法修改可访问性树。

在构建 AOM 之前,屏幕阅读器(screen readers)无法访问内容。

渲染

渲染步骤包括样式、布局、绘制,在某些情况下还包括合成。在解析步骤中创建的 CSSOM 树和 DOM 树组合成一个 Render 树,然后用于计算每个可见元素的布局,然后将其绘制到屏幕上。在某些情况下,可以将内容提升到它们自己的层并进行合成,通过在 GPU 而不是 CPU 上绘制屏幕的一部分来提高性能,从而释放主线程。

Style

第三步是将 DOM 和 CSSOM 组合成一个 Render 树,计算样式树或渲染树从 DOM 树的根开始构建,遍历每个可见节点。

<head> 和它的子节点以及任何具有display: none样式的结点,例如script { display: none; }(在 user agent stylesheets 可以看到这个样式)这些标签将不会显示,也就是它们不会出现在 Render 树上。具有visibility: hidden的节点会出现在 Render 树上,因为它们会占用空间。由于我们没有给出任何指令来覆盖用户代理默认值,因此上面代码示例中的 script 节点将不会包含在 Render 树中。

每个可见节点都应用了其 CSSOM 规则。Render 树保存所有具有内容和计算样式的可见节点——将所有相关样式匹配到 DOM 树中的每个可见节点,并根据 CSS 级联确定每个节点的计算样式。

Layout

第四步是在渲染树上运行布局以计算每个节点的几何体。布局是确定呈现树中所有节点的宽度、高度和位置,以及确定页面上每个对象的大小和位置的过程。回流是对页面的任何部分或整个文档的任何后续大小和位置的确定。

构建渲染树后,开始布局。渲染树标识显示哪些节点(即使不可见)及其计算样式,但不标识每个节点的尺寸或位置。为了确定每个对象的确切大小和位置,浏览器从渲染树的根开始遍历它。

在网页上,大多数东西都是一个盒子。不同的设备和不同的桌面意味着无限数量的不同的视区大小。在此阶段,考虑到视区大小,浏览器将确定屏幕上所有不同框的尺寸。以视区的大小为基础,布局通常从 body 开始,用每个元素的框模型属性排列所有 body 的子孙元素的尺寸,为不知道其尺寸的替换元素(例如图像)提供占位符空间。

第一次确定节点的大小和位置称为布局。随后对节点大小和位置的重新计算称为回流。在我们的示例中,假设初始布局发生在返回图像之前。由于我们没有声明图像的大小,因此一旦知道图像大小,就会有回流。

Paint

最后一步是将各个节点绘制到屏幕上,第一次出现的节点称为 first meaningful paint。在绘制或光栅化阶段,浏览器将在布局阶段计算的每个框转换为屏幕上的实际像素。绘画包括将元素的每个可视部分绘制到屏幕上,包括文本、颜色、边框、阴影和替换的元素(如按钮和图像)。浏览器需要非常快地完成这项工作。

为了确保平滑滚动和动画,占据主线程的所有内容,包括计算样式,以及回流和绘制,必须让浏览器在 16.67 毫秒内完成。在 2048x 1536,iPad 有超过 314.5 万像素将被绘制到屏幕上。那是很多像素需要快速绘制。为了确保重绘的速度比初始绘制的速度更快,屏幕上的绘图通常被分解成数层。如果发生这种情况,则需要进行合成。

绘制可以将布局树中的元素分解为多个层。将内容提升到 GPU 上的层(而不是 CPU 上的主线程)可以提高绘制和重新绘制性能。有一些特定的属性和元素可以实例化一个层,包括 <video> < canvas>,任何 CSS 属性为 opacity、3D 转换、[will-change](https://developer.mozilla.org/en-US/docs/Web/CSS/will-change)的元素,还有一些其他元素。这些节点将与子节点一起绘制到它们自己的层上,除非子节点由于上述一个(或多个)原因需要自己的层。

层确实可以提高性能,但是它以内存管理为代价,因此不应作为 web 性能优化策略的一部分过度使用。

Compositing

当文档的各个部分以不同的层绘制,相互重叠时,必须进行合成,以确保它们以正确的顺序绘制到屏幕上,并正确显示内容。

当页面继续加载资产时,可能会发生回流(回想一下我们迟到的示例图像),回流会触发重新绘制和重新组合。如果我们定义了图像的大小,就不需要重新绘制,只需要重新绘制需要重新绘制的层,并在必要时进行合成。但我们没有包括图像大小!从服务器获取图像后,渲染过程将返回到布局步骤并从那里重新开始。

交互

一旦主线程绘制页面完成,你会认为我们已经 “准备好了”,但事实并非如此。如果加载包含 JavaScript(并且延迟到 [onload](https://developer.mozilla.org/en-US/docs/Web/API/GlobalEventHandlers/onload)事件激发后执行),则主线程可能很忙,无法用于滚动、触摸和其他交互。

”Time to Interactive“(TTI)是测量从第一个请求导致 DNS 查找和 SSL 连接到页面可交互时所用的时间——可交互是”First Contentful Paint“之后的时间点,页面在 50ms 内响应用户的交互。如果主线程正在解析、编译和执行 JavaScript,则它不可用,因此无法及时(小于 50ms)响应用户交互。

在我们的示例中,可能图像加载很快,但anotherscript.js文件可能是 2 MB,而且用户的网络连接很慢。在这种情况下,用户可以非常快地看到页面,但是在下载、解析和执行脚本之前,就无法滚动。这不是一个好的用户体验。避免占用主线程,如下面的 WebPageTest 示例所示:

visa_network.png

在本例中,DOM 内容加载过程花费了 1.5 秒多的时间,主线程在这段时间内完全被占用,对单击事件或屏幕点击没有响应。

See Also

本文由 简悦 SimpRead 转码, 原文地址 liyucang-git.github.io

本文从浏览器进程,再到浏览器内核运行,再到 JS 引擎单线程,再到 JS 事件循环机制,从头到尾系统的梳理一遍,摆脱碎片化,形成一个知识体系。

线程与进程

首先这里来个经典的列子简单了解下进程与线程:

1
2
3
4
5
6
7
8
9
进程是一个工厂,工厂有它的独立资源

工厂之间相互独立

线程是工厂中的工人,多个工人协作完成任务

工厂内有一个或多个工人

工人之间共享空间

对应到概念:

1
2
3
4
5
6
7
8
9
工厂的资源 -> 系统分配的内存(独立的一块内存)

工厂之间的相互独立 -> 进程之间相互独立

多个工人协作完成任务 -> 多个线程在进程中协作完成任务

工厂内有一个或多个工人 -> 一个进程由一个或多个线程组成

工人之间共享空间 -> 同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)

而之所以会有进程和线程的概念,是因为 CPU 与其他 PC 资源之间速度的不协调,人们想提高资源利用率,所以人们提出了多任务系统。得益于 CPU 的计算速度,我们可以 “同时” 运行多个任务,实质上是多个任务之间轮流使用 CPU 资源,由于速度超快,给用户的感觉就是连续的。

在 CPU 看来所有的任务都是一个一个的轮流执行的,具体的轮流方法就是: 先加载程序 A 的上下文,然后开始执行 A,保存程序 A 的上下文,调入下一个要执行的程序 B 的程序上下文,然后开始执行 B,保存程序 B 的上下文……

最后,用较为官方的术语简单总结一下:

  • 进程是 cpu 资源分配的最小单位 (是能拥有资源和独立运行的最小单位)
  • 线程是 cpu 调度的最小单位 (线程是建立在进程的基础上的一次程序运行单位,一个进程中可以有多个线程)

浏览器是多进程的

我们在 chromed 浏览器的任务管理器中可以查看到当前浏览器所有的进程:

1552642296722_1630.png

可以发现,浏览器一般会为每一个页面创建一个进程,以及浏览器自身的主进程和GPU进程。但在这里浏览器应该也有自己的优化机制,有时候打开多个 tab 页后,可以在 Chrome 任务管理器中看到,也会合并一些 tab 页面为一个进程,如: 标签页等。

经翻阅相关资料,浏览器主要有这些进程:

  1. 浏览器进程 (Browser 进程): 浏览器进程只有一个,用于管理标签页、窗口和浏览器本身。这个进程同时负责处理所有跟磁盘、网络、用户输入和显示的交互,然而它不分析和渲染任何网页内容。

  2. 第三方插件进程: 览器进程同样为处于使用状态的每种类型的插件创建一个进程,如: Flash、Quicktime 或 Adobe reader。这些进程仅仅包含插件本身以及和浏览器进程、渲染器进程交互的胶水代码。

  3. GPU 进程: 最多一个,用于 3D 绘制等

  4. 浏览器渲染进程 (浏览器内核)(Renderer 进程,内部是多线程的): 渲染器进程会存在多个,每个都负责渲染网页。渲染器进程中包含用于操作 HTML,JavaScript,CSS,图片和其他内容的复杂的逻辑。我们使用了也同样被 Apple Safari 浏览器使用的开源的 WebKit 渲染引擎实现以上功能。每个渲染进程都运行在沙箱内,这意味着它对磁盘、网络和显示器没有直接的访问权限。所有跟网络应用的交互,包括用户输入事件和屏幕绘制都必须通过浏览器进程。这可以让浏览器进程监视渲染器的可疑行为,一旦发现其从事破坏活动就将其终止。

在浏览器刚被设计出来的时候,那时的网页非常的简单,几乎没有动态的代码。这对仅使用一个进程渲染所有要访问的网页却仍然保持非常低的资源占有率是行得通的。

然而在今天我们看到大量网页转而使用动态网页,从含有大量 javascript 和 flash 的网页到像完全成熟的网络应用如 GMail。这些应用的很大一部分是在浏览器中运行的,就像运行在操作系统之上的应用程序一样。跟操作系统一样,浏览器必须让这些应用互相分离。

除此之外,浏览器中负责渲染 HTML,JavaScript 和 CSS 的部分日益的复杂。在这些渲染引擎在演化的过程中会频繁的出现 bug,有些 bug 会导致渲染引擎崩溃。不仅如此,渲染引擎会经常性的在网络上遇到不可信、甚至是恶意的代码,它们会利用这些漏洞在你的电脑上安装恶意的软件。

在当今世界,把所有东西都放进一个进程的浏览器面临在健壮性,响应速度,安全性方面的挑战。如果浏览器中的一个网络应用崩溃的话,这会波及括所有被打开的应用在内的任何其他应用。单线程的网络应用不得不经常相互竞争以获得的 cpu 时间,这有时会导致整个浏览器无法响应。安全性也同样不容小觑,因为仅仅一个页面就可以利用渲染引擎的某个漏洞获得对整台计算机的控制权。

然而,并不是非这样做不可。网络应用在设计的时候就是在浏览器中相互独立且并行的运行。它们不需要对磁盘和设备的访问权。这些被应用在网络上的安全策略保证了这些,使让你在访问大部分的页面时并不需要担心数据和计算机的安全性。这意味着可以让浏览器中的应用在不破坏彼此的情况下完全相互隔离。对于浏览器中的插件如 flash 也是一样的,它们与浏览器松散的耦合在一起且相互隔离,这没有任何问题。

Google Chrome 充分利用了这种特性,它将插件或是网络应用放在与浏览器本身不同的进程中。在一个渲染引擎中的崩溃并不会影响浏览器本身或是其他网络应用。这意味着操作系统可以并发的运行网络应用来提高响应速度,如果一个特定的网络应用程序或是插件停止响应时浏览器本身并不会被死锁。这也意味着我们可以在一个严格意义上的沙箱内运行渲染引擎进程,帮助减少发生错误时造成的损失。

总结一下,多进程浏览器有这些优点:

  • 避免单个 page crash 影响整个浏览器
  • 避免第三方插件 crash 影响整个浏览器
  • 多进程充分利用多核优势
  • 方便使用沙盒模型隔离插件等进程,提高浏览器稳定性

浏览器渲染原理及流程

在浏览器的几个进程中,我们最关心的肯定是渲染进程啦。那浏览器渲染进程到底干了啥呢?可以这么说,咱比较关心的: 页面的渲染、JS 的执行、事件的循环,都是在这个进程内进行。

先看看它包含那些常驻线程:

  1. GUI 渲染线程负责渲染浏览器界面,解析 HTML,CSS,构建 DOM 树和 RenderObject 树,布局和绘制等。当界面需要重绘 (Repaint) 或由于某种操作引发回流 (reflow) 时,该线程就会执行注意,GUI 渲染线程与 JS 引擎线程是互斥的,当 JS 引擎执行时 GUI 线程会被挂起 (相当于被冻结了),GUI 更新会被保存在一个队列中等到 JS 引擎空闲时立即被执行。

  2. JS 引擎线程也称为 JS 内核,负责处理 Javascript 脚本程序。(例如 V8 引擎) JS 引擎线程负责解析 Javascript 脚本,运行代码。 JS 引擎一直等待着任务队列中任务的到来,然后加以处理,一个 Tab 页 (renderer 进程) 中无论什么时候都只有一个 JS 线程在运行 JS 程序。同样注意,GUI 渲染线程与 JS 引擎线程是互斥的,所以如果 JS 执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。

  3. 事件触发线程归属于浏览器而不是 JS 引擎,用来控制事件循环 (可以理解,JS 引擎自己都忙不过来,需要浏览器另开线程协助) 当 JS 引擎执行代码块如 setTimeOut 时 (也可来自浏览器内核的其他线程,如鼠标点击、AJAX 异步请求等),会将对应任务添加到事件线程中。当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待 JS 引擎的处理。注意,由于 JS 的单线程关系,所以这些待处理队列中的事件都得排队等待 JS 引擎处理 (当 JS 引擎空闲时才会去执行)

  4. 定时触发器线程,传说中的 setInterval 与 setTimeout 所在线程浏览器定时计数器并不是由 JavaScript 引擎计数的,(因为 JavaScript 引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确) 因此通过单独线程来计时并触发定时 (计时完毕后,添加到事件队列中,等待 JS 引擎空闲后执行) 。注意,W3C 在 HTML 标准中规定,规定要求 setTimeout 中低于 4ms 的时间间隔算为 4ms。

  5. 异步 http 请求线程,XMLHttpRequest 在连接后通过浏览器新开一个线程请求,检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由 JavaScript 引擎执行。

Browser 进程和浏览器内核 (Renderer 进程) 的通信过程

这里再说下浏览器的 Browser 进程 (控制进程) 是如何和内核通信的:

  • Browser 进程收到用户请求,首先需要获取页面内容 (譬如通过网络下载资源),随后将该任务通过 RendererHost 接口传递给 Render 进程。

  • Renderer 进程的 Renderer 接口收到消息,简单解释后,交给渲染线程,然后开始渲染

    • 渲染线程接收请求,加载网页并渲染网页,这其中可能需要 Browser 进程获取资源和需要 GPU 进程来帮助渲染
    • 当然可能会有 JS 线程操作 DOM(这样可能会造成回流并重绘)
    • 最后 Render 进程将结果传递给 Browser 进程
  • Browser 进程接收到结果并将结果绘制出来

梳理浏览器内核中线程之间的关系

GUI 渲染线程与 JS 引擎线程互斥

由于 JavaScript 是可操纵 DOM 的,如果在修改这些元素属性同时渲染界面 (即 JS 线程和 UI 线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了。

因此为了防止渲染出现不可预期的结果,浏览器设置 GUI 渲染线程与 JS 引擎为互斥的关系,当 JS 引擎执行时 GUI 线程会被挂起, GUI 更新则会被保存在一个队列中等到 JS 引擎线程空闲时立即被执行。

JS 阻塞页面加载

从上述的互斥关系,可以推导出,JS 如果执行时间过长就会阻塞页面。

譬如,假设 JS 引擎正在进行巨量的计算,此时就算 GUI 有更新,也会被保存到队列中,等待 JS 引擎空闲后执行。然后,由于巨量计算,所以 JS 引擎很可能很久很久后才能空闲,自然会感觉到巨卡无比。

所以,要尽量避免 JS 执行时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞的感觉。

WebWorker

一个 worker 是使用一个构造函数创建的一个对象 (e.g. Worker()) 运行一个命名的 JavaScript 文件 - 这个文件包含将在工作线程中运行的代码; workers 运行在另一个全局上下文中,不同于当前的 window. 因此,使用 window 快捷方式获取当前全局的范围 (而不是 self) 在一个 Worker 内将返回错误。

让我们上段代码瞅瞅这玩意是怎么用的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// demo_workers.js
var i=0;

function timedCount() {
i=i+1;
postMessage(i);
setTimeout("timedCount()"500);
}

timedCount();
// 以上代码中重要的部分是 postMessage() 方法 - 它用于向 HTML 页面传回一段消息。

// index.js
if(typeof(w)=="undefined") {
w=new Worker("demo_workers.js");
}
w.onmessage=function(event) {
document.getElementById("result").innerHTML=event.data;
};

w.terminate(); // 终止监听消息

我们可以这样理解,创建 Worker 时,JS 引擎向浏览器申请开一个子线程 (子线程是浏览器开的,完全受主线程控制,而且不能操作 DOM)。JS 引擎线程与 worker 线程间通过特定的方式通信 (postMessage API,需要通过序列化对象来与线程交互特定的数据)。Worker 主要是浏览器给 JS 引擎开的外挂,专门用来解决那些大量计算问题。

既然都到了这里,就再提一下 SharedWorker。

  • WebWorker 只属于某个页面,不会和其他页面的 Render 进程 (浏览器内核进程) 共享

    • 所以 Chrome 在 Render 进程中 (每一个 Tab 页就是一个 render 进程) 创建一个新的线程来运行 Worker 中的 JavaScript 程序。
  • SharedWorker 是浏览器所有页面共享的,不能采用与 Worker 同样的方式实现,因为它不隶属于某个 Render 进程,可以为多个 Render 进程共享使用

    • 所以 Chrome 浏览器为 SharedWorker 单独创建一个进程来运行 JavaScript 程序,在浏览器中每个相同的 JavaScript 只存在一个 SharedWorker 进程,不管它被创建多少次。

看到这里,应该就很容易明白了,本质上就是进程和线程的区别。SharedWorker 由独立的进程管理,WebWorker 只是属于 render 进程下的一个线程。

浏览器渲染流程

从你输入 url 到页面生成,这已经是最常见的面试题之一了。我们现在来简单复习一下:

输入 url -> 浏览器主进程接管 -> dns 查询 (先查是否缓存) -> 发送 http 请求 -> 三次握手确立链接 -> 四次挥手断开链接 -> 将返回内容通过 RendererHost 接口转交给 Renderer 进程 -> 开始渲染 -> 解析 html 以构建 dom 树 -> 构建 render 树 -> 布局 render 树 -> 绘制 render 树

这里我们主要讲一下渲染过程,先解释一下几个概念,方便大家理解:

  • DOM Tree: 浏览器将 HTML 解析成树形的数据结构。

  • CSS Rule Tree: 浏览器将 CSS 解析成树形的数据结构。

  • Render Tree: DOM 和 CSSOM 合并后生成 Render Tree。

  • layout: 有了 Render Tree,浏览器已经能知道网页中有哪些节点、各个节点的 CSS 定义以及他们的从属关系,从而去计算出每个节点在屏幕中的位置。

  • painting: 按照算出来的规则,通过显卡,把内容画到屏幕上。

  • reflow(回流): 当浏览器发现某个部分发生了点变化影响了布局,需要倒回去重新渲染,内行称这个回退的过程叫 reflow。reflow 会从 <html> 这个 root frame 开始递归往下,依次计算所有的结点几何尺寸和位置。reflow 几乎是无法避免的。现在界面上流行的一些效果,比如树状目录的折叠、展开 (实质上是元素的显 示与隐藏) 等,都将引起浏览器的 reflow。鼠标滑过、点击…… 只要这些行为引起了页面上某些元素的占位面积、定位方式、边距等属性的变化,都会引起它内部、周围甚至整个页面的重新渲 染。通常我们都无法预估浏览器到底会 reflow 哪一部分的代码,它们都彼此相互影响着。

  • repaint(重绘): 改变某个元素的背景色、文字颜色、边框颜色等等不影响它周围或内部布局的属性时,屏幕的一部分要重画,但是元素的几何尺寸没有变。

这里有几点需要大家注意的:

  1. display:none 的节点不会被加入 Render Tree,而 visibility: hidden 则会,所以,如果某个节点最开始是不显示的,设为 display:none 是更优的。

  2. display:none 会触发 reflow,而 visibility:hidden 只会触发 repaint,因为没有发现位置变化。

  3. 有些情况下,比如修改了元素的样式,浏览器并不会立刻 reflow 或 repaint 一次,而是会把这样的操作积攒一批,然后做一次 reflow,这又叫异步 reflow 或增量异步 reflow。但是在有些情况下,比如 resize 窗口,改变了页面默认的字体等。对于这些操作,浏览器会马上进行 reflow。

下面是 webkit 引擎渲染时的主要流程:

1552642296817_3006.png

渲染的主要流程如下:

  1. 浏览器会将 HTML 解析成一个 DOM 树,DOM 树的构建过程是一个深度遍历过程: 当前节点的所有子节点都构建好后才会去构建当前节点的下一个兄弟节点。

  2. 将 CSS 解析成 CSS Rule Tree 。

  3. 根据 DOM 树和 CSS Rule Tree 来构造 Rendering Tree。注意: Rendering Tree 渲染树并不等同于 DOM 树,因为一些像 Header 或 display:none 的东西就没必要放在渲染树中了。

  4. 有了 Render Tree,浏览器已经能知道网页中有哪些节点、各个节点的 CSS 定义以及他们的从属关系。下一步操作称之为 layout,顾名思义就是计算出每个节点在屏幕中的位置。

  5. 再下一步就是绘制,即遍历 render 树,并使用 UI 后端层绘制每个节点。

  6. 浏览器会将各层的信息发送给 GPU,GPU 会将各层合成 (composite),显示在屏幕上。

注意: 上述这个过程是逐步完成的,为了更好的用户体验,渲染引擎将会尽可能早的将内容呈现到屏幕上,并不会等到所有的 html 都解析完成之后再去构建和布局 render 树。它是解析完一部分内容就显示一部分内容,同时,可能还在通过网络下载其余内容。

load 事件与 DOMContentLoaded 事件的先后

渲染完毕后会触发 load 事件,那么你能分清楚 load 事件与 DOMContentLoaded 事件的先后么?

很简单,知道它们的定义就可以了:

  • 当 DOMContentLoaded 事件触发时,仅当 DOM 加载完成,不包括样式表,图片。 (譬如如果有 async 加载的脚本就不一定完成)

  • 当 onload 事件触发时,页面上所有的 DOM,样式表,脚本,图片都已经加载完成了。 (渲染完毕了)

所以,顺序是: DOMContentLoaded -> load

css 加载是否会阻塞 dom 树渲染?

这里说的是头部引入 css 的情况

首先,我们都知道:css 是由单独的下载线程异步下载的。

然后再说下几个现象:

css 加载不会阻塞 DOM 树解析 (异步加载时 DOM 照常构建) 但会阻塞 render 树渲染 (渲染时需等 css 加载完毕,因为 render 树需要 css 信息) 这可能也是浏览器的一种优化机制。

因为你加载 css 的时候,可能会修改下面 DOM 节点的样式,如果 css 加载不阻塞 render 树渲染的话,那么当 css 加载完之后, render 树可能又得重新重绘或者回流了,这就造成了一些没有必要的损耗。所以干脆就先把 DOM 树的结构先解析完,把可以做的工作做完,然后等你 css 加载完之后,在根据最终的样式来渲染 render 树,这种做法性能方面确实会比较好一点。

普通图层和复合图层

可以简单的这样理解,浏览器渲染的图层一般包含两大类: 普通图层以及复合图层。

首先,普通文档流内可以理解为一个复合图层 (这里称为默认复合层,里面不管添加多少元素,其实都是在同一个复合图层中)。

其次,absolute 布局 (fixed 也一样),虽然可以脱离普通文档流,但它仍然属于默认复合层。

然后,可以通过硬件加速的方式,声明一个新的复合图层,它会单独分配资源 (当然也会脱离普通文档流,这样一来,不管这个复合图层中怎么变化,也不会影响默认复合层里的回流重绘)。

可以简单理解下:GPU 中,各个复合图层是单独绘制的,所以互不影响,这也是为什么某些场景硬件加速效果一级棒。

可以 Chrome 源码调试 -> More Tools -> Rendering -> Layer borders 中看到,黄色的就是复合图层信息。

1552642297086_5677.png

如何变成复合图层 (硬件加速)

将该元素变成一个复合图层,就是传说中的硬件加速技术

  • 最常用的方式: translate3d、translateZ

  • opacity 属性 / 过渡动画 (需要动画执行的过程中才会创建合成层,动画没有开始或结束后元素还会回到之前的状态)

  • <video><iframe><canvas><webgl>等元素

  • 其它,譬如以前的 flash 插件

复合图层的作用?

一般一个元素开启硬件加速后会变成复合图层,可以独立于普通文档流中,改动后可以避免整个页面重绘,提升性能。但是尽量不要大量使用复合图层,否则由于资源消耗过度,页面反而会变的更卡

硬件加速的坑

chrome 在具体什么时候会创建复合图层呢?经翻阅资料:

什么情况下能使元素获得自己的层?虽然 Chrome 的启发式方法 (heuristic) 随着时间在不断发展进步,但是从目前来说,满足以下任意情况便会创建层:

  • 3D 或透视变换 (perspective transform) CSS 属性
  • 使用加速视频解码的 元素
  • 拥有 3D (WebGL) 上下文或加速的 2D 上下文的 元素
  • 混合插件 (如 Flash)
  • 对自己的 opacity 做 CSS 动画或使用一个动画 webkit 变换的元素
  • 拥有加速 CSS 过滤器的元素
  • 元素有一个包含复合层的后代节点 (换句话说,就是一个元素拥有一个子元素,该子元素在自己的层里)
  • 元素有一个 z-index 较低且包含一个复合层的兄弟元素 (换句话说就是该元素在复合层上面渲染)

最后一条这样理解: 如果一个元素添加了硬件加速,并且 index 层级比较低,那么在这个元素的后面其它元素 (层级比这个元素高的,或者相同的,并且 releative 或 absolute 属性相同的),会默认变为复合层渲染。

所以我们在使用 3D 硬件加速提升动画性能时,最好给元素增加一个 z-index 属性,提高图层排序,减少 chrome 创建不必要的复合层,提升渲染性能。

js Event Loop

最后终于到了重头戏,谈谈 js 在浏览器中的事件循环机制。首先上个图:

1552642298730_2864.png

我们经常会听到引擎和 runtime,它们的区别是什么呢?

  • 引擎: 解释并编译代码,让它变成能交给机器运行的代码 (runnable commands)。
  • runtime: 就是运行环境,它提供一些对外接口供 Js 调用,以跟外界打交道,比如,浏览器环境、Node.js 环境。不同的 runtime,会提供不同的接口,比如,在 Node.js 环境中,我们可以通过 require 来引入模块;而在浏览器中,我们有 window、 DOM。

Js 引擎是单线程的,如上图中,它负责维护任务队列,并通过 Event Loop 的机制,按顺序把任务放入栈中执行。而图中的异步处理模块,就是 runtime 提供的,拥有和 Js 引擎互不干扰的线程。接下来,我们会细说图中的: 栈和任务队列。

现在,我们要运行下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function bar() {
console.log(1);
}

function foo() {
console.log(2);
bar();
}

setTimeout(() => {
console.log(3)
});

foo();

1552642299305_2732.jpg.png

任务队列

出现原因

首先来了解下任务队列出现的原因:

JavaScript 的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定 JavaScript 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

所以,为了避免复杂性,从一诞生,JavaScript 就是单线程,这已经成了这门语言的核心特征,将来也不会改变。

单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。

如果排队是因为计算量大,CPU 忙不过来,倒也算了,但是很多时候 CPU 是闲着的,因为 IO 设备 (输入输出设备) 很慢(比如 Ajax 操作从网络读取数据),不得不等着结果出来,再往下执行。

JavaScript 语言的设计者意识到,这时主线程完全可以不管 IO 设备,挂起处于等待中的任务,先运行排在后面的任务。等到 IO 设备返回了结果,再回过头,把挂起的任务继续执行下去。

于是,所有任务可以分成两种,一种是同步任务 (synchronous),另一种是异步任务(asynchronous)。同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入主线程、而进入” 任务队列”(task queue)的任务,只有”任务队列”通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

执行步骤

Js 中,有两类任务队列: 宏任务队列 (macro tasks) 和微任务队列(micro tasks)。宏任务队列可以有多个,微任务队列只有一个。那么什么任务,会分到哪个队列呢?

  • 宏任务: script(全局任务), setTimeout, setInterval, setImmediate, I/O, UI rendering.

  • 微任务: process.nextTick, Promise, Object.observer, MutationObserver.

我们上面讲到,当 stack 空的时候,就会从任务队列中,取任务来执行。共分 3 步:

  1. 取一个宏任务来执行。执行完毕后,下一步。
  2. 取一个微任务来执行,执行完毕后,再取一个微任务来执行。直到微任务队列为空,执行下一步。
  3. 更新 UI 渲染。

Event Loop 会无限循环执行上面 3 步,这就是 Event Loop 的主要控制逻辑。其中,第 3 步 (更新 UI 渲染) 会根据浏览器的逻辑,决定要不要马上执行更新。毕竟更新 UI 成本大,所以,一般都会比较长的时间间隔,执行一次更新。

从执行步骤来看,我们发现微任务,受到了特殊待遇!我们代码开始执行都是从 script(全局任务)开始,所以,一旦我们的全局任务 (属于宏任务) 执行完,就马上执行完整个微任务队列。看个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
console.log('script start');

Promise.resolve().then(() => {
console.log('p 1');
});

setTimeout(() => {
console.log('setTimeout');
}, 0);

var s = new Date();
while(new Date() - s < 50); // 阻塞50ms

Promise.resolve().then(() => {
console.log('p 2');
});

console.log('script ent');


/*** output ***/

// one macro task
script start
script ent

// all micro tasks
p 1
p 2

// one macro task again
setTimeout

上面之所以加 50ms 的阻塞,是因为 setTimeout 的 delayTime 最少是 4ms. 为了避免认为 setTimeout 是因为 4ms 的延迟而后面才被执行的,我们加了 50ms 阻塞。在微任务中,process.nextTick 是一个特殊的任务,它会被直接插入到微任务的队首 (当然了,多个 process.nextTick 之间也是先入先出的),优先级最高。

setTimeout、setInterval

这里要注意的是,setTimeout()、setInterval()只是将事件插入了”任务队列”,必须等到当前代码 (执行栈) 执行完,主线程才会去执行它指定的回调函数。要是当前代码耗时很长,有可能要等很久,所以并没有办法保证,回调函数一定会在 setTimeout()指定的时间执行。

MutationObserver

MutationObserver 可以用来实现 microtask(它属于 microtask,优先级小于 Promise,一般是 Promise 不支持时才会这样做)。

它是 HTML5 中的新特性,作用是: 监听一个 DOM 变动,当 DOM 对象树发生任何变动时,Mutation Observer 会得到通知。

像以前的 Vue 源码中就是利用它来模拟 nextTick 的,具体原理是,创建一个 TextNode 并监听内容变化,然后要 nextTick 的时候去改一下这个节点的文本内容,如下:(Vue 的源码,未修改)

1
2
3
4
5
6
7
8
9
10
11
var counter = 1
var observer = new MutationObserver(nextTickHandler)
var textNode = document.createTextNode(String(counter))

observer.observe(textNode, {
characterData: true
})
timerFunc = () => {
counter = (counter + 1) % 2
textNode.data = String(counter)
}

不过,现在的 Vue(2.5+) 的 nextTick 实现移除了 MutationObserver 的方式 (据说是兼容性原因),取而代之的是使用 MessageChannel(当然,默认情况仍然是 Promise,不支持才兼容的)。

MessageChannel 属于宏任务,优先级是: MessageChannel->setTimeout,所以 Vue(2.5+) 内部的 nextTick 与 2.4 及之前的实现是不一样的,需要注意下。

Node.js 的 Event Loop

上面我们讨论的都是浏览器中的 Event Loop,随着 nodejs 的出现,js 代码首次能运行在其他的环境中。Node.js 也是单线程的 Event Loop,但是它的运行机制不同于浏览器环境。

nodejs 中 Event loop 有以下特点:

  1. 初始化: Node.js 启动后,会进行一些初始化

    • 初始化 Event loop
    • 处理目标脚本
    • 然后进入事件循环
  2. 每个阶段,都有其 FIFO 队列,用来执行回调函数。

    1. 每个阶段都是特殊的。
    2. 当进行到该阶段时,会执行该阶段特有的操作,然后执行该阶段队列中的回调。
    3. 当队列空,或者达到执行次数限制,事件循环进行下阶段。循环往复。

阶段总览

1552642299535_7253.png

根据上图,Node.js 的运行机制如下。

(1)V8 引擎解析 JavaScript 脚本。

(2) 解析后的代码,调用 Node API。

(3)libuv 库负责 Node API 的执行。它将不同的任务分配给不同的线程,形成一个 Event Loop(事件循环),以异步的方式将任务的执行结果返回给 V8 引擎。

(4)V8 引擎再将结果返回给用户。

其中,event loop 包括:

  1. timers: 执行 setTimeout() 和 setInterval() 安排的回调
  2. I/O callbacks: 执行除了 close 事件的 callbacks、被 timers(定时器,setTimeout、setInterval 等) 设定的 callbacks、setImmediate() 设定的 callbacks 之外的 callbacks;
  3. idle,prepare: 只用于内部
  4. poll : 获取新的 I/O 事件,node 在该阶段会适当的阻塞
  5. check : setImmediate 的回调被调用
  6. close callbacks: e.g socket.on(‘close’,…);
  7. 在每次运行事件循环之间,node.j 检查是否有正在等待的异步 i/o 调用、timers 等。如果没有,就清除并结束 (退出程序),例如: 执行一个程序,仅有一句话 (var a= ‘hello’😉,处理完目标代码后,不会进入 evetloop,而是直接结束程序。

Node.js 中的宏任务和微任务

宏任务: setTimeout 和 setImmediate

  • setTimeout 设计在 poll 阶段为空闲时,且设定时间到达后执行;但其在 timer 阶段执行
  • setImmediate 设计在 check 阶段执行;

谁先输出,谁后输出?

1
2
3
4
5
6
7
setTimeout(function timeout () {
console.log('timeout');
},0);

setImmediate(function immediate () {
console.log('immediate');
});

答案是不确定的。有两个前提我们是需要清楚的;

  • event loop 初始化是需要一定时间的
  • setTimeout 有最小毫秒数的,通常是 4ms。

当: event loop 准备时间 > setTimeout 最小毫秒数。从 timers 阶段检查,此时队列中已经有 setTimeout 的任务,所以 timeout 先输出;

当: event loop 准备时间 < setTimeout 最小毫秒数。从 timers 阶段检查,此时队列是空的就下检查接下来的阶段,到 check 阶段,已经有 setImmediate 的任务,所以 immediate 先输出;

微任务: process.nextTick() 和 Promise.then()

微任务不在 event loop 的任何阶段执行,而是在各个阶段切换的中间执行,即从一个阶段切换到下个阶段前执行;nextTick 比 Promise.then() 先执行。

下面代码是如何执行的。

1
2
3
4
5
6
7
8
9
10
11
12
13
setImmediate(() => {
console.log('setImmediate1')
setTimeout(() => {
console.log('setTimeout1')
}, 0);
})
setTimeout(()=>{
process.nextTick(()=>console.log('nextTick'))
console.log('setTimeout2')
setImmediate(()=>{
console.log('setImmediate2')
})
},0);
  • 从前面的知识知道,此时 setTimeout 和 setImmediate 执行顺序是不确定的。
  • 假设 setImmediate 先执行,输出setImmediate1,setTimeout 的任务添加到 timer 阶段
  • 检查 timer 阶段,这时已经有两个任务。先执行之前的第一个任务,nextTick 添加到微任务队列,输出setTimeout2,setImmediate 的任务添加到 check 阶段。
  • timer 中还有一个任务,执行输出setTimeout1
  • 切换阶段,微任务执行,输出nextTick
  • 检查 check 阶段,输出setImmediate2

再来看看这种情况:

1
2
3
4
5
6
7
8
9
10
let fs = require('fs')

fs.readFile('./1.txt''utf8'function (err, data) {
setTimeout(() => {
console.log('setTimeout')
}, 0);
setImmediate(() => {
console.log('setImmediate')
})
})

readFile 的回调函数是在 poll 阶段执行 答案是 setImmediate 比 setTimeout 先执行。

process.nextTick 和 setImmediate

process.nextTick 方法可以在当前”执行栈”的尾部 (即,微任务队列头部插入)—- 下一次 Event Loop(主线程读取” 任务队列”)之前—- 触发回调函数。也就是说,它指定的任务总是发生在所有异步任务之前。setImmediate 方法则是在 check 阶段的尾部添加事件。请看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
process.nextTick(function A() {
console.log(1);
process.nextTick(function B(){console.log(2);});
});

setTimeout(function timeout() {
console.log('TIMEOUT FIRED');
}, 0)
// 1
// 2
// TIMEOUT FIRED

上面代码中,由于 process.nextTick 方法指定的回调函数,总是在当前”执行栈”的尾部触发,所以不仅函数 A 比 setTimeout 指定的回调函数 timeout 先执行,而且函数 B 也比 timeout 先执行。这说明,如果有多个 process.nextTick 语句 (不管它们是否嵌套),将全部在当前” 执行栈”执行。

由于 process.nextTick 指定的回调函数是在本次”事件循环”触发,而 setImmediate 指定的是在下次”事件循环”触发,所以很显然,前者总是比后者发生得早,而且执行效率也高 (因为不用检查” 任务队列”)