2022-09-15 03:35:27

浏览器渲染

从一个经典问题入手:从输入 URL 到按下回车,再到网页最终展示,浏览器发生了什么?

如果先忽略 DNS 查询、TCP 连接、TLS 握手等网络细节,整个过程可以粗略分为两个阶段:网络请求页面渲染

浏览器通过网络模块发起 HTTP/HTTPS 请求,与服务器建立通信,并获取返回的 HTML 内容。随后,浏览器会将这份 HTML 包装成一个渲染任务,放入渲染主线程的消息队列中。在事件循环机制的调度下,渲染主线程取出任务,并正式开启页面渲染流程。

我们来拆解:浏览器拿到 HTML 字符串后,如何把它渲染成最终页面。

整体流程

页面渲染大致可以分为以下几个阶段:

  • 解析 HTML,构建 DOM
  • 解析 CSS,构建 CSSOM
  • 样式计算,得到每个节点的最终样式
  • 布局,生成布局树
  • 分层,生成图层树
  • 生成绘制指令
  • 分块与光栅化
  • 合成与显示

1. 解析 HTML,构建 DOM 树

浏览器接收到的网络响应本质上是字节数据,也就是一串 01。虽然我们平时看到的是 HTMLCSSJS 这样的文本文件,但在网络传输过程中,它们都会先以字节形式进行传递。

当浏览器接收到 HTML 字节数据后,会根据响应头、文档声明或默认规则确定编码方式,然后将字节数据解码为字符串。

得到字符串后,浏览器会进行标记化(tokenization):通过词法分析将字符串拆分成一个个标记(token),例如开始标签、结束标签、属性和文本内容等。

为什么需要标记化?

如果只是一整段字符串,浏览器无法直接理解其中的结构。通过标记化,浏览器才能知道哪些内容是标签、哪些内容是属性、哪些内容是文本节点。

比如下面这段 HTML

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div>
        <span>
            213
        </span>
</div>
</body>
</html>

经过标记化和树构建阶段后,浏览器就能生成对应的 DOM 树。

2. 解析 CSS,构建 CSSOM 树

在解析 HTML 的过程中,如果遇到 <style> 标签,浏览器会解析其中的 CSS;如果遇到 <link rel="stylesheet"> ,浏览器会请求对应的外部样式文件。

为了提升加载效率,浏览器通常还会通过预加载扫描器提前扫描后续的资源,例如样式表、脚本和图片,并尽早发起下载。

CSS 解析完成后,会形成一棵 CSSOM 树。CSSOM 记录了样式规则、选择器以及这些规则最终会如何作用到节点上。

需要注意的是:CSS 通常不会阻塞 DOM 树的构建,但会阻塞页面渲染。因为浏览器需要先知道节点的最终样式,才能继续进行后续的样式计算、布局和绘制。

3. JavaScript 对 HTML 解析的影响

除了 CSS,浏览器在解析 HTML 时也可能遇到外部 JS 文件或内联脚本。

如果主线程解析到普通的同步 <script> 标签,会暂停 HTML 解析,等待脚本下载并执行完成后,再继续构建 DOM 树。

因为 JS 代码可能会通过 document.write 或 DOM API 修改当前文档结构,所以浏览器需要暂停 HTML 解析,保证脚本执行时看到的是当前准确的文档状态。

不过,并不是所有脚本都会以同样的方式阻塞解析。使用 asyncdefertype="module" 时,脚本的下载和执行时机都会有不同。

4. 样式计算

有了 DOM 树之后,浏览器还不知道页面应该长什么样。因为 DOM 只描述了文档结构,而元素最终显示成什么样,还需要结合 CSS 规则来确定。

在样式计算阶段,主线程会遍历 DOM 树,并结合 CSSOM,为每个节点计算出最终样式,也就是 Computed Style

这个过程中会发生几类转换:

  • 没有显式设置的样式,会使用浏览器默认样式或继承值。
  • 相对单位会被转换为更具体的计算值,例如 em 会根据上下文计算出具体尺寸。
  • 颜色关键字会被转换为具体颜色值,例如 red 会变为 rgb(255, 0, 0)

为什么这里要说「大部分」和「很多预设」,可以参考:CSS 属性的计算过程

5. 布局(Layout)

到这一步,浏览器已经知道了页面结构和每个节点的最终样式,但还不知道每个元素应该出现在页面的哪个位置、占据多大的空间。

布局阶段会根据 DOM 树和计算后的样式生成布局树(Layout Tree)。布局树中会包含页面中需要参与布局的节点,并记录它们的位置和尺寸信息。

每个布局节点通常会得到以下信息:

  • 在页面中的位置坐标
  • 盒子的宽高
  • 边距、边框、内边距等盒模型信息
  • 具体的边界盒信息(bounding box)

需要注意的是,display: none 的元素不会出现在布局树中,因为它不参与布局;而一些伪元素、匿名盒等不一定直接对应真实的 DOM 节点,但可能会参与布局。

6. 分层

完成布局后,浏览器还会根据页面情况进行分层,生成图层树(Layer Tree)。

分层的好处是:当某一层发生变化时,浏览器可以尽量只处理这一层,而不必重新处理整个页面,从而提升渲染效率。

例如,页面中的某些元素可能会因为以下原因被提升为单独图层:

  • 使用了 transformopacity 动画
  • 使用了 position: fixed
  • 使用了 will-change
  • 包含视频、Canvas 等特殊内容
  • 浏览器根据自身策略进行图层优化

![]https://raw.githubusercontent.com/patty-yang/pic/images/browser-render_7.png

为了确定哪些元素应该放在哪一层,主线程会遍历布局树,并根据元素样式、层叠上下文和浏览器优化策略创建图层树。

7. 生成绘制指令

分层完成后,主线程会为每个图层生成绘制指令集。这些指令描述了这一层应该如何被画出来,但此时还没有真正把像素绘制到屏幕上。

可以把它理解为类似 Canvas 的绘制命令:

context.beginPath()
context.moveTo(10, 10)
context.lineTo(100, 100)
context.closePath()

这些绘制指令只是描述「怎么画」,真正的像素生成会发生在后面的光栅化阶段。

生成绘制指令后,渲染主线程会将每个图层的绘制信息提交给合成线程。

8. 分块与光栅化

页面可能很大,如果一次性把整个图层都绘制出来,成本会比较高。因此,合成线程会将每个图层划分为更小的区域,这个过程通常称为分块(tiling)。

分块之后,浏览器可以优先处理视口附近的图块,也可以将不同图块的处理任务分配给多个线程,从而提高效率。

光栅化(rasterization)就是把绘制指令转换为位图的过程。换句话说,它会把每个图块转成具体的像素信息。

光栅化通常由合成线程调度,并交给光栅化线程池处理;在开启硬件加速时,部分工作也可能由 GPU 参与完成。

9. 合成与显示

当图块完成光栅化后,合成线程会收集每个图层、每个图块的位置和变换信息,生成一个个绘制四边形(quad)。

quad 会描述位图应该画在屏幕的什么位置,同时也会记录旋转、缩放、透明度等合成信息。

随后,合成线程会把这些信息提交给 GPU,由 GPU 进行最终合成并显示到屏幕上。

这也是为什么 transformopacity 的性能通常比较好:在很多情况下,它们可以只触发合成,而不需要重新执行布局和绘制。