现代 CSS

初探 CSS 渲染引擎

特别声明:如果您喜欢小站的内容,可以点击申请会员进行全站阅读。如果您对付费阅读有任何建议或想法,欢迎发送邮件至: airenliao@gmail.com!或添加QQ:874472854(^_^)

最近一直在探索 Web 渲染性能相关技术,这也是自己一直比较弱的地方。虽然和渲染中的 Style 常打交到,但并未触及浏览器和渲染底层相关的知识。最近饿补了一下这方面的知识。整理了一些有关于 CSS 渲染引擎相关的文章。文章内容较长,图也多。但主要分为三个部分:浏览器理论基础、Web页面解析 和 CSS 渲染引擎。

先从浏览器构成开始!

浏览器理论基础

浏览器构成

欲要对 CSS 渲染有所了解,就有必要对浏览器有一定的了解。在网上有很多介绍 浏览器构成和原理相关的文章。比如:

对于浏览器的构成,可以用下图来描述:

  • User Interface(用户界面):包括浏览器中可见的地址输入框、浏览器前进返回按钮、书签、历史记录等用户可操作的功能选项
  • Browser Engine(浏览器引擎):可以在用户界面和渲染引擎之间传递指令或在客户端本地缓存中读写数据,是浏览器各个部分之间相互通信的核心
  • Rendering Engine(渲染引擎):解析 DOM 文档和 CSS 规则并将内容排版到浏览器中显示有样式的界面,也就是排版引擎,我们常说的浏览器内核主要指的就是渲染引擎
  • Networking(网络功能模块):是游览器开启网络线程发送请求以及下载资源的模块
  • JavaScript Interpreter(JS引擎):解释和执行 JS 脚本部分,例如 V8 引擎
  • UI Backend(UI后端):用于绘制基本的浏览器窗口内控件,比如组合选择框、按钮、输入框等
  • Data Persistence(数据持久化存储):涉及 Cookie,LocalStorage 等一些客户端存储技术,可以通过浏览器引擎提供的 API 进行调用

浏览器进程

下图描述了 Chrome 浏览器中四种进程的位置和作用:

  • Browser Process(浏览器进程):负责浏览器的 Tab (标签选项栏)的前进、后退、地址栏、书签栏的工作和处理浏览器的一些不可见的底层操作,比如网络请求和文件访问
  • Renderer Process(渲染进程):负责一个 Tab选项内的显示相关的工作,也被称为渲染引擎
  • Plugin Process(插件进程):负责控制网页使用到的插件
  • GPU Process(GPU进程):负责处理整个应用程序的 GPU 任务

渲染进程核心目的就是将 HTML 、 JavaScripit 和 CSS 代码转化为用户可以进行交互的 Web 页面。

渲染进程包含的线程主要有:

  • 一个主线程(Main Thread)
  • 多个工作线程(Work Thread)
  • 一个合成器线程(Compositor Thread)
  • 多个光栅化线程(Raster Thread)

不同的线程,有着不同的工作职责。在渲染器进程中,主线程(Main Thread)负责处理你编写的大部分代码。但如果你用了 Web Worker 或 Service Worker,这些 JavaScript 将由 Worker 线程处理。合成器线程(Compositor Thread)和光栅化线程(Raster Thread)用来保证高效、流畅地渲染页面。

具体有这些过程:构建 DOM 树、CSSOM、布局阶段、分层、绘制、分块、光栅化和合成:

  • 渲染进程将 HTML 内容转换为 浏览器可以理解的 DOM 树
  • 渲染进程将 CSS 样式规则转化为 CSSOM ,计算出 DOM 节点样式,这两个过程是半行的
  • 创建 布局树(Layout Tree),计算布局信息
  • 对布局树进行分层,生成 分层树(Layer Tree)
  • 每个图层生成 绘制列表,并提交到 合成线程(Compositor Thread)
    • ○ 绘制列表是记录绘制指令的列表,比如每个元素的背景、边框都是一条单独的指令
  • 合成线程将图层分成 图块,并在光栅化线程(Raster Thread)池 中将“图块转化为位图”(栅格化)
    • ○ 图块:把整个浏览器分成小块,方便浏览器先加载(可视区)
    • ○ 位图:也叫栅格图像,是由像素的单个点组成
    • ○ 这个过程会使用 GPU 加速
  • 合成线程发送绘制图块指令给浏览器进程
  • 浏览器进程根据指令生成页面,并显示到显示器上

渲染引擎

渲染引擎的职责就是渲染 ,即 在浏览器窗口中显示所请求的内容。默认情况下,渲染引擎可以显示 HTML、XML 文档及图片,它也可以借助插件(一种浏览器扩展)显示其他类型数据,例如使用 PDF 阅读器插件,可以显示 PDF 格式。渲染引擎最主要的用途是显示应用了 CSS 之后的 HTML 及图片。

简单地说,渲染引擎将解析 HTML、XML 文档(DOM结构)和 CSS 规则,并将内容排版到浏览器中显示有要式的界面,也就是排版引擎:

在这里我们只关注两条主线:

  • DOM树:HTML Parser 将 HTML 生成 DOM 树
  • CSSOM树:CSS Parser 将 CSS 的样式规则(Style Rules)生成 CSSOM 树

DOM树和CSSOM树结合在一起会构建出一个新的树,即 Render Tree(渲染树),渲染树结合 Layout 绘制在屏幕上,从而展现出来。

这个过程也被称关键渲染路径(Critical Rendering Path)。

关于渲染方面的性能的优化,我们都是在这个路径上进行和完成的。因为页面加载速度中的关键渲染路径决定了“首屏渲染速度”。

渲染引擎工作流程

