浏览器渲染原理

997 阅读12分钟

渲染器进程的内部工作原理

渲染器进程

Renderer Process (渲染进程)负责 Tab 发生的所有事情。 在 Renderer Process 中,主线程 Main Threads 处理你为用户编写的大部分代码。 如果你使用了 web worker 或 service worker,将由 Work Threads 处理。 Compositor (排版) 和 Raster (栅格) threads 也在 Renderer Process 内运行,以便高效、流畅地呈现页面。

Renderer Process 的核心工作是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页。

图1:渲染器进程内部有主线程、工作线程、排版线程和栅格线程

图2:webkit 渲染流程图

解析

DOM 的构建

当渲染器进程接收到本地磁盘或者浏览器从网络进程收到的HTML后 , 主线程开始解析 HTML 字符串,并逐步将其转换为文档对象模型(DOM—Document Object Model ),其过程是顺序的,渐进式的。

  • 将 html 字符串转换成带有 startTagendTag 或是 文本 等信息的 Token

  • 利用带有标识的 Token 识别出子父节点的关系。

图3:Hello 的出现在 titlestarTagendTag 标识之间,那么 hello 属于 titlechildren

  • 依次消耗被生成的 Token ,生成节点对象,并构建 DOM Tree

带有结束符的标识不会创建节点对象

<html>
<head>
    <title>Web page parsing</title>
</head>
<body>
    <div>
        <h1>Web page parsing</h1>
        <p>This is an example Web page.</p>
    </div>
</body>
</html>

上面这串代码最终会生成如下的 DOM Tree

图4:DOM 是页面在浏览器中的内部表示,同时也是 Web 开发人员可以通过JavaScript 与之交互的数据结构和 API

这是 DOM 解析遵循的 HTML 标准 还有 关于 HTML 解析更详细的细节

子资源加载

网站通常使用图像、CSSJavaScript 等外部资源。 这些文件需要从网络或缓存中加载。 主线程可以在解析构建 DOM 时会逐个请求它们,但为了加快速度,预加载扫描器也会同时运行。 如果HTML文档中存在 <img><link> 之类的内容,则预加载扫描器会检查由 HTML解析器 生成的标记,并在浏览器进程中向网络线程发送请求。

图5:主线程解析 HTML 并构建 DOM 树

JavaScript 可以阻止解析

当HTML解析器找到 <script> 标记时,它会暂停解析 HTML 文档,并且必须加载、解析和执行 JavaScript 代码。 JavaScript 可以使用像 document.write() 那样改变整个 DOM 结构的东西来改变文档的形状,而 DOM 结构的改变会导致整个 render 树的重构或回流。

图6:javacript 导致 DOM 重新渲染

提示浏览器如何加载资源

Web 开发人员可以通过多种方式向浏览器发送提示,以便很好地加载资源。 如果你的 JavaScript 不使用 document.write(),则可以向<script>标记添加 asyncdefer 属性。然后,浏览器异步加载和运行 JavaScript 代码,不会阻止解析。 如果合适,你也可以使用 JavaScript Moudle

// lib.mjs
export const repeat = (string) => `${string} ${string}`;
export function shout(string) {
  return `${string.toUpperCase()}!`;
}

// main.mjs
import {repeat, shout} from './lib.mjs';
repeat('hello');
// → 'hello hello'
shout('Modules in action');
<script type="module" src="main.mjs"></script>
<script nomodule src="fallback.js"></script>

带有 module 标识的 script 标签,会被视为一个模块,不会阻塞页面的加载。

图7: script 标签加载示意

<link rel ="preload"> 是一种通知浏览器当前导航肯定需要这个资源的方法,表示你希望尽快下载。

资源优先级

样式计算

拥有 DOM 不足以知道页面的外观,因为我们可以在 CSS 中设置页面元素的样式。 主线程解析 CSS 并确定每个 DOM 节点的样式,得到的信息基于 CSS 选择器将具体的样式应用于每个DOM 节点。 你可以在浏览器中开发者工具中的 computed 中查看。

例如:

.fancy-button {
    background: green;
    border: 3px solid red;
    font-size: 1em;
}

转换成一下数据结构:

图8:浏览器会将诸如 backgroundborder 都转换成普通写法,便于解析

任何维度值都被缩减为三个可能的输出之一: auto 、百分比或像素值

图9: 转换过后的css值

