图解浏览器渲染原理


现代浏览器的底层原理特别的复杂,可以说是一个小型的操作系统。以 chrome 为例,他是多进程的浏览器,其中渲染页面是一个单独的进程,叫做渲染进程。目前用的最多的方式是一个 tab 页单独分配一个渲染进程来维护。每一个渲染进程中,有一个渲染主线程用于无阻塞地执行渲染任务。

渲染,通俗的解释就是将 html 字符串解析为图形像素信息的过程。你输入一个 url 地址后,会通过一系列解析,从目标服务器直接或间接获取到这个 html 字符串,而渲染进程要做的就是这个翻译为像素信息的过程。

本文就通过图解来讲述浏览器的渲染主线程的工作过程。

chromium 内核源码


0. 渲染流水线

依据消息循环的概念,浏览器渲染主线程按照各个任务队列(微任务队列、交互队列,延时队列等)优先级,取用任务包并执行。那么,执行的这些包后的变更,何时会真正呈现在页面上呢?我们看图:

无标题-2023-10-06-1925.png

在浏览器向前端服务器请求到 html 字符串后,就打包为一个消息循环中的一个渲染任务,交由渲染主线程执行渲染工作。

那么浏览器是如何解析这个 html 字符串的呢?我们先来总览一下他的解析流水线,之后的小节再分步来讲解:

无标题-2023-10-06-1925.png

1. 解析 HTML

我们获取一个页面,一般都是通过网络请求拿到 html 字符串本身的:

image.png

我们也可以加上 view-source 前缀来访问这个字符串:view-source:https://www.ucloud.cn

image.png

他与随后获取到的 css、js 文件共同影响了页面的渲染。

浏览器获取到 HTML后,产生渲染任务,交给渲染主线程的消息队列。随后的渲染解析器会将这个结构解析为 DOM 树 和 CSSOM 树。

先解释概念:

  • DOM树:文档对象模型,是网页中 html元素为根节点的一颗元素关联关系的集合

无标题-2023-10-06-1925.png

  • CSSOM树: 是网页CSS对象模型,描述页面DOM结构层集中的css样式

无标题-2023-10-06-1925.png

其中:

  • StyleSheetList 是页面 CSSOM 根节点,代表页面所有的样式表,其包含多套不同优先级的样式表
  • CSSStyleSheet 是页面样式表,有这几种:内联(<div style="">)、外部(<link>)、内部(<style>)和默认样式表 (也叫用户代理样式表,每个浏览器内置样式),通过 document.styleSheets 可以获取上下文中所有的样式表。
  • CSSStyleRule:css 规则,比如:body { margin: 0 } 就是一个规则

浏览器的 API 使用 C++ 实现,其使用 js 进行了封装,使得 DOM 操作可以使用 js 完成

知道了上述概念,我们来看一下浏览器解析 html 的过程:

无标题-2023-10-06-1925.png

我们来看图说话:

  1. 渲染主线程无阻塞的读取 html 字符串
  2. 遇到css,交由预解析线程线程准备css资源,准备好后无阻塞解析,并推送任务。渲染主线程通过css构造CSSOM
  3. 遇到js,交由预解析线程准备js资源,准备好后开始执行js,js会影响CSSOM/DOM树的结构,所以主线程需要阻塞等待,执行完毕后,重新构建CSSOM/DOM树
  4. 渲染主线程继续向下执行,重复上面的步骤,直到结束。

这一步的产出:DOM/CSSOM

2. 样式计算

这一步的目的是遍历 DOM树,找到页面各个元素的最终计算样式(Computed Style):

image.png

在这个过程中:

  • 预设值会变为绝对值。比如 100% -> px,0.125rem -> px,red -> rgb(255, 0, 0)等
  • 同一元素按照优先级层叠、继承等各种因素决定最终的样式
  • 计算样式会展示所有的可能样式,即使没有设置过,也会是 none 或者 unset

获取计算样式有哪些,可以使用 API:getComputedStyle

image.png

3. 布局

布局的过程,就是通过 CSSOM/DOM 树生成真正在页面展示的结构布局树。我们看图:

无标题-2023-10-06-1925.png

上图这个例子就很好的说明了 DOM 树和布局树的区别。布局树中的各个节点都是会有自己的包含块的。

包含块:100% 计算的相对尺寸就是该元素的包含块。如果是 absolute 定位元素,其包含块是离他最近的设置了非 static 定位的祖先元素

