概述

浏览器渲染流程

简单来说,浏览器渲染可以分为 DOM 树、CSSOM 树、渲染树、布局、绘制、合成和显示等过程。

HTML 解析,生成 DOM Tree,解析 CSS 文件生成 CSSOM Tree(这两步多线程并行,这两步之间涉及加载资源或执行 JavaScript 导致的线程阻塞后面讲解)。

将 Dom Tree 和 CSSOM Tree 结合,生成 Render Tree(渲染树),根据 Render Tree 渲染绘制,将像素渲染到屏幕上。

构建 DOM(Document Object Model)

浏览器会将 HTML 代码解析成 DOM(文档对象模型)树。DOM 树是表示文档结构的一种树形结构,它由多个节点组成,每个节点代表一个 HTML 标记。浏览器会根据 HTML 代码的层次关系,将每个 HTML 标记转换为 DOM 节点,并建立它们之间的父子关系。

HTML 解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!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>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 作者:NUMT片段 https://www.bilibili.com/read/cv20748654/ 出处:bilibili
</p>
</body>
</html>

以一个简单的 html 片段为例。

html 的解析是从上往下的,一般来说,html 的根节点 html 嵌套了 head 和 body,head 在前,body 在后。当不遵循这个顺序时解析器也能工作,也是按照从上往下解析。当解析器发现非阻塞资源,例如一张图片,浏览器会请求这些资源并且继续解析。当遇到一个 CSS 文件时,解析也可以继续进行,但是对于 script 标签(特别是没有 async 或者 defer 属性的)会阻塞渲染并停止 HTML 的解析。尽管浏览器的预加载扫描器加速了这个过程,但过多的脚本仍然是一个重要的瓶颈。

预加载扫描器

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

DOCTYPE

一个标准的 html 第一行是文档类型声明,DTD(document type definition) 是一系列的语法规则,用来定义 XML 或(x)HTML 的文件类型。浏览器会使用它来判断文档类型,决定使用何种协议来解析,以及切换浏览器模式。不懂就问

DOCTYPE
DOCTYPE 是 Document Type 的缩写,即文档类型。在 HTML 文档中,DOCTYPE 用来指定文档的类型,告诉浏览器应该使用哪种 HTML 规范来解析该文档。

在 HTML4 中,DOCTYPE 声明的形式如下:

1
2
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
"http://www.w3.org/TR/html4/strict.dtd">

在 HTML5 中,DOCTYPE 声明的形式比较简单,只需使用以下语句:

1
<!DOCTYPE html>

DOCTYPE 声明应该放在 HTML 文档的第一行,位于 <html> 标签之前。如果省略 DOCTYPE 声明或者声明错误,浏览器将以怪异模式(quirks mode)来解析该文档,这可能会导致一些问题。
在怪异模式下,浏览器的解析方式与不同的浏览器不同,会导致页面的呈现出现一些不可预测的问题,例如元素间的间距不一致、文本换行等。因此,为了确保页面的正确呈现,应该始终在 HTML 文档中声明正确的 DOCTYPE。



head 中包含一些与文档相关的元数据和资源,这些元数据和资源并不直接显示在页面上,而是提供了一些有用的信息给浏览器和搜索引擎。

head 标签通常包含以下内容:

  1. 页面的标题:可以通过 title 标签来定义页面的标题,该标题通常会显示在浏览器的标签栏中。
  2. 网站图标:可以通过 link 标签来引用网站的图标,通常是一个小图标,也称为“网站图标”或“favicon”。
  3. 文档的字符编码:可以通过 meta 标签来定义文档的字符编码(<meta charset="UTF-8">),以确保浏览器正确地解析和显示文档中的特殊字符。
  4. 关键词和描述:可以通过 meta 标签来定义文档的关键词和描述,以便搜索引擎能够更好地索引和分类文档。
  5. 外部资源链接:可以通过 link 和 script 标签来引用外部的 CSS 和 JavaScript 文件,以改善页面的样式和功能,对于 <script> 标签(特别是没有 async 或者 defer 属性的)会阻塞渲染并停止 HTML 的解析。
  6. 其他元数据:还可以通过 meta 标签定义其他元数据,例如作者、日期、版本号等。其中 http-equiv 用于模拟 HTTP 响应头的字段,以便在浏览器中设置某些特定的行为或元数据。如 <meta http-equiv="refresh" content="3;Url=网址参数"> refresh 代表多少时间网页自动刷新,加上 Url 中的网址参数就代表,多长时间自动链接其他网址。

