浏览器渲染机制剖析

1,558 阅读9分钟

一、前言

作为站酷前端小分队的一员,今天也来突击补充一下关于浏览器渲染的知识。渲染模块在执行过程中会被划分为很多子阶段,从输入 HTML,最后输出像素。我们把这样的一个处理流程叫做渲染流水线:
构建 Dom 树 ---> 样式计算 ---> 生成布局树 ---> 分层 ---> 分块 ---> 光栅化---> 合成 那具体的每一步浏览器是如何做的呢?这就是这篇文章主要探讨的内容。

二、浏览器的多进程架构

浏览器多进程架构是什么样子的? 首先看看早期的单进程浏览器:

2.1 早期的单进程浏览器时代

单进程浏览器是指浏览器的所有功能模块(包括 js 的运行环境,页面渲染,页面展示,插件,网络等)都运行在同一个进程里,这就意味着很有可能带来不稳定,不流畅,不安全等这些问题。其实早在 2007 年之前,市面上浏览器都是单进程的。单进程浏览器的架构如下图所示:

2.2 多进程架构浏览器是如何解决这些问题的?

  1. 多个进程之间是隔离的,所以当一个页面或者插件崩溃后,不会影响其他网页,完美的解决了不稳定的问题。
  2. js 运行在渲染进程中,即使阻塞了渲染,也仅仅是影响的是本页面。关闭标签页该进程所用到的资源和内存都会被系统回收,这样就轻松解决了浏览器页面的内存泄漏问题。
  3. chrome 的渲染进程和插件进程运行在安全沙箱里,即使在渲染进程和插件进程执行了恶意程序,也无法影响到沙箱之外的系统,从而保证安全性。

2.3 目前的 chrome 多进程架构浏览器

包括浏览器一个主进程 ,一个 GPU 进程,一个网络进程,多个渲染进程和多个插件进程。 下面来看看这几个进程的功能:

  1. 浏览器进程:主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。
  2. GPU 进程:负责 UI 的加速渲染。
  3. 渲染进程:主要是将 html,css,js 转换成用户可以交互的网页,默认情况下 chrome 会为每个 Tab 页创建一个渲染进程。渲染进程都运行在沙箱模式下。
  4. 网络进程:负责网络资源的加载。
  5. 插件进程:负责插件的运行,隔离。因为插件容易崩溃,利用插件进程来保证插件进程崩溃不影响到浏览器的其他进程。

三、 构建 Dom 树的过程

这里有 whatwg 定义的 Tokenization。

下面通过简单的 demo 来了解一下生成 DOM 树的过程:

3.1 词法分析,将字节流转换成 token

<html>
  <body>
    <div>1</div>
    <div>test</div>
  </body>
</html>

上面的 html 经过词法分析生成的 token 如下所示:

3.2 将 Token 解析成 Dom 节点,并添加到 DOM 树中

Html 解析器会维护一个 Token 栈,用来模拟节点之间的父子关系。

  1. 首先会给栈中压入一个 doucment 节点来作为根节点。

  2. 如果解析的 Token 为 StartTag,就会为 Token 创建 Dom 节点,建立父子关系(目前的栈顶元素为该节点的父节点)。

  3. 如果解析的 Token 为 Text,就说明应该为 token 创建一个文本节点。

  4. 如果解析到 Token 为 EndTag,判断当前的类型是否和栈顶元素的 Dom 匹配,如果匹配就将该 Dom 出栈,说明该元素解析完成。

  5. 如果在解析期间遇到自封闭标签就将它立即入栈出栈。

  6. 再一直不断的入栈和出栈,dom 的解析完成

四、 样式计算

4.1 把 css 转换成 CSSOM

和 Html 一样,css parser 也会收集所有的 css 规则,将其解析成 CSSOM。
所有的 css

  • 通过 Link 引入的外联 css
  • <style> 标签内的 css
  • 以及元素的行内 css

4.2 预处理样式,使其标准化,例如:

4.3 给 Dom 节点添加对应的 css 规则

  • 在 dom 节点生成的同时,就应该给其添加 css 规则了。
  • 根据 css 的继承规则和层叠规则计算出正确的 css。 此时生成的带样式的 Dom 树:

五、 生成布局树(layout)

布局树顾名思义就是给 Dom 节点添加标识它几何位置的信息(left,top,right,bottom)等属性。 下面以 flex 布局(主轴方向为 x 轴)为例,模拟一下在 flex 布局中如何计算元素的具体位置:
具体的代码看 github 哦!

5.1 收集元素入行

  • 计算主轴方向父容器的尺寸。
  • 循环所有的子元素,依次把元素放入行内,若设置了 flex-wrap =nowrap,则强行将所有的元素分配到第一行,若是 flex-wrap = warp,则按顺序依次放入行内,超出则放入新行。
let flexLine = [] //当前flex行
let flexLines = [flexLine]
// 主轴剩余空间 默认为父元素的尺寸, 是指减掉固定的元素尺寸,剩余的尺寸,有flex 的元素可根据剩余尺寸压缩
let mainSpace = flexContainerStyle[mainSize]
// 交叉轴剩余空间
let crossSpace = 0

// 收集所有的元素
for (let i = 0; i < items.length; i++) {
  const item = items[i]
  const itemStyle = getElementStyle(item)

  if (flexWrap === 'nowrap') {
    mainSpace -= itemStyle[mainSize]
    flexLine.push(item)
    continue
  }

  if (flexWrap === 'wrap') {
    // 换行 warp
    if (itemStyle[mainSize] > flexContainerStyle[mainSize]) {
      // 超出父元素压缩到父元素的尺寸
      itemStyle[mainSize] = flexContainerStyle[mainSize]
    }

    // 元素放不下放入新行
    if (mainSpace < itemStyle[mainSize]) {
      // 保存上一行的mainSpace,crossSpace
      flexLine.mainSpace = mainSpace
      flexLine.crossSpace = crossSpace
      flexLine = [item]
      flexLines.push(flexLine)
      // 重置mainSpace 和crossSpace
      mainSpace = flexContainerStyle[mainSize]
      crossSpace = 0
    } else {
      flexLine.push(item)
    }
    mainSpace -= itemStyle[mainSize]
  }
}

5.2 计算元素在主轴的位置:(left,right)

  • 单行的情况下,若主轴剩余尺寸 mainSpace < 0,为了能放下元素,需要对所有的元素进行等比压缩,如果存在 flex 属性,flex 元素(在这里指 flex = 1)会被挤压为 0。
  • 多行情况下,对每一行都根据剩余空间和总的 flex 值来对各个 flex 元素瓜分剩余空间。
    若元素没有 flex 值,则需要根据 justify-content 来确定元素的位置。

5.3 计算元素在交叉轴的位置:(top,bottom)

  1. 根据 align-content 属性,所有行在交叉轴上的排列位置
  2. 遍历所有的子元素,根据 align-items 属性来计算元素相对于该行的排列位置

最后生成一个带位置的 Dom 树,也就是布局树。

六、分层(layer)

页面是由多个图层叠加在一起才形成的最终页面。

在生成布局树之后并不能直接进行绘制,渲染进程会将一些复杂的 3d 动画,滚动条,z-index 层级较高的。来生成专门的图层,并生成一颗图层树,来交给 GPU 加速渲染。

你也可以打开 chrome 的开发者工具,选择”More tools“下的”Layers" 标签,来查看网页的图层状态。也可以拖动 profiler 的进度条,来查看图层的渲染过程。

那么需要满足什么条件,渲染引擎才会为特定的节点创建新的图层呢?
chromium 文档 里是这样定义的:

  • Layer is used by <video> element using accelerated video decoding
  • Layer is used by a <canvas> element with a 3D context or accelerated 2D context
  • Layer is used for a composited plugin
  • Layer uses a CSS animation for its opacity or uses an animated webkit transform
  • Layer uses accelerated CSS filters
  • Layer has a descendant that is a compositing layer
  • Layer has a sibling with a lower z-index which has a compositing layer (in other words the layer overlaps a composited layer and should be rendered on top of it)

七、分块渲染(tiles)