然后计算各个选择器权重

!important > 内联 > ID > 类 > 标签 | 伪类 | 属性选择 > 伪对象 > 通配符 > 继承

图10:权重示意

对于权重相同的样式,后面的样式会覆盖前面的样式。

浏览器的样式除了开发者提供的样式以外,还会有其他来源样式:

  • 用户样式:用户对页面做的缩放等操作。
  • 浏览器样式:浏览器自带样式

其中权限如下:用户样式 > 开发者样式 > 浏览器样式

根据样式的权限有限和权重,把计算好的样式更新到 CSSOM ( CSS Object Model) , CSSOM 位于 document.stylesheets 中。

图11:主线程解析 CSS 以添加计算样式

构建 Render 树

现在,渲染器进程知道每个节点的文档和样式的结构,但这还不足以渲染出一个页面。

布局是查找元素几何的过程。 主线程遍历 DOMcomputed 样式,并创建布局树,其中包含诸如 x y 坐标和 marginborderpaddingcontent 等信息。 布局树可以是与 DOM 树类似的结构,但它仅包含与页面上可见内容相关的信息。

  • Render 树上的每一个节点被称为:RenderObject。
  • RenderObject 跟 DOM 节点几乎是一一对应的,当一个可见的 DOM 节点被添加到 DOM 树上时,内核就会为它生成对应的 RenderOject 添加到 Render 树上。
  • 其中,可见的DOM节点不包括:
    • 一些不会体现在渲染输出中的节点(<html><script><link>….),会直接被忽略掉。
    • display: none 的节点
  • 包括 visibility: hidden 的元素
  • 包括伪类元素,如 ::before

图12:主线程遍历具有计算样式的 DOM 树并生成布局树

浏览器渲染引擎并不是直接使用 Render 树进行绘制,为了方便处理 PositioningClippingOverflow-scrollCSS Transfrom/Opacrity/Animation/FilterMask or ReflectionZ-indexing等属性,浏览器需要生成另外一棵树:Layer

浏览器会为一些特定的 RenderObject 生成对应的 RenderLayer,其中的规则是:

  • 是否是页面的根节点 It’s the root object for the page
  • 是否有css的一些布局属性(relative absolute or a transform) It has explicit CSS position properties (relative, absolute or a transform)
  • 是否透明 It is transparent
  • 是否有溢出 Has overflow, an alpha mask or reflection
  • 是否有 css 滤镜 Has a CSS filter
  • 是否包含一个 canvas 元素使得节点拥有视图上下文 Corresponds to canvas element that has a 3D (WebGL) context or an accelerated 2D context
  • 是否包含一个 video 元素 Corresponds to a video element

当满足上面其中一个条件时,这个 RrenderObject 就会被浏览器选中生成对应的 RenderLayer。至于那些没有选中的 RenderObject,会从属于父节点的 RenderLayer。最终,每个 RrenderObject t都会直接或者间接的属于一个 RenderLayer

浏览器渲染引擎在布局和渲染时会遍历整个 Layer 树,访问每一个 RenderLayer ,再遍历从属于这个 RenderLayerRrenderObject,将每一个 RenderObject 绘制出来。

可以理解为:Layer 树决定了网页绘制的层次顺序,而从属于 RenderLayerRrenderObject 决定了这个 Layer 的内容,所有的 RenderLayerRrenderObject 一起就决定了网页在屏幕上最终呈现出来的内容。

图13:主线程生通过遍历布局树来成层树

布局和绘制

当浏览器生成渲染树以后,就会根据渲染树来进行布局(也可以叫做回流)。这一阶段浏览器要做的事情是要弄清楚各个节点在页面中的确切位置和大小。通常这一行为也被称为“自动重排”。

主线程遍历布局树以创建绘制记录。 绘制记录是绘制过程的一个注释,如“背景优先,然后是文本,最后是box”。

布局流程的输出是一个“盒模型”,它会精确地捕获每个元素在视口内的确切位置和尺寸,所有相对测量值都将转换为屏幕上的绝对像素。

图14:主线程遍历布局树并生成绘制记录

布局完成后,浏览器会立即发出 “Paint Setup” 和 “Paint” 事件,将渲染树转换成屏幕上的像素。

更新渲染通道的成本很高

在渲染过程中最重要的一件事就是:在每个步骤中,前一个操作的结果被用于创建新数据。 例如:如果布局树中的某些内容发生更改,会使 DOM 产生重绘。