在 HTML 中,head 标签是必须的,因为它包含了文档的元数据和资源,这些元数据和资源对于浏览器和搜索引擎来说非常重要。

body

body 中一般用于存放页面的主体,一般涉及交互的 script 标签会放到 body 后面,这是由于 dom 解析是从上到下的,所以解析到 body 后面时,页面的 dom 结构已经出来了,再去操作 dom 不会产生问题。反之,如果放在 head 里则需要通过事件(onLoad、DOMContentLoaded)去操作 dom,会增加事件触发线程的开销。

构建 CSSOM(CSS Object Model)

CSSOM

构建流程

  • 解析 CSS 文件:浏览器首先会下载 CSS 文件,并进行解析。解析过程中,浏览器会忽略掉一些无效的 CSS 语句,如注释、空白等,并将有效的 CSS 语句解析成 CSS 规则。
  • 构建样式表:浏览器将解析后的 CSS 规则构建成一个样式表对象,并将其保存在内存中。
  • 构建 CSSOM Tree:浏览器遍历 DOM Tree,并匹配每个 DOM 节点所对应的 CSS 规则,将匹配到的 CSS 规则构建成 CSSOM Tree,并将其与 DOM Tree 结合起来,形成一个 Render Tree。

匹配细节

  • 合并样式:当一个 DOM 节点匹配到多个 CSS 规则时,浏览器会按照一定的优先级规则将这些 CSS 规则合并成一个最终的样式,例如:内联样式 > ID 选择器 > 类选择器 > 标签选择器 > 通配符等。
  • 计算继承样式:当一个 DOM 节点没有匹配到任何 CSS 规则时,浏览器会从它的父节点继承样式,并计算出最终的继承样式。
  • 计算盒模型:浏览器根据样式规则计算每个 DOM 节点的盒模型属性,如元素的宽度、高度、边距和内边距等。

构建渲染树(Render Tree)

Render Tree

当 DOM 和 CSSOM 都解析完成后,浏览器主线程会将两者结合,生成渲染树。如上图,display: none 的节点被过滤掉了。渲染树就是这样只囊括了影响真正渲染结果的节点,保证其高效的渲染效率。

线程协同

了解了渲染的流程后,可以发现浏览器并不是将所有任务全部交给一个线程执行的,以 webkit 内核为例,浏览器会为每个 tab 开一个进程,每个进程分很多线程,下面就讲解下线程之间的配合:

浏览器线程简介

1. 主线程

在浏览器中,主线程是所有页面操作的核心线程。主线程主要负责处理页面的渲染、用户交互、JavaScript 执行和网络请求等任务。

以下是主线程的主要工作:

  1. 处理页面的渲染:主线程通过解析 HTML 和 CSS 文件,构建 DOM Tree 和 CSSOM Tree,然后将它们合并成 Render Tree,最终将 Render Tree 转化为像素信息,交给 GUI 渲染线程绘制在屏幕上。
  2. 处理用户交互事件:主线程监听用户的输入事件,如鼠标点击、键盘输入等,将事件封装成事件对象,并将事件对象添加到事件队列中等待处理。当主线程空闲时,会从事件队列中取出事件对象,并触发相应的事件处理函数执行。
  3. 处理 JavaScript 代码的执行:主线程通过 JavaScript 引擎解析 JavaScript 代码,将其转换成可执行的指令序列,并执行这些指令。在执行过程中,主线程会不断地从任务队列中取出任务,执行相应的 JavaScript 代码。如果 JavaScript 代码执行时间过长,会导致主线程被阻塞,从而导致页面卡顿和不流畅。
  4. 处理网络请求:主线程通过 XMLHttpRequest 或 Fetch API 等技术向服务器发送网络请求,并等待服务器返回响应结果。当服务器返回响应结果后,主线程会将响应结果封装成响应对象,并将响应添加到事件队列中等待处理。