上图所示是渲染引擎的渲染流程示意图,其以 HTML、JavaScript 和 CSS 等文件作为输入,以可视化内容作为输出。

  • Parsing HTML to Construct DOM Tree:渲染引擎使用 HTML 解析器(HTML Parser)解析 HTML 文档,将各个 HTML 元素逐个转化成 DOM 节点,从而生成 DOM 树(DOM Tree)。如果是 XML 文档将会调用 XML 解析器。同时,渲染引擎使用 CSS 解析器(CSS Parser)解析外部 CSS 文件以及 HTML (或 XML)元素中的样式规则(Style Rules)。元素中带有视觉指令的样式规则将用于下一步,以创建另一棵树结构,即 渲染树(Render Tree)。
  • Render Tree Construction:渲染引擎使用第一步 CSS 解析器(CSS Parser)解析得到的样式规则,将其附到 DOM 树上,从而构建成渲染树。渲染树包含多个带有视觉属性(如颜色和尺寸)的矩形。这些矩形的排列顺序就是它们将在屏幕上显示的顺序
  • Layout of Render Tree:渲染树构建完成之后,进入本阶段进行“布局”,也就是为每个节点分配一个应用出现在屏幕上的切确坐标。
  • Painting Render Tree:渲染引擎将遍历渲染树,并调用显示后端将每个节点绘制出来。

渲染引擎组成模块

下图所示为渲染引擎工作流程中各个步骤所对应的模块,其中第一步和第二步涉及到多个模块,并且耦合程度较度。这样的设计会为了达到更好的用户体验,渲染引擎尽快将内容显示在屏幕上。它不必等到整个 HTML 文档解析完毕之后,就可以开始渲染树构建和布局设置。在不断接收和处理来自网格的其余内容的同时,渲染引擎会将部分内容解析并显示出来。

从图中可以看出,渲染引擎主要包含(或调用)的模块有:

  • HTML 或 XML 解析器(HTML Parser):解析 HTML (XML)文档,主要作用是将 HTML (或 XML)文档转换成 DOM 树(DOM Tree)
  • CSS 解析器(CSS Parser):将 DOM 中的各个元素对象进行计算,获取样式信息,用于渲染树的构建
  • JavaScript 引擎(JavaScript Interperter):使用 JS 可以修改 HTML的 DOM结构、内容 和 CSS 规则等。JS引擎能够解释 JS 脚本代码,并通过 DOM API 和 CSSOM API 来修改页面内容、样式规则,从而改变渲染结果
  • 布局(Layout):DOM 创建之后,渲染引擎将其中的元素对象与样式规则进行结合,可以构建渲染树。而局则是针对渲染树,计算其各个元素的大小、位置等布局信息
  • 绘制(Paint):使用图形库将布局计算后的渲染树绘制成可视化的图像结果

Web 页面解析

接下来以 Chrome 浏览器的渲染机制为例,来看 Web页面是如何被渲染引擎解析的。渲染引擎会解析三个东西。

一个是 HTML 或 XML(SVG),HTML 字符串描述了一个页面的结构,渲染引擎会把 HTML 结构字符串解析转换成 DOM 树形结构:

二是 CSS,解析 CSS 会产生 CSS 规则树,它和 DOM 树结构比较像:

三是 JavaScript 脚本,等到 JavaScript 脚本文件加载后,通过 DOM API 和 CSSOM API 来操作 DOM 树和 CSS 规则树:

解析完成后,渲染引擎会通过 DOM 树和 CSS规则树来构建渲染树(Rendering Tree):

  • 渲染树并不等同于 DOM 树,渲染树只会包括需要显示的节点和这些节点的样式信息
  • CSS规则树主要是为了完成匹配并把 CSS 规则附加到渲染树上的每个元素(Element),也就是每个Frame
  • 然后计算每个 Frame 的位置(又叫 Layout 和 Reflow 过程)

最后通过调用操作系统 Native GUI 的 API 绘制。

渲染引擎渲染一个 Web 页面时会从上至下解析文档(HTML 文档):

以上这些模块依赖很多其他的基础模块,包括要使用到网格、存储、2D/3D 图像、音频视频解码器和图片解码器等。

渲染引擎遇到:

    1. 遇见 HTML(或 XML)标记:调用 HTML Parser (HTML解析器) 将标记解析为对应的 Token,并构建 DOM树
    1. 遇见<style><link> 标记:调用 CSS Parser(CSS 解析器)处理 CSS 样式规则,并构建 CSSOM 树
    • a. CSS 解析器工作完成之后, 在 DOM树上附加解析后的样式信息,构建 RenderObject 树(渲染树)
    • b. RenderObject 在创建的时候,渲染引擎会构建结构创建 RendLayer,同时构建一个绘图上下文
    • c. 根据绘图上下文,生成最终的图像
    1. 遇见 <script> 标记:调用 JS 引擎处理,使用 DOM API 和 CSSOM API 来操作 DOM 和 CSS 样式规则
    1. 将 DOM 树与 CSSOM树再次合并成渲染树
    1. 根据渲染树来布局,以计算每个节点的几何信息(重排)
    1. 将各个节点绘制到屏幕上(重绘)

接下来,我们针对这个中所经历的重要步骤进行阐述。

构建 DOM

当渲染引擎收到浏览器进程“提交导航”的消息后,便开始接收 HTML 数据,渲染引擎的主线程开始将 HTML 解析为 DOM (Document Object Mode)。

DOM 是 Web 页面在浏览器内部的表示方法,暴露 DOM 数据结构和 API(DOM API)使得 Web 开发人员可以通过 JavaScript 操作页面结构和逻辑。

将 HTML 文档解析为 DOM 遵循 HTML 标准。渲染引擎也会遵守一套步骤将 HTML 文件转换为 DOM 树。宏观上,可以分为几个步骤:

浏览器从磁盘或网络读取 HTML 的原始字节,并根据文件的指定编码(例如 UTF-8)将它们转换成字符串。

在网格中传输的内容其实都是 01 这些字节数据。当浏览器接收到这些字节数据以后,它会将这些字节数据转换为字符串,就是我们写的代码。

