likes
comments
collection
share

探索现代浏览器(三)|浏览器是如何渲染一个页面的?

作者站长头像
站长
· 阅读数 16

Hi 大家好,这里是 探索现代浏览器 专栏的第三篇。

上一篇中,我们探讨了浏览器导航的过程,其中 Step5 浏览器进程提交导航渲染进程 去做页面渲染。那么今天我们就来探讨另一个经典的前端问题:

浏览器是如何渲染一个页面的?

渲染进程

首先我们先来简单回顾一下渲染进程

渲染进程(renderer process)负责浏览器中 tab 页内发生的所有事情:

  1. 主线程(main thread):处理绝大部分的代码逻辑
  2. Worker 线程(worker thread):web worker & service worker
  3. 合成线程(compositor thread)& 光栅线程(raster thread):协助高效流畅的渲染页面内容

渲染进程的组成结构一般是:一个主线程 + 几个 Worker 线程 + 一个合成线程 + 一个光栅线程

探索现代浏览器(三)|浏览器是如何渲染一个页面的?

渲染流水线

我们常说的浏览器页面渲染过程其实有个官方的名称 —— 渲染流水线(renderer pipeline)

浏览器中的渲染流水线是一种高效的渲染机制,用于将网页内容转换为可视化的图像并在屏幕上显示。它涉及多个步骤和组件的协同工作,以实现快速的页面渲染和流畅的用户体验。

下面我们就来依次深入探索 渲染流水线 中的每一个环节。

Step1: 解析 Parsing

构建 DOM

之前的文章中提到,浏览器进程 在导航结束后发送 commit navigation 消息给 渲染进程 ,然后 渲染进程 会开始接收 HTML 数据,同时 主线程 会开始解析接收到的数据并将其转换为 DOM。

解析 HTML 转换成 DOM 的具体流程非常复杂,详细流程可参阅官方文档:HTML Standard: Parsing

加载子资源

子资源包括 style 资源文件 和 script 资源文件。这些文件会从缓存或者网络上获取,主线程按照在构建DOM 树时遇到各个资源的顺序一个接着一个地发起网络请求获取资源。

这个过程中之所以能并行发起网络请求是因为有个叫 preload scanner 的程序与解析同时运行。当识别到 <img> 或者 <link> 此类标签时,该程序会找到相应的资源地址,并通知 浏览器进程 中的 网络线程 去发起请求。

探索现代浏览器(三)|浏览器是如何渲染一个页面的?

为什么 JS 资源会阻塞 HTML 解析

既然涉及到资源加载,那就不得不提到另一个经典问题了:为什么 JS 资源会阻塞浏览器解析 HTML?

因为在渲染流水线中,当 HTML parser 识别到 script 标签时,会停止 HTML 的解析,转而去加载、解析和执行 JS 脚本。

需要这么做的原因是 JS 脚本中可能会执行诸如 document.write 等改变整个 DOM 树结构的操作。

如何让 JS 资源不阻塞 HTML 解析

当你明确你的 JS 脚本 不会有任何改变 DOM 树结构的操作 时,可以为 script 标签加上 async or defer 属性:

  • async:异步加载,加载后立即执行
  • defer:异步加载,HTML 解析完成后再执行

下面这个图可以很清晰的看出三种 script 标签资源加载的区别: 探索现代浏览器(三)|浏览器是如何渲染一个页面的?

Step2: 样式计算 Style Calculation

解析生成 DOM 树之后,渲染进程中的 主线程  会解析页面的 CSS 并确定每个 DOM 节点的 Computed Style

Computed Style 就是根据样式选择器计算出的每个 DOM 节点具备的具体样式,即我们日常开发通过控制台所看到的那些样式。

每个浏览器都有自己默认的样式表,这也是为什么即使开发什么样式都没设置,但不同标签的样式依然会有差异。 比如 Chromium 内核浏览器的默认样式表

样式计算流程

不同浏览器渲染引擎对于样式计算的处理也是存在差异的。以 Blink 为例,它的样式计算基本流程大致如下:

  1. 样式匹配:Blink 会从顶层样式规则开始匹配,并根据样式选择器与元素匹配程度来确定最终应用的样式规则
  2. 样式解析:Blink 会解析样式表,将样式表中的样式规则转化为计算机可以理解的内部数据结构
  3. 层叠和继承:样式匹配过程中,Blink 会考虑样式的层叠和继承规则。层叠规则决定了当多个样式规则应用于元素时,如何确定最终的样式值。继承规则决定了某些样式属性是否会被父元素传递给子元素。
  4. 计算最终样式:Blink 会根据前面得到的样式规则和样式表来计算每个元素的最终样式