需要注意的是,由于浏览器是单线程的,因此在主线程中执行的任务会互相竞争 CPU 资源。如果某个任务执行时间过长,会导致其他任务被阻塞,从而导致页面卡顿和不流畅。因此,在编写页面时,需要尽可能地减少主线程的负担,避免长时间的 JavaScript 代码执行和网络请求等操作,以提高页面的性能和响应速度。可以使用异步编程、Web Worker 等技术将一些耗时的操作移动到其他线程中,以减轻主线程的负担。

2. GUI 渲染线程

在浏览器中,GUI 渲染线程通常是由主线程执行的。GUI 渲染线程负责将 Render Tree 渲染成屏幕上的像素信息,并将其显示出来。渲染过程包括以下几个步骤:

  1. 布局:根据 Render Tree 中每个节点的尺寸、位置等信息,计算出每个节点在屏幕上的准确位置。
  2. 绘制:将每个节点的背景色、边框、文本、图片等内容绘制到屏幕上。
  3. 合成:将绘制好的层按照正确的顺序进行合成,形成最终的像素信息,并将其显示到屏幕上。

在渲染过程中,GUI 渲染线程需要不断地与主线程进行通信,以获取最新的 Render Tree 和 CSSOM Tree 等信息。由于渲染线程需要频繁地进行绘制操作,因此渲染线程的执行优先级较高,如果主线程中有耗时的操作,会对渲染性能造成影响,从而导致页面的卡顿和不流畅。

为了提高页面的渲染性能,可以采取一些优化策略,如减少 DOM 节点数量、使用 CSS3 动画代替 JavaScript 动画等。同时,也可以通过 Web Worker 等技术将一些计算密集型的操作移动到其他线程中,减轻主线程的负担,从而提高页面的响应速度和性能。

3. JS 引擎线程

JS 引擎线程是浏览器中用于执行 JavaScript 代码的线程。JS 引擎是浏览器中的核心组件之一,它负责解析、编译和执行 JavaScript 代码。

JS 引擎线程和主线程不同,它是独立的线程,拥有自己的执行栈和堆内存,可以并行执行 JavaScript 代码,不会阻塞主线程的执行。当主线程遇到需要执行 JavaScript 代码的任务时,会将任务添加到任务队列中,然后等待 JS 引擎线程的执行。

当 JS 引擎线程取出任务时,会将任务添加到自己的执行栈中,并执行相应的 JavaScript 代码。在执行过程中,JS 引擎线程会不断地从任务队列中取出任务,执行相应的 JavaScript 代码,直到执行栈中没有任务为止。

由于 JavaScript 是单线程的,因此在 JS 引擎线程中执行的任务也是互相竞争 CPU 资源的。如果某个任务执行时间过长,会导致其他任务被阻塞,从而影响页面的性能和响应速度。

4. 事件触发线程

事件触发线程是由主线程之外的单独线程执行的。事件触发线程负责监听用户的输入事件,如鼠标点击、键盘输入等,并将这些事件封装成事件对象,然后将事件对象添加到事件队列中。当主线程空闲时,会从事件队列中取出事件对象,并触发相应的事件处理函数执行。

事件触发线程与主线程之间通过任务队列来进行通信。事件触发线程将事件对象添加到任务队列中,而主线程则不断地从任务队列中取出事件对象,并执行相应的事件处理函数。如果主线程正在执行其他任务,事件处理函数将会被暂时挂起,直到主线程完成当前任务后再执行。

由于事件触发线程与主线程是分离的,因此在事件处理函数执行期间,页面的其他部分仍然可以响应用户的操作,不会出现页面卡顿或不流畅的现象。

在 JavaScript 中,事件处理函数的执行是同步的,也就是说,当事件处理函数执行时,JavaScript 代码会阻塞,直到事件处理函数执行完成后才继续执行后续的代码。当事件触发时,事件处理线程会将事件加入到事件队列中,然后通知 JavaScript 引擎线程。JavaScript 引擎线程会不断地从事件队列中取出事件并执行相应的回调函数。因此,事件触发线程的回调最终是由 JavaScript 引擎线程执行的,但是在事件处理线程和 JavaScript 引擎线程之间有一个事件队列来协调它们的工作。

