浏览器-系列
浏览器渲染流程
浏览器HTTP缓存

前言

动动手写个网页非常简单,因为浏览器帮我们做了很多复杂的工作。

MDN-渲染页面:浏览器的工作原理 告诉我们,导航是加载 web 页面的第一步,经过响应解析渲染,最终得到一个可交互的页面。

现在我们聚焦于解析渲染,即浏览器通过网络请求开始获取或已经拿到了 HTML 等静态资源。

所谓渲染,就是将静态资源转为可视可交互的页面(像素信息)。

浏览器进程

进程是 CPU 分配资源的最小单位(能够拥有资源和独立运行的最小单位)
线程是 CPU 调度的最小单位(运行在进程上的一次程序运行单位)

以Chrome为例,现代化的浏览器是多进程

  1. 浏览器进程 负责用户界面、子进程管理、提供存储等功能。
  2. GPU 进程 负责 3D CSS 渲染、页面绘制、解码等。
  3. 网络进程 负责发起和接受网络请求。
  4. 渲染进程 负责控制显示tab标签页内的所有内容,核心任务是将HTML、CSS、JS转为用户可以与之交互的网页,排版引擎Blink和JS引擎V8都是运行在该进程中,默认情况下Chrome会为每个Tab标签页创建一个渲染进程

常说的浏览器内核也就是浏览器的渲染引擎,即渲染进程。

渲染进程包含多个线程

  1. JS 引擎线程 负责解释执行 JavaScript 脚本。
  2. GUI 渲染线程 负责渲染浏览器界面,解析 HTML、CSS,构建 DOM 树和 Render 树,布局和绘制。
  3. 合成线程 将图层分为图块,并使用GPU进程,在栅格化线程池中将图块转化为位图。
  4. 事件触发线程 负责事件的处理,如鼠标点击、滚动等,将事件放入事件队列中。
  5. 定时器触发线程 负责处理定时器,计算是否到达定时时间,若到达时间则将定时器事件放入事件队列中。
  6. 异步 HTTP 请求线程(网络线程) 处理异步请求及其回调函数。
  7. IO 线程 与其它进程通信。

注意:GUI渲染线程与JS引擎线程是互斥的,当JS引擎执行时GUI线程会被挂起,GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行。

渲染总体流程

浏览器通过网络请求获取 HTML 字符串。当浏览器收到第一个数据分块,就可以开始解析收到的信息。

渲染流程分为多个阶段:HTML 解析、样式计算、布局、分层、生成绘制指令(绘制)、分块、光栅化、绘制(画)。

前一个阶段的输出是后一个阶段的输入,这些阶段是逐步进行的。

解析HTML parse

解析 HTML:浏览器遵守一套步骤将 HTML 转换为 DOM 树 (Document Object Model 文档对象模型)。

步骤:字节数据 -> 字符串 -[状态机]-> 词(Token) -> 节点(Node) -> DOM

浏览器首先将字节数据转为字符串,然后通过状态机进行词法分析,将字符流分解为词,再将词转为节点,最终形成 DOM 树。

这个过程是循序渐进的,当浏览器收到首个 HTML 数据块,就会开始构建 DOM 树,由于自顶向下构建,因此后面构建的不会对前面的造成影响。

Token 分为 TagTokenTextToken,TagToken 即标签,又分为开始标签、结束标签、自封闭标签。

预解析线程

在解析 HTML 的过程中,可以能会遇到如 style、link、script 等标签。
为了提高解析效率,浏览器在开始解析 HTML 前,会启动一个预解析线程,率先下载 HTML 中的外部 CSS 文件和外部的 JS 文件。

对于 CSS:
当渲染主线程解析到 link 位置,若外部 CSS 文件还未下载并解析好,主线程不会等待,而是继续解析后续的 HTML。下载和解析 CSS 的工作都在预解析线程中完成。所以 CSS 不会阻塞 HTML 解析。

对于 JS:
当渲染线程解析到 script 位置,会停止解析 HTML,转而等待 JS 文件下载好,并将全局代码解析执行完成后,才能继续解析 HTML。因为 JS 可能会修改 DOM 结构,此时停止构建 DOM 树才能避免解析错误。

执行 JS 实际上在 JS 引擎线程,而不是在 GUI 渲染线程,但这两个线程是互斥的,所以 JS 会阻塞渲染。