将字符串转换成 Token,例如 <html><body> 等。 Token 中会标识出当前 Token 是“开始标签”或是“结束标签”亦或是“文本”等信息。这些 Token 主要用来维护节点与节点之间的关系,例如“title” Token的起始标签和结束标签之间的节点肯定是属于“head”的子节点。

上图给出了节点之间的关系。

事实上,构建 DOM 的过程中,不是等所有 Token 都转换完成后再去生成节点对象,而是一边生成 Token 一边消耗 Token 来生成节点对象。换句话说,每个 Token 被生成后,会立刻消耗这个 Token 创建出来节点对象。注意,带有结束标签标识的 Token 不会创建节点对象。

假设下面这样的一段 HTML 文本(代码):

<html>
    <head>
        <title>Understanding the Critical Rendering Path</title>
        <link rel="stylesheet" href="style.css">
    </head>
    <body>
        <header>
            <h1>Understanding the Critical Rendering Path</h1>
        </header>
        <main>
            <h2>Introduction</h2>
            <p>Lorem ipsum dolor sit amet</p>
        </main>
        <footer>
            <small>Copyright 2017</small>
        </footer>
    </body>
</html>

渲染引擎 会将上面这段 HTML 代码解析成下图这样的一棵 DOM 树:

基本上,每个元素(HTML 的标签元素)都作为它所包含元素的父节点,这个结构是 递归的。

资源加载

在 Web 页面上通常会使用一些外部资源,比如图片、CSS 和 JavaScript 等。这些资源文件需要从网格或缓存加载。主线程可以在解析构建 DOM 时逐个请求它们,但为了加快速度, “预加载扫描器” 同时并行运行。如果一旦发现 HTML 文档中存在诸如 <img><link> 资源,预加载解析器会生成网格请求发送给浏览器进程的网络线程去执行。

在浏览器进行加载时,其实是并行加载所有资源。

对于 CSS 和图片等资源,浏览器加载是 异步的 ,并不会影响到后续的加载,也不会影响 HTML 的解析,不会阻塞 DOM 构建的构建。但解析器在加载 CSS 文件时继续运行,此时会阻塞页面渲染,直到资源加载解析完。

记住,CSS 的加载不会阻塞 HTML 的解析,也不会阻塞 DOM 树构建,但会阻塞页面渲染。(详见阻塞页面渲染部分)

JavaScript文件略有不同,默认情况下,解析器会在加载 JS 文件然后进行解析同时会阻止对 HTML 的解析。也就是说,当 HTML 解析器在解析 HTML标签时遇到 <script> 时,会暂停解析 HTML 文档,并且加载,解析和执行 JavaScript 代码。因为 JavaScript 可以使用 document.write() 操作改变整个 DOM 结构。这就是 HTML 解析器在重新解析 HTML 文档之前必须等待 JavaScript 运行完的原因。

记住,JavaScript 的加载、解析与执行会阻塞 DOM 的构建,也就是说,在构建 DOM 时, HTML 解析器若遇到了 JS,那么它会暂停 DOM 构建,将控制权移交给 JS 引擎,等 JS 引擎运行完毕,浏览器进程再从中断的地方恢复 DOM 构建。

Web 开发人员可以通过多种方式告诉浏览器如何加载资源。如果你的 JavaScript 不使用 document.write() ,你可以添加 asyncdefer 属性到 <script> 标签。然后,浏览器将异步加载和运行 JavaScript 代码,不阻塞解析。

现代浏览器都支持 asyncdefer 两个属性。这两个属性的共同点是不会阻塞解析器继续解析 JS 片段下的 HTML,区别是 async 在脚本下载后(如果 CSSOM 已完成)便会阻塞解析器开始执行,而 defer 要等 Render Tree 完成了才会执行。

优化 JS 的方式就是将对首屏内容起影响的 JS 内容作为内联(Inline)方式放到 HTML 中,而将其他的 JS 文件设置为 async 或者 defer ,且放置在 </body> 之前。

更好的做法是异步无阻塞加载 JS:

  • defer
  • async
  • 动态创建 <script> 标签
  • 使用 XHR 异步请求 JS 代码并注入到页面

更推荐使用 deferasync。如果使用 deferasync 请将 <script> 放到 </head> 前,以便让浏览器更早地发现资源并在后台线程中解析并加载 JS。

<link> 元素的 rel 属性的属性值 preload 能够让你在你的 HTML 页面中 <head> 元素内部书写一些声明式的资源获取请求,可以指明哪些资源是在页面加载完成后即刻需要的。

对于这种即刻需要的资源,你可能希望在页面加载的生命周期的早期阶段就开始获取,在浏览器的主渲染机制介入之前就进行预加载。这一机制使得资源可以更早的得到加载并可用,用更不易阻塞页面的初步渲染,进行提升性能。

如何判断 DOM 树构建完成

我们使用 JavaScript 操作 DOM 或者给 DOM 绑定事件的前提就是 DOM树已构建完成。当 DOM 树构建完成时, document 对象会派发出事件 DOMContentLoaded 来通知 DOM 树已经构建完成。

DOMContentLoaded 代表着 DOM 和 CSSOM 都已经就绪,所有阻塞的 JS 资源都已经执行完毕。如果本身就不存在阻塞的 JS 资源,那么在 DOMInteractive 事件之后就会立即发生 DOMContentLoaded 事件,来通知 DOM树构建完成。

DOMContentLoaded 可以用于我们的 KPI,这个时刻越早代表了我们越早能够有 Render Tree,遇到的阻塞越少。

DOMInteractive 事件也常常被用于衡量页面性能。 它标志了 DOM 已经构建完毕,如果这个时候 CSSOM 也构建完毕且没有阻塞 JS 资源,那么将马上发生 DOMContentLoaded 事件。 相对于 DOMContentLoaded, DOMInteractive 事件主要被阻塞的 JS 资源影响。比较这两者的区别可以给我们一些优化启示。

