图片皆为原创,仅在掘金发布。对本文的消息队列不清楚的,可以先查阅前置知识:图解现代浏览器事件循环 - 掘金 (juejin.cn)
现代浏览器的底层原理特别的复杂,可以说是一个小型的操作系统。以 chrome 为例,他是多进程的浏览器,其中渲染页面是一个单独的进程,叫做渲染进程。目前用的最多的方式是一个 tab 页单独分配一个渲染进程来维护。每一个渲染进程中,有一个渲染主线程用于无阻塞地执行渲染任务。
渲染,通俗的解释就是将 html 字符串解析为图形像素信息的过程。你输入一个 url 地址后,会通过一系列解析,从目标服务器直接或间接获取到这个 html 字符串,而渲染进程要做的就是这个翻译为像素信息的过程。
本文就通过图解来讲述浏览器的渲染主线程的工作过程。
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 的过程:
我们来看图说话:
- 渲染主线程无阻塞的读取 html 字符串
- 遇到css,交由预解析线程线程准备css资源,准备好后无阻塞解析,并推送任务。渲染主线程通过css构造CSSOM
- 遇到js,交由预解析线程准备js资源,准备好后开始执行js,js会影响CSSOM/DOM树的结构,所以主线程需要阻塞等待,执行完毕后,重新构建CSSOM/DOM树
- 渲染主线程继续向下执行,重复上面的步骤,直到结束。
这一步的产出: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
渲染任务出现的时机
视具体情况而定,并不是每一轮消息循环结束时都会产生一个渲染任务。这要根据屏幕刷新率、页面性能、页面是否在后台运行等来共同决定,通常来说这个渲染间隔是固定的。通常决定渲染时机的因素有如下几点:
- 显示器一般帧率为 60fps(每 16.66ms 渲染一次),如果页面性能维持不了 60fps,浏览器会降到 30fps 以保证渲染能够进行下去。
- 如果浏览器上下文不可见,那么页面会降低到 4fps 左右甚至更低。
- 如果浏览器判定当前改动不会引起视觉变化或者
requestAnimationFrame
回调为空时,则会跳过渲染。 - 在页面 resize、页面 scroll、
requestAnimationFrame
调用、IntersectionObserver 触发显示、元素显示、隐藏或结构变化时,一般在渲染间隔到来时会推送一个渲染任务。
requestAnimationFrame
requestAnimationFrame 的回调有两个特点:
- 在重新渲染前调用。
- 回调合并执行。
第一条,保证了在渲染任务执行之前执行完想要改变的元素,保证了动画的流畅,不会拖延到下一帧再渲染。第二条我们可以看一段代码:
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。
总结
浏览器是如何渲染页面的?
- 接受 HTML,产生渲染任务,交给渲染主线程的消息队列
- 在消息循环机制下,渲染主线程依次取出任务并开始渲染
- 渲染任务分为多个阶段串行流水执行:
js
1. html解析
2. 样式计算
3. 布局
4. 分层
5. 绘制指令
6. 分块
7. 光栅化
8. 画出像素点。