其实在 chrome 源码中也规定了各个标签的内置样式,他们都会在布局阶段进行判断:

image.png

另外一个布局与 DOM 不一样的是:文本内容必须在行盒中,具名的行盒和块盒在布局树中不能相邻,比如一个 inline-block 和一个 flex,他们肯定是分两行显示的,浏览器一种处理方式是插入一个或多个匿名的盒子来做间隔:

无标题-2023-10-06-1925.png

布局需要考虑的因素有很多,定位、样式、 BFC 等;同时在布局树里也会暴露一些常用信息,比如 clientHeight、offsetHeight 等

布局的例子还有很多,上面的例子旨在说明 DOM 树(json)和 布局树的结构(c++ 对象)往往是不一样的

4. 分层

现代浏览器为了提升渲染效率,将二维提升到三维,增加维度,开辟新的空间。类似于 PS 中的图层,便于局部渲染。

在布局确定了元素的位置和样式以后,浏览器会根据渲染引擎的不同采用不同的分层方式。

屏幕录制2023-10-26 下午4.29.57 (2).gif

上图中可以看到,是有分层的。至少滚动条是默认层级高于页面的。

分层一般是跟 css 属性有关系,一般地,堆叠上下文相关的属性会影响分层。 比如 z-index 强制提高层级;whil-change 则适用于分层渲染的浏览器,告诉浏览器,根据实际情况将其单独分层。

分层的作用:

  • 提高渲染效率:适用于位置反复变化的元素,回流重绘只在同层出现,计算量会减少

分层不能滥用:

  • 分层会增加内存消耗,过多的分层会导致浏览器遍历和计算的耗时变长。所以应该避免无意义的分层或者不合理的分层

5. 绘制指令

这一步是渲染主线程的最后一步操作,他针对每一层元素,生成对应的绘制指令:比如将笔移动到 (x, y),绘制宽度100px的黑色直线...(类似 canvas)。

从这一步往后,渲染主线程的工作就完成了,渲染主线程会带着所有生成的绘制指令集,将任务交给其他线程完成。

无标题-2023-10-11-1617.png

6. 分块(tiling)

到了这一步,浏览器会将每一层上的元素分成不同的块,确定各个块的优先级。一般靠近视口的块优先级会相对较高。

无标题-2023-10-11-1617.png

分块一般是交给分块线程来完成的,他会维护很多个合成器来管理这些块的合并和展开。

7. 光栅化(GPU计算为主)

光栅化(Raster)是图像处理的一个概念,他将按照每一个块的优先级,将各个块元素解析为位图格式。如图展示了一个像素点及其周边连通区域像素点的示意图:

无标题-2023-10-11-1617.png

我们所说的 GPU计算主要就是在这一个环节,GPU 比 CPU 更擅长光栅化计算,其本质是矩阵的运算。在浏览器中,这一过程将分配给 GPU 进程来处理。

8. 最终绘制(GPU计算为主)

合成线程负责计算出每一个位图在屏幕上的位置,交给 GPU 进行最终呈现。

这里来解释一下,渲染进程实际上是在沙盒里边运行的,其没有操作硬件的能力,所以这里必须要交给 GPU 进程过渡。

比较神奇的是,css 样式 transform 并不是在光栅化中生成像素点,而是在这一步真正要画的时候决定的,就是在 GPU 做一个矩阵变换。

我们举一个矩阵乘法 做 skewX 的例子。我们将 google 首页的 logo 横向扭曲一下:

image.png

我们用一个点 P(x, y, 1) 来说明问题,将其表示为齐次坐标形式 [x, y, 1]。(模型参考:skewX() - CSS:层叠样式表 | MDN (mozilla.org)

P = [x, y, 1] 

使用乘法算子 T 表示转换矩阵:

T = \begin{bmatrix}  
1 &  tan(25deg) & 0 \\  
0 & 1 & 0 \\  
0 & 0 & 1  \\  
\end{bmatrix}

image.png

现在,我们要将这个点应用 transform: skewX(25deg) 变换。

P' = T * P^{\text{T}} = [x', y', z']

image.png

计算后得:

x' = x + tan(25deg) * y  
y' = y  
z' = z = 1

image.png

可以看到,skewX 后,纵坐标没有变,横坐标整体向右移动了 tan(25deg) * y 的距离,根据三角函数公式可以看出,确实是向右倾斜了 25 度。

skewX 倾斜是根据图形的中心点进行的,下半部分向右平移,上半部分向左平移;上面的例子只举例了下半部分的情况。

