万文图解浏览器渲染原理

图片皆为原创,仅在掘金发布。对本文的消息队列不清楚的,可以先查阅前置知识:图解现代浏览器事件循环 - 掘金 (juejin.cn)

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

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

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

chromium 内核源码


0. 渲染流水线

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

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

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

1. 解析 HTML

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

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

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

浏览器的解析器会将这个结构解析为 DOM 树 和 CSSOM 树。

先解释概念:

  • DOM树:文档对象模型,是网页中 html元素为根节点的一颗元素关联关系的集合
  • CSSOM树: 是网页CSS对象模型,描述页面DOM结构层集中的css样式

其中:

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

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

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

我们来看图说话:

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

这一步的产出:DOM/CSSOM

2. 样式计算

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

在这个过程中:

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

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

3. 布局

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

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

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

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

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

布局需要考虑的因素有很多,定位、样式、 BFC 等;同时在布局树里也会暴露一些常用信息,比如 clientHeight、offsetHeight 等
布局的例子还有很多,上面的例子旨在说明 DOM 树(json)和 布局树的结构(c++ 对象)往往是不一样的

4. 分层

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

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

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

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

分层的作用:

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

分层不能滥用:

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

5. 绘制指令

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

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

6. 分块(tiling)

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

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

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

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

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

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

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

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

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

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

我们用一个点 P(x, y, 1) 来说明问题,将其表示为齐次坐标形式 [x, y, 1]。(模型参考:skewX() - CSS:层叠样式表 | MDN (mozilla.org)
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> P = [ x , y , 1 ] P = [x, y, 1] </math>P=[x,y,1]

使用乘法算子 T 表示转换矩阵:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> T = [ 1 t a n ( 25 d e g ) 0 0 1 0 0 0 1 ] T = \begin{bmatrix} 1 & tan(25deg) & 0 \\ 0 & 1 & 0 \\ 0 & 0 & 1 \\ \end{bmatrix} </math>T=⎣ ⎡100tan(25deg)10001⎦ ⎤

现在,我们要将这个点应用 transform: skewX(25deg) 变换。
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> P ′ = T ∗ P T = [ x ′ , y ′ , z ′ ] P' = T * P^{\text{T}} = [x', y', z'] </math>P′=T∗PT=[x′,y′,z′]

计算后得:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> x ′ = x + t a n ( 25 d e g ) ∗ y x' = x + tan(25deg) * y </math>x′=x+tan(25deg)∗y
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> y ′ = y y' = y </math>y′=y
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> z ′ = z = 1 z' = z = 1 </math>z′=z=1

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

skewX 倾斜是根据图形的中心点进行的,下半部分向右平移,上半部分向左平移;上面的例子只举例了下半部分的情况。
同类型的操作还有 缩放(scale),旋转(rotate)以及位移(translate) 等


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


Q & A

什么是 reflow ?

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

当进行了影响布局的操作后,会引发一次 layout。浏览器有自己的优化策略,往往将多次回流合并执行(浏览器会在消息队列做优化),比如在 js 代码全部执行完毕后进行统一执行,所以 reflow 是异步执行的。

所以,在某种场景下,js 可能不能实时获取最新的布局信息。而使用 css 改变影响布局树的属性则会立即触发回流。

什么是 repaint ?

重绘的本质是重新根据分层信息计算绘制的指令。他开始的节点便是 paint。回流一定会引起重绘。

为什么 transform 效率高 ?

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

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

附:requestAnimationFrame 与 requestIdleCallback

渲染任务出现的时机

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

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

requestAnimationFrame

requestAnimationFrame 的回调有两个特点:

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

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

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

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

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

总结

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

  1. 接受 HTML,产生渲染任务,交给渲染主线程的消息队列
  2. 在消息循环机制下,渲染主线程依次取出任务并开始渲染
  3. 渲染任务分为多个阶段串行流水执行:
js 复制代码
1. html解析
2. 样式计算
3. 布局
4. 分层
5. 绘制指令
6. 分块
7. 光栅化
8. 画出像素点。
相关推荐
JUNAI_Strive_ving4 分钟前
番茄小说逆向爬取
javascript·python
看到请催我学习13 分钟前
如何实现两个标签页之间的通信
javascript·css·typescript·node.js·html5
twins352033 分钟前
解决Vue应用中遇到路由刷新后出现 404 错误
前端·javascript·vue.js
qiyi.sky1 小时前
JavaWeb——Vue组件库Element(3/6):常见组件:Dialog对话框、Form表单(介绍、使用、实际效果)
前端·javascript·vue.js
煸橙干儿~~1 小时前
分析JS Crash(进程崩溃)
java·前端·javascript
哪 吒1 小时前
华为OD机试 - 几何平均值最大子数(Python/JS/C/C++ 2024 E卷 200分)
javascript·python·华为od
安冬的码畜日常1 小时前
【D3.js in Action 3 精译_027】3.4 让 D3 数据适应屏幕(下)—— D3 分段比例尺的用法
前端·javascript·信息可视化·数据可视化·d3.js·d3比例尺·分段比例尺
Q_w77422 小时前
一个真实可用的登录界面!
javascript·mysql·php·html5·网站登录
昨天;明天。今天。2 小时前
案例-任务清单
前端·javascript·css
一丝晨光2 小时前
C++、Ruby和JavaScript
java·开发语言·javascript·c++·python·c·ruby