Step3: 布局 Layout

样式计算完成后,渲染进程可以知道完整的文档结构以及每个节点的样式信息,但这还远不足以渲染一个页面。

这个女人叫小美,有一天小美跟小帅说:现在有一幅画,画上有一个红色的圆形和一个蓝色的正方形。光凭这些信息小帅是不知道这幅画具体长什么样的,她还必须要知道圆形和正方形在这个画中具体处于什么位置才行。

探索现代浏览器(三)|浏览器是如何渲染一个页面的?

同理,要渲染一个页面,还需要通过 布局(Layout) 来计算出每个元素的 几何信息(geometry)

布局的大致流程如下:

  1. 构建 渲染树(Render Tree)主线程 会从遍历刚刚构建的 DOM 树,过滤掉不展示的节点(比如 display: none 的节点 / 脚本标签等),以及插入一些额外节点(比如伪元素 p::before{content:"Hi!"}
  2. 构建 布局树(Layout tree)主线程 会遍历渲染树的节点,并根据节点元素的盒模型、位置信息和关系计算每个元素的几个信息。
  3. 布局:
    • 从布局树的根节点开始,逐级遍历布局树的每个元素。
    • 对于每个元素,浏览器会计算其盒模型(包括宽度、高度、边距、边框、内边距等)。
    • 浏览器确定元素在页面中的准确位置,包括相对左上角的坐标和层叠顺序。
    • 对于包含子元素的元素,浏览器会递归进行布局,先计算子元素的布局,然后确定父元素的布局。
    • 布局过程还涉及处理文档流中的相关特性,例如浮动元素和清除浮动。
    • 最终,浏览器完成了所有元素的布局计算,确定了页面上每个元素的最终几何位置和大小。

探索现代浏览器(三)|浏览器是如何渲染一个页面的?

如何优化布局操作

布局是一个非常复杂且昂贵的工作,频繁的布局操作会对页面性能产生负面影响。为了优化布局操作,通常我们可以:

  • 避免频繁改变样式和尺寸,如有需要可以考虑批量更新
  • 减少不必要的布局操作:比如定位、浮动、清除浮动等
  • 使用 transform 属性替代 top 和 left 等定位属性,transform 使用硬件加速,能够优化布局操作,并提高性能
  • 延迟布局操作:比如使用 requestAnimationFrame 
  • 等等

Step4: 绘制 Paint

确定了布局之后,浏览器依然没办法去渲染一个页面。因为页面元素之间是可能存在层级关系的。

比如下面这张图,如果不考虑层级顺序来渲染页面,那么得到的效果很可能不是你所预期的: 探索现代浏览器(三)|浏览器是如何渲染一个页面的?

浏览器还需要通过 绘制(Paint) 来确定元素的可视化效果。主线程 会遍历前面生成的布局树,根据元素的层叠顺序(z-index)、文档流顺序等因素,生成一系列的 绘制记录(paint records)

绘制记录是对绘画过程的注释(包含 位置信息 等),例如 “首先画背景,然后是文本,最后在 x,y 处画矩形”。最终浏览器会根据这个绘制记录从底层元素开始逐个进行绘制。

探索现代浏览器(三)|浏览器是如何渲染一个页面的?

绘制记录的更新

渲染流水线的更新成本是非常高的:因为流水线中每一步都需要用到前一步的结果来生成新的数据。比如布局树发生了一些变化,那么文档上被影响到的部分的 绘制记录 是要重新生成的。 探索现代浏览器(三)|浏览器是如何渲染一个页面的?

如果你的页面存在动画,浏览器就只能在每个渲染帧的间隔中通过渲染流水线来更新页面元素。一般显示器刷新频率是 60fps,如果流水线更新时间比较久,就会出现动画丢帧的情况,导致页面看起来卡顿了: 探索现代浏览器(三)|浏览器是如何渲染一个页面的?

流水线更新也是在主线程上的,所以也可能出现被 JS 执行阻塞的情况: 探索现代浏览器(三)|浏览器是如何渲染一个页面的?

如前面所说,可以利用 requestAnimationFrame ,将要执行的 JS 代码拆解到每个动画帧中执行,就能有效避免该问题: 探索现代浏览器(三)|浏览器是如何渲染一个页面的?

Step5: 合成 Compositing

截至目前,浏览器已经知道了页面的文档结构、元素的样式信息、几何信息以及绘制的顺序,接下来就是要将这些信息转换为显示器上的像素,这个过程就叫做 光栅化(rasterizing)

最简单的做法就是只光栅化可视区域(viewport)的网页内容。如果用户进行了页面滚动,就移动 光栅帧(rastered frame) 去光栅化滚动加载的页面内容。最早期的 Chrome 就是这么做的。

探索现代浏览器(三)|浏览器是如何渲染一个页面的?

现代浏览器采用的是另外一种更为复杂的方法:合成(Compositing)。合成就是将页面分成不同的 层(Layer),然后分别对每个层进行光栅化,最后在一个单独的线程 - 合成线程(compositor thread)里面合并成一个页面。

当用户进行页面滚动时,由于各个层都已经被光栅化,浏览器仅仅需要合成一个新的帧来展示滚动后的效果即可。

探索现代浏览器(三)|浏览器是如何渲染一个页面的?

页面是如何分层的

主线程 会遍历 布局树 从而创建一棵 层次树(Layer Tree),开发中可以通过设置像 will-change 这样的属性来创建一个独立的层。 通过下面这种设置可以提升页面上的所有元素,并为每个创建独立的层。但事实上每一层都需要占用独立的内存的,在内存有限的设备上,这么操作所带来的性能损耗要远超分层带来的好处。因此做好层数管理也是十分有必要的。

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

探索现代浏览器(三)|浏览器是如何渲染一个页面的?

合成流程

一旦层次树创建完成以及绘制顺序确定之后,主线程 就会向 合成线程 提交这些信息。

接着 合成线程 就会开始光栅化每一层。

因为一层有可能非常大,所以 合成线程 会将其分成很多个 图块(tiles),然后将这些图块发送给 光栅线程(raster thread) 处理。光栅线程在光栅化这些图块之后,会将其存储在 GPU 内存 中。

探索现代浏览器(三)|浏览器是如何渲染一个页面的? (合成线程是可以给光栅线程分配不同的优先级的,在 viewport 中的层可以优先被处理。)

当图块都被光栅化之后,合成线程 会收集图块上的 绘制四边形(draw quads) 信息,从而构建一个 合成帧(compositor frame):

  • 绘制四边形:包含图块在内存的位置以及图层合成后图块在页面的位置之类的信息。
  • 合成帧:代表页面一个帧的内容的绘制四边形集合。

上述步骤完成后,合成线程 会通过 IPC 向 浏览器进程(browser process) 提交一个渲染帧。这些帧最终会被发送给 GPU 并展示在界面上。

合成的好处在于这个过程不涉及到主线程,因此不需要等待诸如 JS 执行等主线程的操作。

探索现代浏览器(三)|浏览器是如何渲染一个页面的?

如何优化合成操作

现代浏览器会通过以下几种常见的手段对合成进行优化:

  • 分层:即前面提到的分层
  • 独立线程:使用区别于 主线程合成线程
  • 硬件加速:使用 GPU 来加速像素混合和合成操作。GPU能够并行处理大量的图形操作,提供更高的合成性能
  • 增量合成:只合成发生变化的部分图层
    • 脏矩形检测:脏矩形指的是页面中发生变化的区域。现代浏览器会通过一些技术(监听用户交互事件 / DOM 结构变化等)来检测脏矩形
    • 增量绘制:一旦脏矩形区域确定,浏览器会只对这些脏矩形区域进行绘制操作,而不对其他未受影响的区域进行绘制
  • GPU 优化:浏览器会针对不同的 GPU 进行优化,常见手段有
    • 纹理压缩(Texture Compression):通过减少纹理数据的存储空间来提高图形渲染性能
    • 顶点着色器(Vertex Shader):利用 GPU 并行计算能力,同时处理图像多个顶点数据

Step6: 显示

最后一步,浏览器将合成和光栅化的结果发送给显示设备进行显示。

这一步的主要工作在于显示器输出,在此就不做过多赘述了。

写在最后

总的来说,浏览器就是通过 渲染流水线 的一系列操作来完成一个页面的渲染的。

渲染流水线 的一系列操作可以分解为: 解析 -> 样式计算 -> 布局 -> 绘制 -> 合成 -> 显示

关于浏览器页面渲染的探讨不知道大家还有没有其他想聊的呢?欢迎评论区一起交流~

参考文章

Inside look at modern web browser (part 3)

转载自:https://juejin.cn/post/7250776775937261626
评论
请登录