阅读 80

浏览器渲染原理

创建DOM树

DOM(文档对象模型)是 HTML 文档的对象表示,同时也是外部内容(例如 JavaScript)与 HTML 元素之间的接口。

HTML 解析器将 HTML文件解析为 DOM树,DOM 树的根节点是 Document 对象。

DOM 与 HTML 之间几乎是一一对应的关系,但 DOM 是保存在内存中树状结构,可以通过 JavaScript 来查询或修改,HTML 本质上就是字符串。

样式计算

样式计算阶段的目的是为了计算出 DOM 节点中每个元素的具体样式(计算样式)

需要完成的步骤:

  1. 把 CSS 转换为浏览器能够理解的结构,便于后序的查询和修改(可以称其为 CSSOM)

    通过 document.styleSheets 可以得到页面上所有 linkstyle 所定义的样式表(styleSheets 类型)

  2. 将样式表中的属性值标准化,由于 CSS 中的写法多样,所以需要转换为统一的值。

  3. 使用 CSS 规则计算出 DOM 树中每个节点的具体样式,通过 DevTools 的 computed 部分可以看到具体信息

布局

有 DOM 树和 DOM 树中元素的样式,但这还不足以显示页面,因为还不知道 DOM 元素的几何位置信息。那么接下来就需要计算出 DOM 树中可见元素的几何位置,这个计算过程叫做布局,布局是一个寻找元素几何形状的过程。

Chrome 在布局阶段需要完成两个任务:创建布局树和布局计算。

布局树

主线程遍历 DOM 树和每个节点的计算样式创建布局树,其中包含坐标和边界框大小等信息。

布局树可能与 DOM 树的结构相似,但是布局树和 DOM 树中的元素并不一一对应,布局树只包含和页面可见有关的内容。

  • 布局树中不包含非可视化元素和 display: none 的元素,但是包含 visibility: hidden 的元素
  • 具有内容的伪类 p::before{content:"Hi!"} 会包含在布局树中,但是不在 DOM 树中

布局计算

拥有一棵完整的布局树之后就要计算布局树节点的坐标位置,布局的计算过程非常复杂。

布局计算就是读取布局树中的内容,并计算机布局信息重新写入布局树。

在执行布局操作的时候,会把布局运算的结果重新写回布局树中,所以布局树既是输入内容也是输出内容,这是布局阶段一个不合理的地方,因为在布局阶段并没有清晰地将输入内容和输出内容区分开来。针对这个问题,Chrome 团队正在重构布局代码,下一代布局系统叫 LayoutNG,试图更清晰地分离输入和输出,从而让新设计的布局算法更加简单。

渲染树

渲染树是16年之前的东西了,现在的代码完全重构了,可以把布局树看成是渲染树,不过和之前的渲染树还是有差别的。

渲染树是由可视化元素按照其显示顺序而组成的树,也是文档的可视化表示,它的作用是让浏览器按照正确的顺序绘制内容。

Firefox 将渲染树中的元素称为 frames 。WebKit 使用的术语是 rendererrender objectrenderer 知道如何布局并将自身及其子元素绘制出来。

分层

为了提高每一帧的渲染效率,Chrome 引入了分层和合成的机制。

现在的网页中具有很多复杂的效果(例如 3D变换、z-index 以及页面滚动),实际上页面被分成了很多图层,这些图层叠加后合成了最终的页面。

合成的概念

合成是一种将页面的各个部分分成多个层、单独光栅化它们并在合成线程中合成为一个页面的技术。

光栅化: 将元素信息转换为屏幕上的像素,将元素的信息转换为位图。

举个例子就是:如果发生滚动,因为图层已经被光栅化,它所要做的就是合成一个新的帧,动画可以通过移动图层并合成新帧以相同的方式实现。

分层和合成的好处就是无需触发重排重绘,直接合成即可完成动画:

视频无法查看,点击查看