DOMInteractive 代表了整个 HTML 已经解析完了, DOM 已经构建。正是由于一些阻塞资源的影响, DOMInteractive 事件之前用户可能已经因为首次绘制看到了部分内容(JS阻塞资源影响);而且即便 DOMInteractive 事件已经发生,用户也可能因为 CSSOM 未完成看到的还是白屏(CSS 阻塞资源影响)。

除了 DOMContentLoadedDOMInteractive 事件还有一个 load 事件。load 仅用于检测一个完全加载的页面,当一个资源及其依赖资源已完成加载时,将触发 load 事件。也就是说, 页面的 HTML、CSS 、JavaScript 和图片等资源都已加载完之后才会触发 load 事件。

构建 CSSOM

CSSOM(CSS Object Mode,CSS 对象模型)是浏览器将 CSS 样式规则解析成树形的数据结构。

只有 DOM 不足以绘制页面,我们还需要 CSS 设置页面元素(HTML的DOM元素)的样式,同样,渲染引擎解析了 CSS 样式规则并确定给每个 DOM 节点的样式。这是 基于 CSS 选择器将解析出来的样式应用于对应的节点,构建 CSSOM。

即使你不提供任何 CSS,每个 DOM 节点也都具有样式。比如<h1> 字号显示大于 <h2>,并且他们都定义了边距(margin),这是因为浏览器(客户端)具有默认 CSS 样式表。

渲染引擎构建 CSSOM 的过程和构建 DOM 的过程非常相似,当浏览器接收到一段 CSS 样式规则,浏览器首先要做的是识别出 Token,然后构建节点并生成 CSSOM。

在这一过程中,浏览器会确定每一个节点的样式规则到底是什么,并且这一过程其实是很耗资源的。因为样式你可以自行设置给某个节点,也可以通过继承获得。在这一过程中,浏览器得递归 CSSOM 树,然后确定具体的元素到底是什么样式。

比如,下面这个示例,在 HTML 文档中使用 <link> 标签引入了一个 style.css 文件,对应代码:

<!-- HTML -->
<html>
    <head>
        <title>Understanding the Critical Rendering Path</title>
        <link rel="stylesheet" href="style.css">
    </head>
    <body>
        <header>
            <h1>Understanding the Critical Rendering Path</h1>
        </header>
        <main>
            <h2>Introduction</h2>
            <p>Lorem ipsum dolor sit amet</p>
        </main>
        <footer>
            <small>Copyright 2017</small>
        </footer>
    </body>
</html>

/* CSS: style.css */
body { 
    font-size: 18px; 
}

header { 
    color: plum; 
}

h1 { 
    font-size: 28px; 
}

main { 
    color: firebrick; 
}

h2 { 
    font-size: 20px; 
}

footer { 
    display: none; 
}

渲染引擎构建的 CSSOM 树如下图所示:

CSSOM 与 DOM 的不同之处在于它不能以增量方式构建,因为 CSS 规则由于特定性而可以在各个不同的点相互覆盖。这就是 CSS 阻塞渲染的原因,因为在解析所有 CSS 样式规则并构建 CSSOM之前,浏览器无法知道每个元素在屏幕上的位置。

CSSOM 和 DOM

正常情况下 CSSOM 和 DOM 树的构建是互不干扰的。

浏览器下载 HTML 文件,解析 HTML 文件,从而构建了 DOM 树;在解析 HTML 文件,遇到 <style><link>rel 等于stylesheet)时,将其加入下载队列,继续构建 DOM(CSS不会阻塞 DOM 树的构建)。

虽然说 CSSOM 和 DOM 的构建互不干扰,但并不代表 所有CSS都不影响 DOM 树的构建,即 不被 JavaScript 需要的 CSS 才不会阻塞 DOM 树构建。

我们知道,JavaScript 不只是可以改变 DOM,它还可以更变 CSS 规则(即可以改变 CSSOM)。而不是完整的 CSSOM 是无法使用的,因此 JavaScript 想访问 CSSOM,并对其进行修改,就必须拿到完整的 CSSOM,而JavaScript 的加载、解析和执行都会阻塞 DOM 的构建,这样一来就导致了:“某一时刻 CSSOM 和 DOM 并行下载(解析)着,突然遇到 JavaScript 脚本要运行,那么浏览器只能让 JavaScript 先等一等,然后优先下载和过错成目前为止的 CSSOM 的构建,再执行 JavaScript 脚本,然后才继续进行 DOM的解析”。

也就是说,在 JavaScript 之前的 CSS 样式规则,更为准确的说是被JavaScript 依赖执行的 CSS 样式规则(内联CSS以及JavaScript标签之前的外联CSS),有可能会因为 JavaScript 的影响,间接阻塞 DOM 的构建。

JavaScript之后的 CSS 则不受影响。

来看两个示例。假设 DOM 构建完成需要 1s ,CSSOM 构建也需要 1s ,在 DOM 构建了 0.2s 时发现了一个 <link> (引入样式的)标签,此时完成这个操作需要的时间大概是 1.2s (DOM 和 CSSOM 的构建完成时间):

但 JavaScript 脚本也可以修改 CSS 样式规则,将会影响 CSSOM 最终结果,而不完成的 CSSOM 是不可以被使用的。

再换一个场景,如果在 HTML 文档的中间插入了一段 JavaScript 脚本,在 DOM 树构建中的过程发现了这个 <script> 标签,假设这段 JavaScript 脚本只需要执行 0.0001s ,那么完成这个操作需要的时间就会变成:

那如果我们把 CSS 放到前面, JavaScript 放到最后引入,构建时间会变成:

由此可见,虽然只是插入了一段只运行 0.0001s 的JavaScript 脚本代码,不同的引入时机(不同的引入位置)也会严重影响 DOM 构建速度。

简而言之: 如果在 DOM, CSSOM 和 JavaScript 执行之间引入大量的依赖关系,可能会导致浏览器在处理渲染资源时出现大幅度延迟。

构建渲染树

渲染引擎会根据 HTML 和 CSS 输入构建 DOM 树和 CSSOM 树。不过,这两棵树都是独立的对象,分别网罗文档不同方面的信息:

  • DOM树:描述内容(HTML文档内容)
  • CSSOM树:描述需要对文档应用的 CSS 样式规则