现代化的浏览器提供了一些资源提示关键词,以避免 JS 阻塞渲染。

  1. async 异步加载 JS 文件,不等待 JS 文件下载,继续解析 HTML,直到下载完成后再执行 JS。执行时会阻塞 HTML 解析。
  2. defer 延迟加载 JS 文件,等到 HTML 解析完成后再执行 JS(在 DOMContentLoaded 事件之前),不会阻塞 HTML 解析。如果 async 和 defer 同时存在,async 优先级更高。
  3. type="module" 默认为 defer,且 JS 代码会在严格模式下执行,但也可以使用 async。
  4. rel="preload" 预加载资源,告诉浏览器提前加载资源,浏览器会尽快地加载某个资源,但不执行。preload 优先级高于 prefetch。
  5. rel="prefetch" 预取资源,告诉浏览器提前加载资源,通常是未来可能需要的资源,浏览器会在网络空闲时下载它,但不执行。
  6. 还有一些,后面开个文章再说。

构建CSSOM

CSS 字符串同样会被解析,构建 CSSOM 树(CSS Object Model)。与构建 DOM 同时进行。

CSSOM 的构建必须要获得一份完整的 CSS 文件,因为存在覆盖等情况。

CSSOM 中包含

  1. 通过 link 引用的外部 CSS 样式文件
  2. style 标签内的CSS样式
  3. 元素的 style 属性内嵌的CSS

样式计算style、Render树

得到了 DOM 和 CSSOM,还需要在样式计算阶段将两者结合起来,形成 Render 树(渲染树)。

渲染树会确定一个节点的所有样式属性。计算过程:

  1. 确定声明:如果属性只有一个声明,那么直接应用该声明。
  2. 层叠冲突:如果属性有多个声明,那么进入层叠冲突流程。这一流程分为比较源的重要性、比较优先级和比较次序(声明的先后顺序)三个步骤
  3. 继承:如果属性没有声明且是可继承的属性,那么直接继承最近的父元素属性值。
  4. 默认值:如果一个属性经历了上述三个流程,还无法确定其属性值,那么就使用默认值。

这个阶段还会标准化属性值,将各种 CSS 样式,转换为渲染引擎能够理解的、标准化的值。如将 em 转为 pxred 转为 rgb(255,0,0)

不可见元素不会出现在渲染树上,如 <head><script>display: none 等。应用了 visibility: hidden 的节点会包含在渲染树中,因为它们会占用空间、拥有其它样式属性。

注意:此图还不是渲染树,因为缺失了默认样式,并且没有标准化属性值。

总结:每个可见节点都应用了 CSSOM 规则。渲染树包含所有可见节点的内容和计算样式,将所有相关样式与 DOM 树中的每个可见节点匹配起来,并根据 CSS 层叠,确定每个节点的计算样式。

布局layout

浏览器根据 Render 树中的节点计算它们在屏幕上的确切位置(x,y 坐标)、输出每个元素的 Box Model(盒子模型)、将所有相对测量值(%)都将转换为绝对像素。这个过程称为布局

生成布局树的过程:主线程会遍历刚刚构建的渲染树,根据 DOM 节点的计算样式计算出一个布局树(layout tree)。布局树上每个节点会有它在页面上的 x,y 坐标以及盒子尺寸的具体信息。

分层 layer

分层是为了提高渲染性能,将页面分为多个图层。

页面中有很多复杂的效果,如3D变换,页面滚动等,为了更方便的实现这些效果,渲染引擎为特定的节点生成专用的图层,并生成一颗对应的图层树,最后再合成图层。这些图层会按照一定顺序叠加在一起,就形成了最终的页面。

分层是自动的,一些属性会触发分层:

  1. 拥有层叠上下文属性的元素
  2. 文档根元素、video、canvas、iframe 等元素。
  3. animation 设置了动画的元素
  4. backface-visibility: hidden
  5. 滚动条也是一个单独图层
  6. 需要裁剪的元素,文字较多超过了盒子的高度,会为文字部分单独创建一个图层。
层叠上下文属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
文档根元素(<html>);
position 值为 absolute(绝对定位)或 relative(相对定位)且 z-index 值不为 auto 的元素;
position 值为 fixed(固定定位)或 sticky(粘滞定位)的元素(沾滞定位适配所有移动设备上的浏览器,但老的桌面浏览器不支持);
flex (flex) 容器的子元素,且 z-index 值不为 auto;
grid (grid) 容器的子元素,且 z-index 值不为 auto;
opacity 属性值小于 1 的元素(参见 the specification for opacity);
mix-blend-mode 属性值不为 normal 的元素;
以下任意属性值不为 none 的元素:
transform
filter
backdrop-filter
perspective
clip-path
mask / mask-image / mask-border
isolation 属性值为 isolate 的元素;
will-change 值设定了任一属性而该属性在 non-initial 值时会创建层叠上下文的元素(参考这篇文章);
contain 属性值为 layout、paint 或包含它们其中之一的合成值(比如 contain: strict、contain: content)的元素。