通常情况下,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。但不管怎样,最终每一个节点都会直接或者间接地从属于一个层。

  • 拥有层叠上下文属性的元素会被提升为单独的一层
  • 需要剪裁(clip)的地方也会被创建为图层
  • 滚动条也会被提升为单独的层
  • 通过 will-change 可以告诉浏览器将某个元素提升到单独的层,使用这个可以优化动画
  • 不能滥用分层,在过多的图层上进行合成可能会导致操作更慢

图层

在 Chrome 的 DevTools 的 Layers 中可以很清楚的看到一个网页的分层情况。

图层树

为了找出哪些元素要在哪些层中,主线程遍历布局树以创建图层树,这部分在 DevTools 性能面板中称为“更新层树”。

图层绘制

绘制是填充像素的过程,它涉及绘出文本、颜色、图像、边框和阴影,基本上包括元素的每个可视部分。

图层的绘制分为两个阶段:创建绘图调用的列表、填充像素。

生成绘制列表

渲染进程主线程会把一个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表。

绘制列表中的指令其实非常简单,就是让其执行一个简单的绘制操作,比如绘制粉色矩形或者黑色的线等。而绘制一个元素通常需要好几条绘制指令,因为每个元素的背景、前景、边框都需要单独的指令去绘制。

绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染进程中的合成线程来完成的,一旦创建了层树并确定了绘制顺序,主线程就会将该信息提交给合成线程。

在开发者工具的 Layers 部分可以清楚的看到一个页面的绘制列表:

光栅化

当图层的绘制列表准备好之后,主线程会把该绘制列表提交给合成线程,合成线程会光栅化(根据绘制列表)每一个图层。

一个图层往往很大,合成线程会将图层划分为若干个图块(大小通常是 256x256 或者 512x512),并将每个图块发送到光栅线程,光栅线程会光栅化每个图块并将它们存储到 GPU 内存中。

  • 渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行

  • 合成线程会将视口附近的图块优先执行栅格化

  • 通常栅格化过程都会使用 GPU 来加速生成,使用 GPU 生成位图的过程叫快速栅格化

合成显示

一旦所有图块都被光栅化,合成线程就会收集图块信息(DrawQuad)创建合成帧,合成线程将合成帧通过 IPC 传递给浏览器进程,接着合称帧会被传递到 CPU 中显示到页面上。

如果出现滚动事件,合成器线程会创建另一个合成器帧以发送到 GPU 而无需主线程的参与。

composit

渲染流程概述

一个完整的渲染流程大致可总结为:

  1. 渲染引擎将 HTML 内容转换为 DOM 树
  2. 渲染引擎将 CSS 样式表转化为 StyleSheets,计算出 DOM 节点的样式
  3. 创建布局树,并计算元素的布局信息
  4. 对布局树进行分层,并生成分层树
  5. 为每个图层生成绘制列表,并将其提交到合成线程
  6. 合成线程将图层分成图块,并在栅化线程池中将图块转换成位图
  7. 合成线程将图块合称为合称帧,并发送给 GPU 显示到屏幕上

回流和重绘

重排/回流

通过 JavaScript 或者 CSS 修改元素的几何位置属性,浏览器会触发重新布局之后的一系列阶段,这个过程就叫回流。

回流需要更新完整的渲染流水线,所以开销是最大的。

reflow

重绘

当更新了元素的绘制属性(如:背景颜色),浏览器不会重新布局,直接从绘制阶段开始执行之后的一系列阶段。

直接合成

更改一个既不要布局也不要绘制的属性,渲染引擎将跳过布局和绘制,只执行后续的合成操作,我们把这个过程叫做合成。

使用了 CSS 的 transform 来实现动画效果,这可以避开重排和重绘阶段,直接在非主线程上执行合成动画操作。

因为是在非主线程上合成,并没有占用主线程的资源,另外也避开了布局和绘制两个子阶段,所以相对于重绘和重排,合成能大大提升绘制效率。