图15:DOM + Style,布局和绘制树的生成顺序

如果要制作动画元素,则浏览器必须在每个帧之间运行这些操作。 我们的大多数显示器每秒刷新屏幕60次(60 fps); 当你在每一帧内使页面发生变化时,动画对人眼来说会很平滑。 但是如果动画错过了其中的帧,则页面将发生闪烁或者卡顿。

图16:时间轴上的动画帧

即使你的渲染操作能够跟上屏幕刷新,这些计算也是在主线程上运行的,这意味着当你的应用运行 JavaScript 时它可能会被阻止。

图17:时间轴上的动画帧,但JavaScript阻止了一帧

你可以将JavaScript操作划分为小块,并使用 requestAnimationFrame()安排在每个帧上运行。 你也可以在 Web Workers 中运行 JavaScript 来避免阻塞主线程。

图18:在动画帧的时间轴上运行的较小的JavaScript块

合成

你会如何绘制一个页面?

现在浏览器知道 DOM 的结构,每个元素的样式,页面的几何形状和绘制顺序,它是如何绘制页面的?

将这些信息转换成到屏幕的像素被称为光栅化。

图19:简单光栅化过程

处理光栅化的一种简单的方法是先在视口(viewport)内部使用栅格化部分画面。 如果用户滚动页面,则移动光栅窗口,并通过光栅化填充缺少的部分。 这就是 Chrome 首次发布时处理栅格化的方式。 但是,现代浏览器运行一个称为合成的更复杂的过程。

什么是合成

合成是一种将页面的各个部分分成若干层,分别对其进行栅格化并在独立的合成器线程进行页面喝成的技术。 如果发生滚动,则由于图层已经被栅格化,所以它所要做的就是合成一个新帧:通过移动图层并合成新的帧来以实现动画。

图20:合成过程的示意动画

你可以使用浏览器开发者工具的“layout”面板中查看你的网站如何划分为多个图层

分为几层

为了找出哪些元素需要放在哪些层中,主线程通过遍历布局树以创建层树

图21: 页面分层示例

使页面分层的常见情况:

  • 3D transformstranslate3d、translateZ
  • video、canvas、iframe 等元素
  • 通过 Element.animate() 实现的 opacity 动画转换
  • 通过 СSS 动画实现的 opacity 动画转换
  • position: fixed
  • 具有 will-change 属性
  • opacitytransformfliterbackdropfilter 应用了 animation 或者 transition

也许你想要为每个元素提供图层,但是过多的图层进行合成可能会导致比每帧光栅化页面的小部分更慢的操作,因此测量应用程序的渲染性能至关重要。

光栅和复合关闭主线程

一旦创建了层树并确定了绘制顺序,主线程就会将该信息提交给合成器线程。 合成器线程然后栅格化每个图层。 一个图层可能像页面的整个长度一样大,因此合成器线程将它们划分为图块,并将每个图块发送给栅格线程。 栅格线程栅格化每一个图块并将它们存储在 GPU 内存中。

图22:栅格线程创建 tile 位图,并发送到 GPU

合成器线程可以优先处理不同的栅格线程,以便可以首先对视口(或附近)中的事物进行栅格化。 图层还具有用于不同分辨率的多个分块,以处理诸如放大动作之类的事情。 栅格化后,合成器线程会收集称为“绘制矩形”的图块信息以创建合成帧。

名词 解释
绘制矩形 包含诸如图块在内存中的位置以及在考虑页面合成的情况下在图块中绘制图块的位置之类的信息
合成帧 表示页面框架的绘制四边形的集合

然后通过 IPC 将合成帧提交给浏览器进程。此时,可以从 UI 线程添加另一个合成帧以用于浏览器UI更改,或者从其他渲染器进程添加扩展。 这些合成器帧被发送到 GPU 用来在屏幕上显示。 如果发生滚动事件,合成器线程也会创建另一个合成帧并发送到 GPU。

图23:合成器线程创建合成帧。

帧先被发送到浏览器进程,然后再发送到 GPU 合成的好处是它无需涉及主线程即可完成。 合成线程不需要等待样式计算或 JavaScript 执行。 这就是合成动画是平滑性能的最佳选择的原因。 如果需要再次计算布局或绘图,则必须涉及主线程。

问题