将 DOM 树和 CSSOM 合并在一起,就会构建一棵新的树形结构,即 渲染树(Render Tree):

为了构建渲染树,渲染引擎大致做了下面几件事情:

  • 从 DOM 树的根节点开始遍历每个可见节点
    • ○ 某些节点不可见(例如<script><meta><link> 等),因为它们不会体现在渲染输出中,所以会忽略
    • ○ 某些节点通过 CSS 隐藏,因此在渲染树中也会被忽略,比如上图中的 span 节点就不会出现在渲染树中,因为该节点显式设置了display:none 的 CSS 样式规则
  • 对于每个可见节点,为其找到适配的 CSSOM 规则并应用它们
  • 发射可见节点,连同其内容和计算的样式

记住,渲染树(Render Tree)是衔接浏览器排版引擎和渲染引擎之间的桥梁,它是排版引擎的输出,渲染引擎的输入!

样式计算 ComputedStyle

渲染树构建完成之后,渲染引擎就会为对应的 DOM 元素选择对应的样式信息,这样的一个过程也被称为 样式计算(ComputedStyle)。

样式计算的主要目的是为了计算出 DOM 节点中每个元素的个体样式,这个阶段大致分为三步来完成。

  • 把 CSS 转换为浏览器能够理解的结构:和 HTML 文档一样,渲染引擎也是无法直接理解纯文字的 CSS 样式规则,所以当渲染引擎接收到 CSS 样式规则时,会执行一个转换操作,将 CSS 样式规则转换为渲染引擎可以理解的结构,即 styleSheets
  • 转换样式表中的属性值,使其标准化:CSS 样式规则转化为渲染引擎可以理解的结构之后,就需要对其进行属性值的标准化操作
  • 计算出 DOM 树中每个节点的具体样式:样式的属性已被标准化了之后就需要计算 DOM 树中每个节点的样式属性,会按照 CSS 的 继承规则 和层叠规则 来计算样式的属性
CSS 属性值标准化

用一段简单的 CSS 样式规则来阐述 CSS 属性值标准化:

body {
    font-size: 2em;
}

div {
    font-weight: bold;
}

div {
    color: red;
}

上面 CSS 样式规则中有不同的属性值,比如 2emboldred。这些不同类型的值不容易被渲染引擎理解,所以需要将这些值转换为渲染引擎能理解的、标准化的计算值,这个过程就是属性值标准化:

从上图中可以看到,2em 被解析成了 32pxbold 被解析成了 700red 被解析成了 rgb(255, 0, 0)

高效的样式计算(ComputedStyle)

渲染引擎还有一个非常的策略,在特定情况下,渲染引擎会共享 ComputedStyle,在网页中能共享的标签(HTML 元素)很多,所以能极大的提升执行效率。

如果能共享,就不需要执行匹配算法,执行效率自然就很高。

如果两个或多个元素(Element)的 ComputedStyle 不通过计算可以确认他们相等,那么这些 ComputedStyle 相等的元素(Element)只会计算一次样式,其余的仅仅共享该 ComputedStyle。比如:

<section class="one">
    <p class="desc">One</p>
</section>

<section class="one">
    <p class="desc">two</p>
</section>

需要满足以下几个条件,才能高效的共享 ComputedStyle

  • 标签元素(TagName)和类名(class)属性必须一样
  • 标签元素不能有行内样式,即style 属性指定的样式规则。哪怕 style属性相等,他们也不共享 ComputedStyle
  • 不能使用兄弟(相邻)选择器(Sibling Selector)
  • mappedAttribute 必须相等

布局

现在,渲染引擎知道每个节点的样式和结构,但还不足以绘制页面。想象一下,你正在通过电话向朋友描述一幅画:"有一个大的红色圆圈和一个小的蓝色方块",但并不足以让你的朋友了解这幅画的外观:

也就是说,虽然现在有了完整的渲染树,渲染引擎也知道了要渲染什么,但是渲染引擎并不知道在哪里渲染。因为渲染引擎并不知道 DOM 元素的几何位置信息(即 每个节点的位置和大小),所以渲染引擎就需要计算出 DOM 树中可见元素的几何位置(即 每个节点的位置和大小),这个计算过程叫作 布局,也被称为 自动重排。

简单地说,布局就是查找元素几何位置的过程。渲染引擎会遍历 DOM 并计算样式,创建 布局树(Layout Tree),其中包含 xy 坐标和盒子大小等信息。布局树可以是与 DOM 树类似的结构,但它仅包含与页面上可见内容相关的信息。

为了构建布局树,渲染引擎大致完成了下面这些工作:

  • 遍历 DOM 树中的所有的可见节点,并把这些节点添加到布局树中
  • DOM树中不可见节点会被布局树忽略

我们来看一个简单的实例:

<!DOCTYPE html>
<html>
    <head>
        <meta name="viewport" content="width=device-width,initial-scale=1">
        <title>Critial Path: Hello world!</title>
    </head>
    <body>
        <div style="width: 50%">
            <div style="width: 50%">Hello world!</div>
        </div>
    </body>
</html>

HTML 的正文(<body></body>)两个嵌套的 <div> ,且都使用style 属性设置了 width: 50% 样式规则,那么:

  • 第一个<div> (父)将节点的显示尺寸设置为视窗宽度的50%
  • 第二个<div> (父div 包含的那个div)将其宽度设置为其父元素宽度的50%,相当于将节点的显示尺寸设置为视窗宽度的 25%

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

事实上,确定页面布局是一项具有挑战性的任务。即使是最简单从上到下块流的页面布局,也必须考虑字体的大小以及在哪里换行、分割,因为它们会影响段落的大小和形状,然后影响下一段所在的位置。

绘制Paint

有了 DOM、样式和布局仍然无法让渲染引擎把页面绘制出来。假设你正在尝试着绘制一幅画。你知道元素的大小、位置和形状,但你仍需要判断绘制它们的顺序。