生成绘制指令paint

渲染线程会为每个图层单独生成绘制指令,这些指令描述了如何将图层渲染到屏幕上。

绘制指令类似于 canvas 的绘图指令

1
2
3
4
5
context.beginPath(); // 开始路径
context.moveTo(10, 10); // 移动画笔
context.lineTo(100, 100); // 绘画出一条直线
context.closePath(); // 闭合路径
context.stroke(); // 进行勾勒

这一步只是生成了各图层的绘制指令集,还没有开始执行这些指令。

在开发者工具中,通过Layer标签可以看到图层的绘制列表和绘制过程。

当图层绘制列表准备好之后,渲染主线程会把该绘制列表提交给合成线程。剩余的渲染工作(合成操作)都将由合成线程完成。

分块tiling

一个图层可能很大,为了提高绘制效率、优先绘制视口区域,合成线程会将图层分成很多小块,这个过程称为分块

这些图块的大小通常是 256x256 或者 512x512。

光栅化raster

将图块转为位图,确认每一个像素点的 rgb 信息,这个过程称为光栅化

合成线程将需要光栅化的图块提交给 GPU 进程,由 GPU 进程的光栅化线程池完成相关操作。会优先将视口区域的图块进行光栅化。

绘制draw

当所有的图块都被栅格化后,合成线程会拿到各个块的位图,从而生成一个个 指引(quad) 信息。

指引信息会标识出每个位图应该画到屏幕的哪个位置,还会考虑到旋转、缩放等变形效果

变形发生在合成线程,与渲染主线程无关,这就是 transform 效率高的原因。

合成线程会把指引提交给 GPU 进程,由 GPU 进程产生系统调用,提交给 GPU 硬件,完成最终的屏幕成像。

合成线程会通过 IPC 向浏览器进程(browser process)提交(commit)一个渲染帧。这个时候可能有另外一个合成帧被浏览器进程的 UI线程(UI thread)提交以改变浏览器的 UI。这些合成帧都会被发送给 GPU 完成最终的屏幕成像。

当合成线程收到页面滚动的事件,合成线程会构建另外一个合成帧发送给 GPU 来更新页面。

总结

这些步骤就是关键渲染路径CRP,其中生成绘制指令及其后的步骤可以合并看作是绘制步骤。

优化关键渲染路径:

  1. 通过异步、延迟加载或者消除非关键资源来减少关键资源的请求数量。
  2. 优化必须的请求数量和每个请求的文件体积。
  3. 通过区分关键资源的优先级来优化被加载关键资源的顺序,来缩短关键路径长度。

其它

回流reflow

回流(重排 reflow):对页面的任意部分或整个文档的大小和位置的重新计算。性能开销较大。

本质就是重新计算布局树回流一定会触发重绘

发生回流的情况:

  1. 页面首次渲染
  2. 浏览器窗口大小发生变化
  3. 元素尺寸、位置、内容发生变化
  4. 添加或删除可见的DOM元素
  5. 激活CSS伪类

在使用 JS 操作元素样式时,为了避免连续的多次操作可能导致布局树反复计算,浏览器会合并这些样式操作,在 JS 执行完后一次性进行渲染。

若 JS 读取了元素的位置、尺寸等布局相关信息,则会强制渲染,这对于制作无缝轮播图以及一些动画效果是非常有用的。

查看属性是否会触发回流或重绘:csstriggers

重绘repaint

当改动了可见样式后,就需要重新计算,会触发重绘

重绘不会触发布局、分层阶段,所以效率比回流要高很多。

本质是重新生成了绘制指令。


参考:
渲染页面:浏览器的工作原理-MDN
深入理解浏览器解析渲染 HTML
浏览器渲染原理流程
画了20张图,详解浏览器渲染引擎工作原理
渲染树构建、布局和绘制
浏览器渲染流程(上) DOM树、CSSOM树、布局
JS深入浅出 - 浏览器的重绘(Repaint)与回流(Reflow)