5. 定时器触发线程

在浏览器中,定时器是通过 JavaScript 引擎线程来触发的。具体来说,当我们调用 setTimeout 或 setInterval 等定时器函数时,JavaScript 引擎会将相应的定时器任务加入到定时器队列中,并设定相应的定时器时间。然后 JavaScript 引擎线程就会继续执行后面的任务,直到定时器时间到达后,定时器触发线程就会将相应的定时器任务加入到事件队列中,以便 JavaScript 引擎线程在合适的时机执行相应的回调函数。

当定时器任务开始执行时,JavaScript 引擎会将其加入到执行栈中,并将其标记为正在执行。此时,其他的定时器任务和 JavaScript 代码都会被阻塞,直到当前任务执行完毕并从执行栈中弹出。因此,如果定时器任务的执行时间很长,那么其他的定时器任务就会一直被阻塞,直到当前任务执行完成。

需要注意的是,定时器并不是精确的,实际的触发时间可能会因为浏览器性能和系统资源等因素而有所偏差。此外,在浏览器中打开多个标签页或者在后台运行其他应用程序时,定时器也可能会受到影响,因为浏览器或者操作系统可能会将其置于低优先级任务中,以便更好地分配资源。

因此,在使用定时器时,应当尽量避免使用非常精确的时间,而是采用一些容错机制,例如设置一个较小的时间间隔并在回调函数中检查是否达到了预期的条件。

6. HTTP 请求线程

在浏览器中,HTTP 请求通常是通过浏览器内核的网络请求模块来处理的,也就是说,HTTP 请求并不是在 JavaScript 引擎线程中处理的。具体来说,当我们使用 JavaScript 发起一个 HTTP 请求时,JavaScript 引擎会将相应的请求任务交给浏览器内核的网络请求模块处理。网络请求模块会负责建立网络连接、发送 HTTP 请求、接收服务器响应、处理响应数据等一系列工作,并将最终的结果返回给 JavaScript 引擎线程。

HTTP 请求是一种异步操作,也就是说,JavaScript 引擎不会阻塞等待请求结果的返回,而是继续执行后面的任务。当请求结果返回后,网络请求模块会将相应的事件加入到事件队列中,以便 JavaScript 引擎线程在合适的时机执行相应的回调函数。因此,我们可以在 JavaScript 代码中使用回调函数来处理 HTTP 请求结果,以便在结果返回后进行相应的处理操作。

7. 事件队列

浏览器中的事件队列是一种基于事件循环(Event Loop)机制的异步执行机制。当事件发生时,例如鼠标点击、键盘输入等,浏览器会将相应的事件加入到事件队列中,等待 JavaScript 引擎线程空闲时执行相应的事件处理程序。

具体来说,事件队列是由多个任务队列组成的,每个任务队列用于存储一类事件的处理函数。例如,鼠标事件、键盘事件、定时器事件等,每个事件都有对应的任务队列。当事件发生时,相应的任务会被加入到相应的任务队列中。JavaScript 引擎线程在执行任务时,会先从任务队列中取出一个任务执行,执行完成后再从队列中取出下一个任务,直到队列为空为止。

需要一提的是,在浏览器的事件队列中,每个任务队列都有一个优先级。一般来说,浏览器会将用户交互相关的事件(例如鼠标点击、键盘输入等)的任务队列的优先级设置为最高,以确保用户能够尽快地得到响应。其他类型的事件(例如定时器事件、网络请求事件等)的任务队列优先级相对较低,会在用户交互事件的任务队列处理完后才开始执行。

综述

总的来说,主线程与各个线程的事件队列通信,协调所有的工作。JS 引擎线程是所有的 JS 执行的地方,所有线程遇到 JS 执行的需求时都要与 JS 的任务队列交互,这也是我们常说的 JS 是单线程的,因为基本所有任务都要在 JS 引擎线程的任务队列里排队。