例如,某些元素可能设置 z-index ,在这种情况下,按 HTML 中的元素顺序绘制将导致不正确的图层顺序。

在此绘制步骤中,渲染引擎会遍历布局树以创建绘制记录。绘制记录是一个绘画过程的注释,如“背景优先,然后是文本,然后是矩形”。如果你在 <canvas> 中使用 JavaScript 绘制元素,那么你可能会对此过程很熟悉。

更新修改渲染过程的成本是昂贵的

渲染过程中最重要的是在每个步骤中,前一个操作的结果用于创建新数据。例如,如果 布局树(Layout Tree)中的某些内容发生更改,则需要为文档受影响的部分重新生成绘制逻辑。

如果要为元素设置动画,则浏览器必须在每个帧之间运行这些操作。我们的大多数显示器每秒刷新 60 次(即 60fps),对人眼来说会很平滑。但是,如果动画丢失某些中间帧,则页面将显得比较卡顿。

即使渲染速度跟得上屏幕刷新频率,但这些计算在主线程上运算,这意味着当你的应用程序运行 JavaScript 时也可能会阻塞动画。

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

合成Composite

现在浏览器知道了文档的结构,每个元素的样式,页面的几何形状位置和绘制顺序,那将如何绘制页面呢?将这些原始信息转化为屏幕上像素的过程称 光栅化(Raster)。

也许处理这种情况的一种原始的方法是在视窗内部使用栅格部件。如果用户滚动页面,则称动光栅位置,并通过更多光栅填充缺少的部分。这就是 Chrome 首次发布时处理栅格化的方式。但是,现代浏览器运行了一个称为 合成器(Compositing) 的更复杂的计算逻辑。

什么是合成

合成是一种将页面的各个部分分层,分别栅格化,并在一个名为合成器线程的单独线程中合成为页面的技术。如果发生滚动,由于图层已经光栅化,因此它所要做的就是合成一个新帧。通过移动图层和合成新帧可以实现动画。

你可以在开发者工具使用“图层”面板查看你的网站是如何划分为多个图层的。

有些浏览器(比如 Microsoft Edge )还可以开启 3D 图层来查看

图层划分(Layer)

为了找出哪些元素要在哪些层中,主线程在遍历 布局树(Layout Tree)以创建 图层树(Layer Tree)。这个部分在开发者工具性能面板中称为“更新层树”。如果页面的某些部分应该是单独的图层(如滑入式侧边菜单)但是没有绘制单独图层,那么你可以使用 CSS 的 will-change 属性提示浏览器。

你可能想要为每个元素提供图层,但是对于过多图层进行合成可能会导致比每帧光栅化页面的小部分更慢的操作。

将元素提升为图层(Layer)往往有助于性能,这也可能会诱使开发者无节制将页面元素提升为层。

* {
    will-change: transform;
    transform: translateZ(0);
}

上面的代码 虽能把页面所有元素提升图层。但问题是你创建的每一层都需要内存和管理,而这些并不是免费的。事实上,在内存有限的设备上,对性能的影响可能远远超过创建层带来的任何好处。每一层的纹理都需要上传到 GPU,使 GPU 与 GPU 之间的带宽、GPU上可用纹理处理的内存都受到进一步限制。

警告:如无必要,请勿将元素提升为图层!

渲染引擎会将 “拥有层叠上下文属性的元素” 和 “需要剪裁的地方” 提升为图层。简单地说:

CSS 中触发 z-index 生效的属性都会将 元素提升为图层!

比如下图所展示的就是布局树和 图层树(LayerTree)关系示意图:

光栅、合成器与主线程

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

栅格化(Raster) 操作是指将图块转换为位图。

合成器线程可以考虑不同的光栅线程,以便当前视窗(或附近)内的事物可以先被光栅化。图层还具有多个不同分辨率的图块,可以处理放大操作等功能。

一旦图块被光栅化,合成器线程会收集图块信息称为 Draw Quads 并创建合成器帧。

  • Draw Quads 包含诸如图块在内存中的位置及页面合成的情况下绘制块的页面中的位置等信息
  • 合成器帧表示页面一帧的绘制所需四边形的集合

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

合成的好处是它可以在不干涉主线程的情况下完成。合成器线程不需要等待样式计算或 JavaScript 执行。这就是为什么合成动画被认为是性能友好的最佳选择,因为它可以完全在合成器线程内完成。如果需要再次计算布局或绘图,则必须涉及主线程。

Web 页面渲染过程拆解

上一部分有关于 Web 页面解析(渲染)太过于理论化,对于我们平时工作而言,我们只需要了解并关注其中五个主要区域。因为这几个部分是我们可以控制的,也是像素至屏幕管道中的关键点:

  • JavaScript:一般来说,我们会使用 JavaScript 对 DOM 进行操作(比如对 DOM的增删改查,以及给 DOM 元素添加新的 CSS 样式规则),从而改变视觉上的效果。当然,除了 JavaScript ,还有一些常用的方法也会改变页面的视觉效果,比如 CSS 的 Animation、Transition 和 Web Animation API等
  • Style(计算样式):此过程会根据匹配的 CSS 选择器计算出哪些 DOM 元素应用哪些 CSS 样式规则。从中知道规则后,将应用规则并计算每个元素的最终样式
  • Layout(布局):在知道对一个元素应用哪些 CSS 样式规则之后,浏览器即开始计算它要占据的空间大小及其在屏幕上的位置。网页的布局模式意味着一个元素可能影响其他元素,例如 <body> 元素的宽度一般会影响其子元素的宽度以及树中各处的节点,因此对于浏览器来说,布局过程是经常发生的
  • Paint(绘制):绘制是填充像素的过程,它涉及绘出文本、颜色、图像、边框和阴影,基本上包括元素的每个可视部分。绘制一般是在多个表面(通常称为层)上完成的
  • Composite(合成):由于页面的各部分可能被绘制到多层,因此它们需要按正确顺序绘制到屏幕上,以便正确渲染页面。对于与另一重叠元素来说,这点特别重要,因为一个错误可能使一个元素错误出现在另一个元素的上层