同类型的操作还有 缩放(scale),旋转(rotate)以及位移(translate) 等


再来看一下完整的过程,帮助回忆:

无标题-2023-10-06-1925.png


9. Q & A

什么是 reflow ?

回流的本质是重新计算布局树。我们来看一下回流的执行过程:

无标题-2023-10-11-1617.png

当进行了影响布局的操作后(cssom改变),会引发一次 layout。

浏览器有自己的优化策略,往往将多次回流合并执行(浏览器会在消息队列做优化),比如在 js 代码全部执行完毕后进行统一执行,所以 reflow 是异步执行的。所以,在某种场景下,js 可能不能实时获取(同步获取)最新的布局信息。(可以通过直接同步读取属性来强制触发回流)

开发过程中,代码应该避免频繁修改布局树(height/width/margin/padding...)

什么是 repaint ?

重绘的本质是重新根据分层信息计算绘制的指令。他开始的节点一般是 paint,某种情况也会引起分层变化。

回流一定会引起重绘。

无标题-2023-10-11-1617.png

为什么 transform 效率高 ?

因为计算 transform 是在最后一步(draw)时,针对本层级的元素,在 GPU 中执行变换的,不会引起回流和重绘。

无标题-2023-10-11-1617.png

上图可以看到,transform 不在渲染主线程中执行任务,所以,即使页面 js 进入了死循环,动画也可以正确执行。

10. 附:requestAnimationFrame 与 requestIdleCallback

渲染任务出现的时机

视具体情况而定,并不是每一轮消息循环结束时都会产生一个渲染任务。这要根据屏幕刷新率、页面性能、页面是否在后台运行等来共同决定,通常来说这个渲染间隔是固定的。通常决定渲染时机的因素有如下几点:

  1. 显示器一般帧率为 60fps(每 16.66ms 渲染一次),如果页面性能维持不了 60fps,浏览器会降到 30fps 以保证渲染能够进行下去。
  2. 如果浏览器上下文不可见,那么页面会降低到 4fps 左右甚至更低。
  3. 如果浏览器判定当前改动不会引起视觉变化或者 requestAnimationFrame 回调为空时,则会跳过渲染。
  4. 在页面 resize、页面 scroll、requestAnimationFrame 调用、IntersectionObserver 触发显示、元素显示、隐藏或结构变化时,一般在渲染间隔到来时会推送一个渲染任务。

requestAnimationFrame

requestAnimationFrame 的回调有两个特点:

  1. 在重新渲染前调用。
  2. 回调合并执行。

第一条,保证了在渲染任务执行之前执行完想要改变的元素,保证了动画的流畅,不会拖延到下一帧再渲染。第二条我们可以看一段代码:

// requestAnimationFrame 在渲染任务执行之前执行
setTimeout(() => {
  console.log(1)
  requestAnimationFrame(() => console.log(2))
})
setTimeout(() => {
  console.log(3)
  requestAnimationFrame(() => console.log(4))
})

queueMicrotask(() => console.log(5))
queueMicrotask(() => console.log(6))

/** 输出
5
6
1
3
2
4
*/

queueMicrotask 是 web API,用于创建一个微任务

requestIdleCallback

requestIdleCallback 是空闲调度算法,把一些计算量较大但是又没那么紧急(任务队列的优先级决定)的任务放到空闲时间去执行。不要去影响浏览器中优先级较高的任务,比如动画绘制、用户输入、微任务等等。

// 定义你的计算密集型任务  
function computeIntensiveTask() {  
  // 执行一些复杂的计算...  
}  
  
// 使用 requestIdleCallback 在空闲时执行任务  
requestIdleCallback(function() {  
  computeIntensiveTask();  
}, {  
  timeout: 1000, // 设置超时时间,如果在这段时间内没有空闲时间,回调函数将被取消  
  dependencies: [] // 可以设置依赖项,当所有依赖项都完成时,才会执行回调函数  
});

但是,其兼容性还不好,导致 React 的 fiber 不得不自己实现了一套这个 API。

image.png

11. 总结

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

  1. 接受 HTML,产生渲染任务,交给渲染主线程的消息队列
  2. 在消息循环机制下,渲染主线程依次取出任务并开始渲染
  3. 渲染任务分为多个阶段串行流水执行:
1. html解析
2. 样式计算
3. 布局
4. 分层
5. 绘制指令
6. 分块
7. 光栅化
8. 画出像素点。

Was this page helpful?