万文图解浏览器渲染原理

图片皆为原创,仅在掘金发布。对本文的消息队列不清楚的,可以先查阅前置知识:图解现代浏览器事件循环 - 掘金 (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. 画出像素点。
相关推荐
一只月月鸟呀37 分钟前
Vue 过滤器 filter(s) 的使用
javascript·vue.js·ecmascript
秋月华星3 小时前
【flutter】TextField输入框工具栏文本为英文解决(不用安装插件版本
前端·javascript·flutter
—Qeyser3 小时前
用Deepseek写一个 HTML 和 JavaScript 实现一个简单的飞机游戏
javascript·游戏·html
青红光硫化黑4 小时前
React基础之React.memo
前端·javascript·react.js
GDAL5 小时前
better-sqlite3之exec方法
javascript·sqlite
匹马夕阳5 小时前
基于Canvas和和原生JS实现俄罗斯方块小游戏
javascript·canva可画
m0_616188495 小时前
Vue3 中 Computed 用法
前端·javascript·vue.js
六个点5 小时前
图片懒加载与预加载的实现
前端·javascript·面试
weixin_460783875 小时前
Flutter解决TabBar顶部页面切换导致页面重载问题
android·javascript·flutter
逍遥客.6 小时前
uniapp对接打印机和电子秤
javascript·vue.js·uni-app