上面的每个过程都有产生掉帧(从而引起卡顿)的问题,因此一定要弄清我们的代码将会运行在哪一步(触发了哪个管道)。

有时候你可能听到与绘制一起使用的术语 “栅格化”。这是因为绘制实际上分为两个任务:

  • 创建绘图调用的列表
  • 填充像素

填充像素 称为“栅格化”,因此在开发者工具中可以看到绘制记录时,就应当将其视为包括栅格化。

注意,在某些架构下,绘图调用的列表创建以及栅格化是在不同的线程中完成的,但这不是开发者所能控制的

但我们写的代码不一定每帧都总是会经过管道每个部分。实际上,不管是使用 JavaScript、 CSS 还是 Web 动画(Web Animation API),在实现视觉变化时,管道针对指定帧的运行通常有三种方式。

JS/CSS » Style » Layout » Paint » Composite

如果你在代码中修改元素的 Layout(布局)属性,也就是改变了元素的几何属性(例如宽度、高度、位置等),那么浏览器将必须检查所有其他元素,然后“重排”页面。任何受影响的部分都要重新绘制,而且最终绘制的元素需要进行合成。

注意:改变布局属性会引起重绘和重排!

重排是非常昂贵的,但不幸的是,它可以很容易被触发。(详见重排和重排部分)

JS/CSS » Style » Paint » Composite

如果你仅修改“绘制”相关的属性(比如背景图片、颜色等),这些属性不会影响页的“布局”,浏览器会跳过布局(Layout)管道,但仍将执行绘制。最终绘制的元素需要重新进行合成。

注意:改变绘制属性会引起重绘

另外,重排必将引起重绘,但重绘不会引起重排!(详见重排和重排部分)

JS/CSS » Style » Composite

如果你更改一个既不要布局也不要绘制的属性,则浏览器将跳到只执行合成。这种方式是开销最小,对动画和滚动这种负荷很重的渲染,我们要争取使用这种渲染流程。

注意,从渲染引擎的角度来看,我们在编码的时候,应该尽可能的少改变布局和绘制相关的属性,尽可能避开布局和绘制两个管道,从而提高页面的性能。简单地说,尽可能避免重排和重绘。

CSS 渲染引擎

我们再花点时间来看看 CSS 引擎相关的内容。

CSS引擎功能是什么?

CSS 引擎仅是浏览器渲染引擎的一部分。渲染引擎负责解析 Web 的 HTML 文档和 CSS 文件,并将他们转换成显示在屏幕上的像素(点)。

每个浏览器都有一个渲染引擎,为了把文档转换屏幕上的像素,渲染引擎基本上都会做。

将文档解析为浏览器可以理解的结构,即 DOM 树。在这个阶段, DOM 知道了页面的结构,也知道了各个元素之间的关系,但这个时候并不知道这些元素应该是什么模样。

搞懂这些元素应该是什么模样(使用什么样式)。此时,针对每个 DOM 节点,CSS引擎都会一一确定哪些 CSS 样式规则适用,然后再为该 DOM 节点的每个 CSS 属性设定一个值(即属性值)。

弄清每个节点的尺寸和它在屏幕上的位置。为每个将在屏幕上显示的东西创建盒子。这些盒子不只是代表 DOM 节点,DOM 节点内的东西(比如文本)也可以是个盒子(匿名盒子)。

绘制不同的盒子。这可能会有多层的情况,好比早期的手绘卡通,要用好几层素描纸来完成。这样就可以只改变一个层,而不必在其他层上重绘盒子。

取得不同的图层叠 ,运用只有合成器的属性(比如 transform),将他们合成一个图像。这个过程基本上就是把各层叠 在一起组合成图片一般。之后,该图像将被渲染到屏幕上。

也就是说,当 CSS 引擎开始计算样式规则时,CSS 引擎手上有两样东西:

  • 一棵DOM 树
  • 一份样式规则的列表

它会逐一遍历每个 DOM 节点,并计算出各个 DOM 节点样式规则。在此过程中,它还会为 DOM 节点的各个 CSS 属性提供值(即使样式表中并未提供该属性的值)。就好比有一个人要填表格一样。他必须给每个 DOM 节点填一份表格,且表格上的所有单元格都不能是空的。

要填好所有的表格, CSS 引擎必须做两件事:

  • 找出哪些规则(CSS 样式规则)用于哪些节点(DOM 节点),也就是要做选择器配对(选择器匹配,即 通过 CSS 选择器找到对应的 DOM 节点)。注意, CSS 选择器无法和 DOM 中的文本节点(匿名盒子)相匹配。
  • 使用父级或预设值来填补缺少的值(即 CSS 中的层叠和继承)

注意,这里涉及 CSS 中几个非常重要的概念和核心:选择器权重、继承和层叠!

CSS 语法规则的解析

渲染引擎在解析一个 Web 页面时,HTML Parser 会把 HTML 生成 DOM 树,而 CSS Parser 会将 CSS 解析结构附加到 DOM 树上:

而 CSS 有自己的一套规则:

浏览器渲染引擎会使用自己的解析器将 CSS 文件解析成 StyleSheet 对象,且每个对象都包含 CSS 规则。CSS 规则对象则包含了 CSS选择器 和 声明对象(属性/值对),以及其他与 CSS 语法对应的对象。

CSS 解析过程会按照 规则(Rule)和 声明(Declaration) 来操作:

尝试着在开发者工具的控制台把 cssRules 打印出来:

document.styleSheets[0].cssRules

你会看到:

如果不再进一步探究 浏览器内核是怎么解析 CSS 的话,我们只需要知道 CSS 依赖 WebCore 来解析,而 WebCore 又是 Webkit 是一个重要模块(不同内核的浏览器可能略有不同):