网页的缓存通常都不是一大块,而是划分成一格一格的小块,通常为 256×256 或者 512×512 大小,这种渲染方式称为分块渲染(Tile Rendering)。

使用分块渲染的原因?

  • 分块渲染是依靠 GPU 加速渲染的。所谓 GPU 渲染,通常是使用 Open GL/ES 贴图来实现的,而这时的缓存其实就是纹理(GL Texture),而很多 GPU 对纹理的大小有限制,比如长/宽必须是 2 的幂次方,最大不能超过 2048 或者 4096 等,所以无法支持任意大小的缓存。
  • 使用小块缓存,方便浏览器使用一个统一的缓存池来管理分配的缓存,这个缓存池一般会分配成百上千个缓存块供所有的 WebView 共用。所有打开的网页,需要缓存时都可以以缓存块为单位向缓存池申请,而当网页关闭或者不可见时,这些不需要的缓存块就可以被回收供其它网页使用。

八、光栅化

光栅化或是称为栅格化。 栅格化的过程就是将图块转化成位图的过程。 图块(tiling 是栅格化的最小单位,栅格化会优先离视口最近的图块来进行渲染,离得远的会降级栅格化的优先级,同时在渲染的过程中会借用 GPU 来加速生成,最后将生成的位图保存到 GPU 的内存中。

九、合成

图块被栅格化完成,合成线程会生成绘制图块的指令 ——“DrawQuad”, 这些指令都会被放到 CompositorFrame 对象 中,然后将命令交给浏览器主线程,合成器(Viz) 会调用 OpenGL 指令来渲染 Compositor Frame 里面的 draw quads,把像素点输出到屏幕上。

Compositor Frame

什么是 viz? viz 文档

9.1 重排,重绘,合成

9.1.1 更新了元素的几何属性(重排)

可以看出重排会更新整个渲染流程,花费的开销也最大。

9.1.2 更新元素的绘制属性(重绘)

相对于重排操作,重绘省去了布局和分成阶段,处理的过程也会更少一些 哪些属性会触发重排和重绘制:

9.1.3 直接合成

相对于重绘和重排,合成能大大提升绘制效率。

哪些属性可以直接合成?
浏览器会把一些繁重的任务交给 GPU 处理,GPU 是为图形渲染的复杂的计算来处理的。并不是所有的 css 属性都能触发 GPU 加速,只有少数的属性可以,比如:

  • transform:translate3d()或 translateZ()
  • opacity
  • filter
  • will-change

但是也要注意使用 GPU 渲染可能会带来的问题:

  • 过多地开启硬件加速可能会耗费较多的内存,这一点在移动端浏览器上尤为明显,会导致渲染的结果很差,
  • 使用 GPU 渲染会影响字体的抗锯齿效果

十、 性能指标

Web Vitals 以用户为中心,提出了衡量网页性能的三大指标,也提出了帮助您量化网站的体验并确定改进的一些方法:

LCP:衡量视口内渲染的最大内容元素渲染时间的指标。
FID:从用户第一次与页面进行交互,到浏览器实际上能够开始处理程序的时间。
CLS:测量页面发生布局偏移的总和

如何改善网页性能? web.dev/fast/

十一、 总结

  1. 对之前模棱两可的知识有了清楚的认识,比如 Dom 树,CSSOM 是如何构建的?怎么生成布局树的? 渲染流程如何进行绘制的?等这些问题都有了答案。
  2. 加深了对 flex 布局的了解及实现。
  3. js 是如何阻塞页面渲染的?重流,重绘,合成经历了哪些阶段?如何避免重流和重绘?是不是也重新有了认识。
  4. 如何检测网页的性能指标和改进也有了针对性的参考

总之,学习到了很多东西,希望对正在路上的你也有帮助,水平有限,如有问题,欢迎大家指出。

作者:qiulijun9

十二、参考资料

李兵老师的《浏览器工作原理与实践》 www.html5rocks.com/zh/tutorial…
www.html5rocks.com/zh/tutorial…
www.chromium.org/developers/…
juejin.cn/post/684790…
cloud.tencent.com/developer/a…