减少回流和重绘

  • 使用 transform 替代 top
  • 使用 visibility 替换 display: none ,因为前者只会引起重绘,后者会引发回流
  • 不要把节点的属性值放在一个循环里当成循环里的变量
  • 不要使用 table 布局,可能很小的一个小改动会造成整个 table 的重新布局
  • 动画实现的速度的选择,动画速度越快,回流次数越多,也可以选择使用 requestAnimationFrame
  • CSS 选择符从右往左匹配查找,避免节点层级过多
  • 将频繁重绘或者回流的节点设置为图层,图层能够阻止该节点的渲染行为影响别的节点。比如浏览器会自动将 video 节点变为图层。

页面事件

load

windowload事件:页面中所有资源全部加载完毕之后触发

DCL

documentDOMContentLoaded:DOM树构建完成后发生,且只能采用 DOM2 的方式注册(addEventListener

readystate

  • loading
  • interactive
  • complete

interactive 时DOM树构建完毕,触发 DOMContentLoaded 事件。complete 时页面加载完毕,触发 windowload 事件

脚本和样式表

脚本

当浏览器遇到 <script> 时会立即加载并执行脚本,此时DOM树的构建将暂停,直到该脚本执行完毕。

一般脚本放在 body 的最底部,避免阻塞页面解析。如果脚本中没有操作 DOM 相关代码,就可以将该脚本设置为异步加载。

异步加载方案

  1. defer:要等到 DOM 全部解析完(DCL事件之前)才会被执行,不会阻塞
  2. async:加载完就异步执行,不会阻塞

样式表

解析样式表不会更改 DOM 树,所以请求样式表无需停止文档解析,可以并行处理。

CSS 不会阻塞 DOM树的生成,只有一种情况:

<html>
    <head>
        <style type="text/css" src = "theme.css" />
    </head>
    <body>
        <p>xxxxxxx</p>
        <script>
            const p = document.getElementsByTagName('p')[0]
            p.style.color = 'blue'
        </script>
        <p>xxxxxxx</p>
        <p>xxxxxxx</p>
    </body>
</html>
复制代码

JavaScript中访问了某个元素的样式,当时还没有加载和解析样式,就需要等待样式的加载和解析完毕。所以在这种情况下,CSS也会阻塞DOM的解析。

预解析

网络进程接收数据之后,会和渲染进程之间会建立一个共享数据的管道,网络进程将接收到数据(HTML文件)往这个管道里面放,而渲染进程则从管道的另外一端不断地读取数据,并同时将读取的数据给 HTML 解析器,HTML 解析器动态接收字节流,并将其解析为 DOM。

一般来说,当DOM的解析遇到了脚本会暂停整个 DOM 的解析,加载并执行脚本之后才会继续解析。

不过 Chrome 浏览器做了很多优化,其中一个主要的优化是预解析操作。当渲染线程收到字节流之后,会开启一个预解析线程,用来分析 HTML 文件中包含的 JavaScript、CSS 等相关文件,解析到相关文件之后,会提前下载这些文件。

交互阶段优化

交互阶段优化的原则是:尽量减少一帧的生成时间

  • 减少 JavaScript 脚本占用主线程的事件,可以分解为多个任务
  • 避免强制同步布局,强制同步布局就是提前在执行脚本的过程中提前布局
    • 正常获取 offsetWidth 等属性的值用的是上一帧的缓存值
    • 但是在获取之前先修改 DOM 样式再获取,浏览器也会强行重新布局,因为需要确保这些值是实时的
    • 即使是在 requestAnimationFrame 中也会造成这个后果
  • 避免布局抖动,布局抖动是指在一次 JavaScript 执行过程中,多次执行强制布局和抖动操作
  • 尽量使用 CSS 动画,不占用主线程

参考文章

文章分类
前端
文章标签