如上图所示,“CSS仅是Webkit的WebCore的一部分”而以。

要了解 WebCore 更核心的部分(如何解析CSS),那就需要阅读相关源码

CSS 选择器的解析

渲染引擎解析 CSS 选择器是从右往左解析。我们知道 DOM 树和 CSSOM 树(CSS 样式规则)合在一起构建渲染树(Render Tree),实际上是需要将CSS样式规则附到 DOM 树上。因此需要根据 CSS 选择器提供的信息对 DOM 树进行遍历,才有将样式附到对应的 DOM 节点(元素)上。比如我们有下面这样的一段 CSS :

.mod-nav h3 span {
    font-size: 16px;
}

对应的 DOM 树:

若从左向右的匹配,其过程是:

  • .mod-nav 开始,遍历子节点 header 和 子节点 div
  • 然后各自向子节点遍历,在右侧 div 的分支中,最后遍历到叶子节点 a ,发现不符合规则
  • 所以需要回溯到 ul 节点,再遍历下一个li-a ,然后去搜索下个节点,重复这样的过程

这样的搜索过程对于一个只是匹配很少节点的选择器来说,效率是极低的,因为我们花费了大量的时间在回溯匹配不符合规则的节点。

再换从右至左的匹配,其过程是:

  • 先找到所有的最右节点 span ,对于每一个 span ,向上寻找节点 h3
  • h3 再向上寻找 .mod-nav 的节点
  • 最后找到根元素 html 则结束这个分支的遍历

两者对比下来,可以明显的发现后者匹配性能更好,是因为从右向左的匹配在第一步就筛选掉了大量的不符合条件的最右节点(叶子节点),而从左向右的匹配规则的性能都浪费在了失败的查找上面。这样我们就可以得到结论:

渲染引擎 CSS 匹配核心算法的规则是以从右向左方式匹配节点的。

这样做是为了减少无效匹配次数,从而匹配快,性能更优。所以我们在写 CSS 选择器时,从右到左的选择器匹配的节点越少越好。另外,不同 CSS 解析器对 CSS Rules(CSS规则)解析速度差异也很大。

简而言之,CSS 选择器的层级或使用对渲染性能是有影响的。

选择器配对

了解了 CSS 选择器如何解析之后,再来看 CSS 选择器的配对。针对这个步骤,我们将把与 DOM 节点匹配的所有规则都加入到清单。因为 多个规则 可达成配对,所以同一个属性可能会有多个声明(在不同的地方声明):

此外,浏览器本身还会加入一些预设的 CSS 规则(用户代理样式表)。此时需要借助 CSS 选择器权重来判断应该选择哪个值:

CSS 引擎基本上会产生一个试算表,并将声明排入不同的列中:

根据选择器权重计算公式,分数越高权重越大,对应的选择器就胜出。因此,根据此试算表, CSS 引擎会先填入它能填的值。

至于其他的部分,将会使用到CSS的级联(Casade),也被称为层叠!

CSS 的级联

CSS的级联让 CSS 变得易于编写和维护。通过 CSS 的级联,你可以在 <body> 上设置color 属性,并且知道 pspanli 元素中的文本都会使用该颜色(除非有一个更具体的规则覆盖)。

要做到这一点, CSS 引擎会查看其表单上的空白框。如果该属性是默认继承的(CSS中有些属性是可继承的),那么 CSS 引擎就会沿着树干(DOM树)向上走,确认是否其中有一个祖先(Ancestor)有值。如果没有一个祖先有值,或者该属性不被继承,CSS 引擎便将给予一个预设值。

另外,CSS 级联除了考虑 CSS 属性的继承之外,还会考虑到 CSS 的来源。这个来源包括:

  • 浏览器的内部样式表
  • 由浏览器扩展或操作系统添加的样式表
  • 开发者编写的样式表

这些源的特殊性(权重),从不特殊到最特殊的顺序如下:

  • 用户代理基本样式:这些是你的浏览器默认应用于 HTML 元素上的样式
  • 本地用户样式:这些样式可以来自操作系统层面,例如基本的字号,或者减少动画的偏好设置。它们也可以来自浏览器的扩展,例如,允许用户为网页编写自己的定义的 CSS 的浏览器扩展插件
  • 开发者编写的样式:就是你自己为网页编写的 CSS样式规则
  • 开发者编写的样式中带!important:在你自己编写的CSS规则中带有 !important
  • 本地用户样式中带有 !important:任何来自操作系统或浏览器扩展插件中 CSS样式规则中带有!important
  • 用户代理基本样式中带有!important:任何由浏览器提供的,在默认CSS样式规则中带有 !important

比如下图,描述了 CSS 样式规则是怎么胜出:

到这里, DOM 节点的所有样式都已计算完成。

CSS 样式结构共享

CSS 整个体系有数以百计的属性。如果CSS引擎为每个 DOM节点的每个属性都保留一个值,它很快就会耗尽内存。相反, 引擎通常会做一些叫做样式结构共享(Style Struct Sharing)的事情。它们将经常一块出现的属性(比如字体属性)存储在一个叫做样式结构的不同对象中。于是便不必保存一个 DOM 节点内所有属性,而只要给计算过的样式 DOM 节点提供指针(Pointer)。这样一来,对于每个类别,都有一个指向样式结构的指针,这个样式结构有适合这个DOM节点的值。

这样做可以节省记忆和时间。拥有类似属性的 DOM 节点(如兄弟元素,即相邻DOM节点)可以直接指向它们共享的相同结构。由于许多属性是继承的,祖先可以与没有指定自己重写的任何子孙共享一个结构。

小结

文章中提到的内容大部分是围绕着浏览器的渲染引擎和 CSS渲染引擎的,涉及到的底层核心介绍的并不详细。了解这些对于浏览器渲染相关的构成会有一定的理论基础,可以帮你我们进一步了解渲染相关的知识,以及渲染相关的性能优化。

我自己也是这方面的初学者,如果有不正之处,还请路过的各路大神拍正!

返回顶部