浏览器的功能组件是非常复杂的,在了解浏览器渲染原理前,需要先了解一些前置的概念知识。
CPU 和 GPU
CPU
CPU(Central Processing Unit - 中央处理单元),可以被看作是计算机的大脑,每个核就像一个员工,能够依次处理各种任务,包括数学和艺术问题,还能接听客户电话。很早之前,CPU 只有一个内核,而现代 CPU 则拥有多个内核,可以提供更强的计算能力。
GPU
GPU(Graphic Processing Unit - 图形处理单元)是计算机的另一个重要部分,GPU是专门处理图形相关任务的硬件,擅长大规模并行计算。它就像一群员工可以同时处理大量相同类型的任务,常用于图形渲染、视频解码和机器学习等需要高计算能力的场景。与CPU不同,GPU擅长处理重复性高、数据密集的计算工作,因此在图像处理和加速某些复杂计算时性能更佳。
并行与并发
并行(Parallelism) 和 并发(Concurrency) 是计算机科学中的两个重要概念,虽然看起来相似,但实际意义和应用场景上是不同的。
并行
并行是指同时执行多个任务,通常是通过多核处理器或多个处理器来完成的。在并行处理时,多个任务在同一时间点上真正地被多个 CPU 核心执行。
并行的特点:
- 同时处理多个任务
- 需要硬件支持
并行的场景:
- 需要计算处理大量数据,并行可以将任务分割成多个部分,分配到多个处理器上同时进行
- 图形处理:GPU 的并行计算,可以同时处理大量像素或图形数据
- 并行编译:Weback 通过多进程或多线程实现并行编译,优化打包速度
并发
并发是指多个任务在同一时间段内交替执行,但它们未必真正地同时进行。并发系统中的任务可能是由单个处理器快速切换完成的,模拟出多任务同时进行的效果。
并发的特点:
- 任务之间切换,看起来像是同时执行,但实际上每个任务轮流执行
- 并发任务之间可能需要协调和同步(避免竞争资源的问题)
并发的场景:
- 服务器处理多个请求:服务器需要同时处理成千上万的用户请求。通过并发技术,服务器可以快速响应每个请求,而无需等待上一个请求完成,使得系统资源得到更有效的利用,提高任务效率,从而降低总体时间
- 前端异步操作:JavaScript 的事件循环机制
举例理解
举例:比如一位程序员正在写代码,办公桌上有一杯奶茶。
- 一边写代码、一边用吸管喝奶茶,这两个任务是同时执行的,这种情况就叫并行。
- 写一段代码,然后喝一口奶茶,之后再继续写代码,通过来回切换完成了这两个任务,这种情况就叫并发。
- 先写完代码,再喝奶茶,这种情况既不是并行也不是并发。
进程和线程
进程
一个进程就是一个程序的运行实例。启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,我们把这样的一个运行环境叫进程。
进程必须有线程才能执行,任何代码的执行都必须由线程来进行。
一个进程可以包含多个线程,多个线程之间可以共享数据。
线程
线程是进程中的一个执行任务(控制单元),负责当前进程中程序的执行。多个线程可以共享进程的内存和资源,并且可以同时执行。
浏览器的进程模型
现代浏览器,如 Chrome、Firefox、Edge 等,都采用多进程架构,每个功能模块运行在不同的进程中,从而隔离它们的运行。这种设计方式确保了即使某个进程崩溃,其他进程仍然能够继续运行,避免整个浏览器被影响而关闭。
在Chrome中打开一个标签,点击 Chrome 浏览器右上角的三个点菜单,在下拉菜单中选择“更多工具”,然后点击“任务管理器”,就会打开 Chrome 的任务管理器窗口。任务管理器可以用来查看和管理浏览器中各个进程和服务的资源使用情况。
上图中,可以看到 Chrome 任务管理器中显示了多个进程及它们的相关信息,如浏览器主进程、GPU 进程、网络服务(Network Service)、存储服务(Storage Service)、以及 Service Workers 等等。下面将详细介绍这些进程和服务。
面向服务的进程模型
在 Chrome 的最新架构设计中,使用了面向服务的架构(Services Oriented Architecture,简称SOA)的思想。将浏览器的不同功能模块划分为独立的服务(Service),通过 IPC(Inter-Process Communication,进程间通信)来通信。每个服务都可以在独立的进程中运行,并且可以轻松拆分为不同的进程或聚合为一个进程。
常见的 IPC 通信工作机制:消息传递、共享内存。
当 Chrome 在功能强大的硬件上运行时,它可能会将每个服务拆分为不同的进程以提供更高的稳定性,但是如果是在资源受限的设备上,Chrome 会将服务整合到一个进程中以节省内存。
Chrome的主要进程
浏览器主进程(Browser Process)
浏览器主进程负责管理和调度各个辅助进程,确保它们正常运行并高效通信。例如渲染进程、GPU 进程和网络进程。当你在地址栏输入网址并按下回车键时,主进程会协调网络进程来发起网络请求,获取网页内容。
渲染进程(Renderer Process)
渲染进程专门负责将 HTML、CSS 和 JavaScript 等网页内容进行解析并绘制到屏幕上。它还负责处理用户交互,如点击、滚动等操作。
通常,每个标签页都有一个独立的渲染进程,它在沙盒环境中运行,确保一个页面的崩溃不会影响其他页面。例如,当某个网页因脚本错误崩溃时,其他页面依然可以正常运行。
标签页和渲染进程的关系:
- 多个标签页共享渲染进程:某些特定情况下,多个标签页可能会共享一个渲染进程,用于节省系统资源;
- 单个标签页可能使用多个渲染进程:某些复杂的页面可能会涉及多个 iframe,尤其是跨域的 iframe。这种情况下,浏览器可能会为主页面及其跨域的 iframe 分配多个渲染进程,每个 iframe 有自己的渲染进程。
插件进程(Plugin Process)
插件进程主要用于浏览器加载和执行某些特定的插件(如浏览器内置的 PDF 查看器、 Flash 插件等)。
每个插件在独立进程中运行,避免插件问题影响浏览器的稳定性。插件进程与主进程和渲染进程独立运行,即使插件出现故障,也不会导致整个浏览器崩溃。
网络进程(Network Process)
网络进程负责处理所有网络请求,包括 HTTP 请求、WebSocket、DNS 解析等。网络进程通常由浏览器主进程在浏览器启动时创建。所有的网络请求都通过网络进程来管理,这样可以减少重复的网络连接和资源加载(缓存网络请求结果),并提高网络请求的安全性。
GPU 进程(GPU Process)
负责处理网页中的 GPU 加速任务,如 3D 渲染、图形加速、视频解码、光栅化(Raster)等。它可以加速图像处理、CSS3 动画和变换等需要 GPU 计算的操作。
浏览器会根据系统的硬件条件自动选择使用 GPU 还是 CPU 来处理图形和渲染任务。在有 GPU 支持的情况下,浏览器会利用 GPU 进程来加速这些操作,以提供更高的性能和更流畅的用户体验。如果没有 GPU,浏览器会回退到 CPU 渲染,虽然可能性能不够好,但依然能够完成这些任务。
扩展进程(Extension Process)
为浏览器的扩展程序提供单独的进程,以确保扩展的运行不会影响浏览器的核心功能或安全性。
其它进程
此外,针对不同的需要和场景,浏览器还有很多其它的进程,比如音频进程(Audio Process)、视频进程(Video Process)等等。
多进程模型的优缺点
多进程模型提升了浏览器的稳定性、安全性和并发性能。但也带来了缺点:每个进程都需要独立的内存空间来存储其运行状态、资源等,运行多个标签页时,内存占用会显著增加,可能会导致系统卡顿。
浏览器的沙箱机制
浏览器的沙箱机制(SandBox)是一种安全技术,用于隔离网页和浏览器内的进程,防止恶意代码或不可信内容对操作系统或用户数据造成破坏。
浏览器的渲染引擎
浏览器的渲染引擎是负责解析 HTML, CSS, JavaScript,渲染页面。
主流浏览器的渲染引擎
| 浏览器 | 引擎 |
|---|---|
| Chrome | 早期:Webkit 现在:Chromium/Blink |
| Microsoft Edge | 基于 Chromium 的版本使用 Blink |
| Firefox | Gecko (俗称Firefox内核) |
| Opera | 早期:Presto 现在:Chromium/Blink |
| Safari | WebKit |
| Internet Explorer | 旧版 IE 使用的渲染引擎:Trident |
| 旧版 Microsoft Edge | EdgeHTML(已抛弃) |
WebKit 的发展历程:
渲染进程中的多线程
前面提到过,渲染进程(Rendering Process)负责管理和运行网页的渲染任务。渲染进程中包含渲染引擎,同时也处理其他与渲染相关的任务,如JavaScript执行、事件处理和用户交互等。
渲染进程内部是多线程的。通常包含以下几个主要线程:
渲染主线程(Main Thread)
- 解析 HTML 和 CSS:主线程负责解析 HTML 构建 DOM 树,解析 CSS 构建 CSSOM 树。
- 构建渲染树(Render Tree):根据 DOM 树和 CSSOM 树,生成渲染树。
- 布局计算:主线程负责处理布局(Layout)任务,计算每个元素在页面中的大小和位置,生成布局树(Layout Tree)。
-
- 在此阶段,主线程会根据页面的结构和样式决定哪些元素需要被提升为独立的图层,如使用 z-index 来控制元素的堆叠顺序等,这一操作被称为图层划分(Layering)。
- 图层划分的目的在于提升性能,使得某些图层可以独立更新或动画化,而不必重新计算和重绘整个页面的布局,确保流畅的渲染体验。
- 绘制(Paint):在图层划分后,主线程会为每个图层生成对应的 绘制指令(Paint Instructions),这些指令描述了如何绘制每个图层中的内容(背景、边框、文字等)。
- 协调各个线程:主线程协调渲染流程的不同阶段,以及与其他线程的工作同步。
GUI渲染线程(GUI Rendering Thread)
- 页面绘制执行:根据主线程的布局和绘制指令,将页面内容渲染到屏幕上。
- 页面更新与重绘:页面的某些元素需要更新样式或内容时,GUI 渲染线程会处理页面的重绘(Repaint),将修改的部分重新绘制。
- 响应页面变化:当用户交互(如滚动页面)或页面尺寸改变时,GUI 渲染线程负责根据这些变化重新渲染页面。
JS 引擎线程(JS Engine Thread)
职责:
- 执行 JavaScript 代码:JS 引擎线程专门负责执行 JavaScript 代码,处理 DOM 操作、事件回调、异步任务等。
- 修改 DOM 和样式:JS 引擎线程可以通过操作 DOM 和 CSSOM 来动态更新页面内容和样式。
- 事件处理:处理用户交互事件(如点击、键盘输入等),并执行相应的回调函数。
- 任务队列管理:负责管理任务队列,包括微任务(Microtasks)和宏任务(Macrotasks)。当有事件或异步操作完成时,JS 引擎线程会将它们加入队列中等待处理。
注意:
- JS 引擎线程与 GUI 渲染线程是互斥的。当 JavaScript 代码正在执行时,GUI 渲染线程将暂停工作,直到 JavaScript 执行完成。这意味着过长的 JavaScript 执行会影响页面的渲染和响应。
重点提醒:
渲染进程内部是很多个线程一起协同配合工作的。一般,由于线程之间的相互依赖,为了方便理解,我们会将主线程、GUI 线程和 JS 线程归在一起,统称为主线程。但这并不会影响我们对 JavaScript 作为单线程语言的理解,尤其是在事件循环机制方面。
合成器线程(Compositor Thread)
合成线程执行图层的合成与渲染:
- 一旦图层划分完成并生成绘制指令,主线程将这些信息传递给合成线程。
- 合成线程会对图层进行分块处理(Tiling),并将这些分块交由光栅线程进行光栅化(将图层转换为像素)。
- 最后,合成线程负责将已经光栅化的图层合成为完整的页面,并通过 GPU 显示到屏幕上。
光栅线程(Raster Thread)
- 光栅化:光栅线程负责将合成线程分块后的内容转换为位图。光栅化的过程是将矢量图形(如 CSS 样式、文本、图像等)转换为像素,以便渲染到屏幕上。每个图层的块(Tile)都会由光栅线程处理,生成可以直接绘制的像素数据。
- GPU 加速:光栅线程可以利用 GPU 加速,特别是在处理复杂的图形时。通过硬件加速,光栅化过程可以更快地完成,尤其是在渲染大型或复杂的页面时。GPU 通常可以在多个光栅线程中并行处理这些任务,进一步提升渲染性能。
合成线程与光栅线程的协作方式
- 合成线程接收主线程传递的图层和绘制指令。它将图层分块(Tiling)并准备进行光栅化。
- 合成线程将这些图层分块发送给光栅线程。光栅线程负责将这些图层块转换为像素数据(光栅化)。
- 光栅线程接收来自合成线程的光栅化任务。它将图层块的矢量信息(如 CSS 样式、图像、文本等)转换为实际的像素数据。
- 光栅化后的结果会发送回合成线程。合成线程将光栅化后的位图合成成最终的图像。它会将不同图层的位图合成到一起,生成完整的页面视图。
- 合成线程会处理图层的合成顺序和透明度等属性,最终准备好一个完整的合成帧(Composited Frame)。
- 合成线程将合成好的图层合成帧(Composited Frame)发送给 GPU 或屏幕渲染设备。
- GPU 或屏幕渲染设备根据合成帧的像素数据,将图像绘制到屏幕上,用户就可以看到更新后的网页内容。
如下图:
工作线程(Worker Threads)
主要有两大应用:
- Web Workers:可以在后台执行耗时的任务,避免阻塞主线程。比如进行复杂的计算、数据处理等任务时,不会影响页面的交互响应。它们在独立的线程中运行,与主线程之间通过消息传递进行通信。
- Service Workers:在后台运行的脚本,用于拦截和处理网络请求、实现离线缓存等功能。可以缓存页面资源,使得在网络状况不佳或离线时,用户仍然能够访问部分或全部页面内容。
以上关于渲染进程中的多线程,可结合第七节内容一起看,方便理解。下面将详细介绍以上不同线程的工作内容。
网页的渲染流程
前置的 DNS 查询和建立网络连接等流程,暂不在本章内容中讨论。
先看流程图:
解析 HTML - 构建 DOM 树
DOM 树(Document Object Model Tree)的构建,是指浏览器在解析 HTML 文档时,将 HTML 元素转换为可以被操作的对象结构的过程。
流程如下:
加载二进制数据
当浏览器访问一个网站时,网络进程会处理与服务器之间的通信,获取所需的资源。服务器响应请求的数据是以二进制的字节流的形式返回。
转换字符
浏览器接收到字节流后,根据 HTTP 响应头中的编码格式(如 content-type: text/html; charset=utf-8)将字节转换为字符,如 0x48 0x54 0x4D 0x4 四个字节会被转换为 HTML 字符。
分词(Tokenization)
浏览器的 HTML 解析器可以将字符数据转换为 Token。Token 是 HTML 文档的基本解析单元,如开始标签、结束标签、属性、文本等。
- 开始标签 Token:如
<div> - 结束标签 Token:如
</div> - 文本 Token:如
Hello, World! - 属性 Token:如
id="main"
解析(Parsing)
解析器将生成的 Token 解析为 DOM 树的节点。每个 HTML 元素或文本节点都被转换为 DOM 节点。
- 处理开始标签:当遇到开始标签 Token 时,解析器创建一个新的 DOM 节点,并将其添加到当前节点的子节点列表中。
- 处理文本节点:文本 Token 转换为文本节点,并添加到当前节点的子节点列表中(Token 栈)。
- 处理结束标签:当遇到结束标签 Token 时,解析器将当前节点标记为结束,并返回到上一级节点。
解析过程中的 Token 栈操作如下:
比如有这样的 HTML 结构:
<html>
<body>
<div>hello chrome</div>
<p>hello world</p>
</body>
</html>
开始时,HTML解析器会创建一个根为 document 的空的 DOM 结构,同时将 StartTag document 的Token压入栈中,然后再将解析出来的第一个 StartTag html 压入栈中,并创建一个 html 的DOM节点,添加到document上,此时Token栈和DOM树如下:
接下来body和div标签也会和上面的过程一样,进行入栈操作:
随后就会解析到 div标签中的文本Token,渲染引擎会为该 Token 创建一个文本节点,并将该 Token 添加到 DOM 中,它的父节点就是当前 Token 栈顶元素对应的节点:
接下来就是第一个EndTag div,这时 HTML 解析器会判断当前栈顶元素是否是 StartTag div,如果是,则从栈顶弹出 StartTag div,如下图所示:
再之后的过程和上面类似,最终的结果如下:
可以在浏览器【性能】调试工具中查看 HTML 的解析过程:
局部更新
当 DOM 发生变化(如插入、删除、修改元素)时,浏览器不会重新生成整个 DOM 树,而是会对现有的 DOM 树进行相关的局部更新 —— 脏检查机制(Dirty Checking),避免不必要的性能开销。除非是进行了极端操作,如页面完全重新加载、彻底清空DOM 的根节点(body)等。
样式计算 - 构建 CSSOM 树
浏览器构建 DOM 树时,这个过程会占用主线程。为了提高效率,浏览器会开启一个预解析线程。预解析线程会解析其它可用的内容并请求高优先级的资源,如 CSS、JavaScript 和 web 字体。
在解析 HTML 的同时,浏览器也会解析所有与页面关联的 CSS 文件、内联样式和嵌入样式(<style> 标签中的内容)。浏览器将每个 CSS 规则解析成对应的节点,构建出一棵 CSSOM 树。
CSS 解析的数据流程图为:
前面的加载数据过程和加载 HTML 类似,这里只介绍下后面的步骤。
分词
分词就是将 CSS 源代码分解成基本的 token 单位,这些 token 包括选择器、属性名、属性值、单位、关键字、函数等。
例如,对于 CSS 规则 .my-class { color: blue; font-size: 14px; },分词后会识别出 .my-class 为选择器 token,color 和 font-size 为属性名 tokens,blue 和 14px 为属性值 tokens。
还有其它类型的 Token,如伪类(Pseudo-classes)、伪元素(Pseudo-elements)、运算符(Operators)等等。
生成 CSS 规则集
CSS 代码进行分词(tokenization)后,解析器会将这些分解后的 token 转换成结构化的 CSS 规则集。
一个 CSS 规则集的基本组成如下:
- 选择器(Selector):确定样式应用的 HTML 元素。
- 样式声明块(Declaration Block):一个包含一组属性-值对的块,用花括号
{}包围。每对属性-值用分号;分隔。
完整示例:
/* 规则集示例 */
p { /* 选择器 */
color: red; /* 样式声明:color 属性,值为 red */
font-size: 14px; /* 样式声明:font-size 属性,值为 14px */
}
以掘金网页为例,在控制台输入 document.styleSheets ,可以看到该网页的样式表:
解析规则集
解析规则集后,就会创建 CSSOM 树的节点,如下图:
样式继承
在 CSS 中存在样式的继承机制,CSS 继承就是每个 DOM 节点都包含有父节点的样式。如上图中的设置了 display: none 样式的 span 标签,就继承了父节点 p 标签的样式。
继承属性值表
| 属性 | 描述 | 示例用途 |
|---|---|---|
color | 文本颜色。 | 设置文本颜色 |
font-family | 字体系列。 | 设置字体系列 |
font-size | 字体大小。 | 设置字体大小 |
font-style | 字体样式(如斜体)。 | 设置字体样式 |
font-variant | 字体变体(如小型大写字母)。 | 设置字体变体 |
font-weight | 字体粗细。 | 设置字体粗细 |
letter-spacing | 字母间距。 | 设置字母之间的间距 |
line-height | 行高。 | 设置文本行高 |
text-align | 文本对齐方式(如左对齐、右对齐、居中)。 | 设置文本对齐方式 |
text-indent | 文本缩进。 | 设置文本缩进 |
text-transform | 文本转换(如大写、小写)。 | 设置文本的大小写转换 |
white-space | 空白符处理方式(如 normal, pre, nowrap)。 | 设置如何处理文本中的空白 |
word-spacing | 单词间距。 | 设置单词之间的间距 |
list-style | 列表样式(如圆点、数字)。 | 设置列表项的样式 |
list-style-type | 列表项的样式类型(如 disc, circle)。 | 设置列表项的标记样式 |
list-style-position | 列表标记的位置(如 inside, outside)。 | 设置列表标记的位置 |
list-style-image | 列表标记的图像(如 URL)。 | 设置列表标记的图像 |
border-collapse | 表格边框的折叠方式(如 collapse, separate)。 | 设置表格边框折叠方式 |
border-spacing | 表格单元格之间的间距。 | 设置表格单元格的间距 |
caption-side | 表格标题的位置(如 top, bottom)。 | 设置表格标题的位置 |
empty-cells | 表格中空单元格的显示方式(如 show, hide)。 | 设置表格中空单元格的显示方式 |
样式层叠
样式层叠是指多个 CSS 规则对同一元素应用样式时,如何确定最终的样式。
样式层叠的三个原则:
- 来源优先级(Origin)
-
- 浏览器默认样式:浏览器自带的默认样式。
- 自定义样式:网站开发者定义的样式,通常通过 CSS 文件或内联样式来应用。
- 特指性(Specificity)
-
- 每个 CSS 选择器都有一个特指性值,表示选择器的复杂性。特指性值越高,优先级越高。特指性计算规则如下:
-
- 内联样式(直接在元素上定义的样式):特指性值最高。
- ID 选择器(如
#id):特指性值较高。 - 类选择器、属性选择器和伪类选择器(如
.class,[type="text"],:hover):特指性值中等。 - 元素选择器和伪元素选择器(如
div,p,::before):特指性值最低。
- 样式来源(Order of Appearance)
-
- 当多个规则具有相同的特指性值时,最后出现的规则将覆盖之前的规则。这是因为在 CSS 中,后定义的样式具有更高的优先级。
层叠优先级的计算:
以下是一个简单的计算特指性的规则:
- 内联样式:
-
- 特指性值为 1000(即直接在元素上使用的样式,具有最高优先级)。
- ID 选择器:
-
- 特指性值为 100(ID 选择器的优先级)。
- 类选择器、属性选择器和伪类选择器:
-
- 特指性值为 10(类选择器、属性选择器和伪类选择器的优先级)。
- 元素选择器和伪元素选择器:
-
- 特指性值为 1(元素选择器和伪元素选择器的优先级)。
如下面的例子:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CSS Cascading Example</title>
<style>
/* 浏览器默认样式 */
p {
color: black;
}
/* 作者样式 */
.text {
color: blue; /* 特指性: 10 */
}
#unique {
color: red; /* 特指性: 100 */
}
.text#unique {
color: green; /* 特指性: 110 */
}
</style>
</head>
<body>
/* 内联样式 */
<p id="unique" class="text" style="color: yellow;">Sample text</p>
</body>
</html>
层叠过程:
- 内联样式(
color: yellow;):特指性值最高,应用于p元素。 .text#unique:具有特指性值 110,但由于内联样式特指性值更高,所以不生效。#unique:特指性值 100,应用于p元素,但被内联样式覆盖。.text:特指性值 10,应用于p元素,但被内联样式覆盖。p:浏览器默认样式,特指性值最低,被内联样式覆盖。
全量更新
层叠和继承:CSS 样式的层叠(cascading)和继承机制使得单个规则的变化可能影响多个元素。例如,修改一个祖先元素的样式,可能会影响其所有子元素的样式。因此,为了确保样式规则的正确性,浏览器通常会选择重新计算整个样式表。
选择器复杂性:一些复杂的 CSS 选择器(如 :hover, nth-child() 等)依赖于整个文档的结构和状态。如果某些选择器发生变化,浏览器可能无法仅局部更新,因为这些规则的影响范围可能扩展到多个不相关的元素。
尽管整个 CSSOM 树不能局部更新,但浏览器可以通过一些优化机制,减少不必要的重绘和重排:
- 样式变更的合并:当有多个 CSS 样式变更发生时,浏览器通常会合并这些变更,并在下一帧中一起处理,以避免频繁的重排和重绘。
- 样式作用域限制:对于某些局部样式变更(如通过 JavaScript 动态修改内联样式),浏览器可以仅对受影响的部分进行样式重新计算,而不必完全重构整个页面的样式树。
合成渲染树(Render Tree)
在 DOM 树和 CSSOM 树都渲染完成之后,就会进入渲染树的构建阶段。
渲染树就是 DOM 树和 CSSOM 树的结合,会得到一个可以知道每个节点会应用什么样式的数据结构。
这个结合的过程就是从 DOM 树的根节点开始,遍历整个 DOM 树,然后在 CSSOM 树里查询到匹配的样式。
在构建渲染树时,某些节点(如 link)会被忽略;有些节点通过 css 隐藏了,也会在渲染树中被忽略。例如上图中的 span 节点。
布局(Layout,也称为 Reflow)
渲染树生成后,布局(Layout)通过计算每个节点的样式属性(如宽高、位置、边距等),确定它们在设备可视窗口中的确切位置和大小,生成布局树。也就是说,布局就是找到所有元素的几何关系的过程。
布局也称为重排(Reflow)。当浏览器需要重新计算元素的尺寸和位置时,就会触发重排。重排可能发生在页面的初次渲染过程中,也可能由于某些操作(如窗口大小改变、元素尺寸或内容发生变化)而被触发。
重排是一个性能开销较大的操作,因为它可能会影响整个页面的布局,尤其是在复杂的布局中,因此优化代码以减少重排是提升网页性能的重要手段。
布局的计算过程
- 根元素的确定:
-
- 布局过程由浏览器的渲染引擎(如 Chrome 的 Blink 引擎)负责。渲染引擎首先确定根元素(
<html>)的尺寸和位置,通常基于浏览器窗口的大小和默认样式设置。
- 布局过程由浏览器的渲染引擎(如 Chrome 的 Blink 引擎)负责。渲染引擎首先确定根元素(
- 递归计算子元素:
-
- 从根元素开始,渲染引擎递归地计算每个子元素的尺寸和位置。这一过程考虑了元素的盒模型(包括内容区、内边距、边框和外边距)、CSS 布局属性(如
display、float、position等)以及文档流的方向(如从左到右、从上到下等)。
- 从根元素开始,渲染引擎递归地计算每个子元素的尺寸和位置。这一过程考虑了元素的盒模型(包括内容区、内边距、边框和外边距)、CSS 布局属性(如
- 复杂布局处理:
-
- 对于复杂的布局,如弹性布局(flexbox)和网格布局(grid),渲染引擎会根据相应的布局规则进行详细计算。这些布局模型具有独特的计算逻辑和规则,确保元素按照预期的方式排列和显示。
布局计算结果就形成了布局渲染树,准备后面阶段的分成和绘制流程。
分层(Layering)
形成布局树之后,浏览器主线程遍历布局树,根据响应的策略对布局树进行分层,并生成一棵对应的图层树。
可以在浏览器开发者工具中的 Layers 工具中查看分层情况。
可以看到顶部 Header 被划分到一个独立的图层中了,分层的原因是:当页面滚动时,position: fixed 的元素相对于视口固定不动。如果浏览器不把它放到独立图层,那么每次滚动时,这个元素会和其他内容一起重新绘制,影响性能。
分层的优点
- 提高性能: 通过将页面内容分成多个图层,浏览器可以只更新和重绘受影响的图层,而不是整个页面。这减少了绘制和布局的开销,提高了渲染效率。
- 优化动画和过渡效果: 对于使用
fixed定位、 CSS 动画、变换(transform)和透明度(opacity)的元素,分层可以使这些元素在独立的图层上处理,从而实现更平滑的动画效果。 - 减少重绘和回流: 分层允许浏览器仅对那些发生变化的图层进行重绘,减少了整个页面的重新布局和绘制,避免了不必要的回流(Layout Reflow)和重绘(Repaint)。
- 提高滚动性能: 当滚动页面时,分层可以将滚动内容独立于其他图层,从而提高滚动的平滑性和响应速度。
- 更好的 GPU 加速: 对于一些复杂的图层合成操作,浏览器可以利用图形硬件的加速功能。例如,使用 GPU 来加速图层的混合、变换和透明度计算等操作。
在实际开发中,合理利用图层可以显著提升性能。例如:
- 动画和过渡效果: 使用 CSS
transform和opacity时,可以促使浏览器将这些元素分配到独立的图层,以实现平滑的动画效果。 - 滚动和变换: 对于需要进行滚动或变换的元素,使用图层可以避免重新布局,从而提高滚动性能和流畅度。
- 合理使用will-change:
will-change允许开发者显式地声明哪些属性会改变,浏览器可以提前为这些元素分配图层,减少重绘和回流的开销,从而提高动画和视觉效果的性能。但是使用will-change可能会增加内存开销,因为每个使用了will-change的元素都会被提升到一个独立的图层。如果不加限制地使用,可能会导致内存使用量的增加,甚至可能影响页面性能。
绘制(Paint)
划分好图层之后,主线程会为每个图层生成绘制指令集。
绘制指令集,就是用于描述这一层的内容(如颜色、纹理、图像等)该如何画出来。比如把画笔移到某个位置,先画什么再画什么,把一个图层的绘制拆分成很多小的绘制指令 ,然后再把这些指令按照顺序组成一个待绘制列表。和 Canvas 的绘制有相似之处。
在 Chrome 开发者工具中的图层工具 — 分析器里,可以看到左侧的绘制指令和右侧的绘制过程。
主线程生成绘制指令集之后,会把图层和绘制指令传递给合成线程。
分块(Tiling)
合成线程在接收到主线程传递的图层(layer)和绘制指令后,会对图层进行分块(Tiling)处理。
分块的目的是为了优化图层的光栅化和渲染性能。根据图层大小,图层会被划分为多个瓦片(Tile),瓦片尺寸通常为 256x256 或 512x512 像素(具体大小可因设备而调整)。每个瓦片都独立进行处理和光栅化,光栅化线程可以并行处理多个瓦片,充分利用多核处理器的能力,从而显著减少光栅化时间。
对于大型或复杂的图层(例如整个网页或复杂背景),分块技术有效地管理和处理图层内容,避免一次性处理整个图层导致的性能消耗。
光栅化(Rasterization)
合成线程将这些图层的分块数据发送给光栅线程。光栅化是将页面上的图形、文本和其他可见元素转换为像素的过程。在浏览器中,页面的可视内容通常以矢量形式表示,但在显示器上呈现时需要将其转换为光栅图像(由像素组成的位图)。
有了很多分块之后,浏览器就可以动态分配多个光栅化线程,以提高并行处理效率。并且,分块之后也可以支持惰性光栅化(Lazy Rasterization)的优化。浏览器会对可视区域及其周围的区域进行优先光栅化。这是因为这些区域的内容用户会立即看到,优先处理可以提高页面的加载感知速度。
同时,浏览器也会缓存已光栅化的瓦片,以便在用户滚动或缩放时,重新使用这些瓦片,减少重复光栅化的开销。
合成阶段(Composite)
合成阶段的任务就是将这些光栅化后的分块合并到各自的图层中,并最终组合这些图层进行显示。
合成阶段主要处理下面几个任务:
- 合并瓦片到图层:光栅化后的瓦片是图层的组成部分,合成阶段首先要确保这些瓦片正确地无缝拼合在各自的图层上,确保图层的完整性和连贯性。
- 图层整理与排序:根据页面的结构和元素的堆叠顺序,建立图层的层次关系。比如,如果一个半透明的图像图层在文本图层之上,那么在合成时,图像图层就会在上面遮挡文本图层。
- 处理图层属性、变换和动画:
-
- 透明度处理:对于具有透明度的图层,计算其与下面图层的混合效果。根据透明度值和颜色值,确定最终合成后的颜色。例如,一个半透明的红色图层覆盖在蓝色图层上,会根据透明度计算出混合后的颜色。
- 变换和动画:对于进行了平移、旋转、缩放等变换的图层,应用这些变换效果。例如,如果一个图层被移动了一定位置,在合成时需要将其像素按照移动后的坐标进行重新定位。
- 输出到显示缓冲区:当所有图层完成合成后,合成后的结果是帧缓冲区中的一帧图像。合成器会将结果绘制到帧缓冲区中,最后呈现在屏幕上。
显示
有了帧图像之后,下一步就是将这些帧显示在显示器上。这个过程会从帧缓冲区读取图像数据,并通过显示器的刷新机制将其呈现到屏幕上。、
每个显示器都有固定的刷新频率,通常是 60HZ,也就是每秒更新 60 张图片。显示器所做的任务很简单,就是每秒固定读取 60 次缓冲区中的图像,并将读取的图像显示到显示器上。
渲染原理相关的常见问题
理解浏览器的渲染原理之后,开发中经常碰到的问题,就能够更好的理解并处理了。
重排和重绘
重排 是浏览器在需要重新计算页面元素的几何属性时进行的操作。它涉及到计算元素的大小、位置和布局,并在这些计算之后更新页面布局。
触发条件:
- 修改元素的大小、位置或边距(例如
width、height、padding、margin、position等)。 - 添加或删除 DOM 元素。
- 改变元素的显示状态(如
display: none→display: block)。 - 窗口大小变化:如用户调整浏览器窗口大小,浏览器需要重新计算元素的布局。
- 字体变化:改变字体的大小、类型或样式可能导致重排,因为这些变化会影响元素的尺寸和位置。
- 读取某些布局属性(如
offsetWidth、offsetHeight等),因为浏览器需要确保这些值是最新的,从而会先触发重排。
重绘 是在浏览器已经知道元素的位置和尺寸之后,更新这些元素的视觉表现的过程。它涉及到绘制元素的颜色、边框、背景等视觉样式。
- 样式更新:修改了不影响布局的 CSS 属性,如 color、background-color、border-color、visibility 等。
- 内容更新:更改文本内容或图片的源,这会导致浏览器更新这些内容的显示。
- 元素的显示状态变化:如通过 JavaScript 更改元素的 visibility 或 opacity 属性。
重排 是重新计算布局,开销较大。重绘 是重新绘制元素外观,开销较小但频繁重绘仍然影响性能。
优化建议
为了避免频繁的重排和重绘导致性能下降,可以采取以下优化措施:
- 减少 DOM 操作:
避免频繁操作 DOM,特别是涉及尺寸、布局的变化。可以将多个 DOM 操作合并成一次性操作。 - 批量更新:
使用documentFragment或display: none暂时隐藏元素,在内存中进行批量修改,然后再一次性显示,减少对页面的反复更新。 - 避免频繁读取布局属性:
避免在 JavaScript 中频繁读取offsetHeight、offsetWidth等布局属性。这些属性会强制浏览器同步执行重排操作。可以将值缓存起来,避免多次访问触发重排。 - 使用 CSS3 动画:
使用transform、opacity等不会触发重排的 CSS 属性来创建动画,而避免使用会触发重排的属性,如top、left、width等。
更高效的动画
尽可能通过 CSS transition 和 animation 创建动画。JS 可以 requestAnimationFrame 来创建动画,因为 requestAnimationFrame 的回调函数是在浏览器进行下一次重绘之前触发的。
下面举个例子,说明 transform 对比使用 left 创建动画的优势:
<!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>
<style>
.ball {
width: 100px;
height: 100px;
background: #f40;
border-radius: 50%;
margin: 30px;
}
.ball1 {
animation: move1 1s alternate infinite ease-in-out;
}
.ball2 {
position: fixed;
left: 0;
animation: move2 1s alternate infinite ease-in-out;
}
@keyframes move1 {
to {
transform: translate(100px);
}
}
@keyframes move2 {
to {
left: 100px;
}
}
</style>
</head>
<body>
<button id="btn">死循环5秒</button>
<div class="ball ball1"></div>
<div class="ball ball2"></div>
<script>
function delay(duration) {
const start = Date.now();
while (Date.now() - start < duration) { }
}
btn.onclick = function () {
delay(5000);
};
</script>
</body>
</html>
运行结果如下:
可以发现,使用 left 变化实现动画的蓝色小球,会一直触发重绘;而使用 transform 变换实现动画的红色小球,只会绘制一次。在使用 js 死循环卡死主线程的 5s 时间里,主线程无法完成重绘的操作,造成蓝色小球卡住不动。
DocumentFragment 的原理
DocumentFragment 是浏览器提供的一种轻量级的文档片段,它是一个特殊的 DOM 节点,存在于内存中,不会直接被渲染到页面上。DocumentFragment 的核心目的是提供一个便捷且高效的方式,允许开发者在内存中进行批量的 DOM 操作,然后一次性将其添加到文档中,减少性能消耗。
使用方式如下:
// 创建一个 DocumentFragment
let fragment = document.createDocumentFragment();
// 创建一些 DOM 元素
let newDiv = document.createElement('div');
let newParagraph = document.createElement('p');
// 将元素添加到 DocumentFragment
fragment.appendChild(newDiv);
fragment.appendChild(newParagraph);
// 一次性将所有子节点插入到 DOM 树中
document.body.appendChild(fragment);
// 此时,fragment 中的子节点已经被移到 DOM 树中,fragment 为空
JS 的执行为什么会阻碍渲染
前面提到过,浏览器渲染进程内部是很多个线程一起协同配合工作的。一般,由于线程之间的相互依赖,为了方便理解,我们会将主线程、GUI 线程和 JS 线程归在一起,统称为主线程。
JS 运行在浏览器的主线程上,而主线程同时也负责处理页面的解析、布局和绘制等任务。当 JavaScript 执行时,主线程被占用,因为 JS 可以通过操作 DOM 和 CSSOM 来动态更新页面内容和样式,这样一来其他任务(如 DOM 解析和渲染)就会被阻塞。因此,如果 JavaScript 执行时间过长,整个页面的渲染过程可能会被延迟,从而导致页面加载缓慢或出现明显的卡顿。
如下面的例子:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>JS 的执行会阻塞主渲染进程</title>
</head>
<body>
<p>我是内容1</p>
<button>修改内容的按钮</button>
<script>
const p = document.querySelector('p');
const button = document.querySelector('button');
// 死循环指定的时间
function whileLoop(time) {
const start = Date.now();
while (Date.now() - start < time) {}
}
button.addEventListener('click', () => {
// 1. 执行死循环
whileLoop(3000);
// 2. 修改内容
p.textContent = '我是内容2';
});
</script>
</body>
</html>
主线程被占用时,无法在点击按钮后修改 p 标签中的内容。
Web Works
Web Workers 运行在与主线程(即主 JavaScript 线程)隔离的独立线程中。这意味着 Web Workers 的执行不会阻塞主线程的 UI 渲染和用户交互,可以提升网页的响应速度和整体性能。