前端渲染原理

现代游览器

一、游览器架构

1、早期游览器

早期游览器使用的单进程单线程

缺点

  • 不安全
    所有模块共享进程权限(如文件读写),恶意插件或脚本可直接利用高权限攻击系统。
  • 不流畅
    核心任务(如渲染、JS 执行)运行在同一线程,长时间任务(复杂 JS 计算)会阻塞线程,导致页面卡顿或无响应。
  • 不稳定
    单进程内模块耦合紧密,任一组件(如插件)崩溃会直接导致整个浏览器崩溃,多标签页同时受影响。

2、现代游览器

现代游览器使用多进程, 谷歌游览器会为每一个标签页启动一个进程运行(渲染进程)

现代游览器的进程

进程 负责
browser 浏览器基本功能,包括导航栏、导航按钮、书签、网络请求、文件读写等。
renderer * n tab 内和网页展示相关的所有工作,主要是将 HTML,CSS,以及 JavaScript 转变为我们可以进程交互的网页内容。 Chrome 为每一个 tab 甚至是页面里面的每一个 iframe 都分配一个单独的进程
Extension * n 每个扩展程序可运行在独立的进程中,确保安全隔离。
GPU 处理来自不同 tab 的渲染请求并把它在同一个界面上画出来
Network Chrome将网络请求独立为一个进程,管理缓存、Cookie 和资源加载

优点

  1. 更高的稳定性

    • 进程隔离:Chrome 将浏览器的各个组件(如渲染、插件、GPU等)分配到不同的进程中,这样一个进程崩溃时不会影响其他进程。例如,如果一个标签页崩溃,其他标签页和浏览器本身仍然能够正常工作。
    • 标签页隔离:如果一个网页或标签页的渲染进程出现问题,它的崩溃通常不会影响到其他标签页。这减少了因单个网页错误导致整个浏览器崩溃的风险。
  2. 安全性增强

    • 跨站攻击防护:多进程架构有助于保护用户免受跨站脚本攻击(XSS)和跨站请求伪造(CSRF)等安全问题。
    • 沙箱技术:每个渲染进程(也就是每个标签页的内容)、插件都运行在沙箱环境中,限制了恶意代码的执行范围。即使某个网页包含恶意代码,攻击者也很难通过该网页访问操作系统或其他网页的数据。

缺点

  1. 内存和资源消耗
    • 高内存消耗:多进程架构的一个显著缺点是,它会消耗更多的内存资源。每个进程都需要分配一定的内存空间,而 Chrome 在每个标签页、插件、扩展甚至网络请求上通常都会启动独立的进程。尽管浏览器会尽量合并同一网站的多个标签页,但整体上仍然会导致内存使用量的增加,尤其是在打开大量标签页时。
    • 进程管理开销:操作系统需要管理多个进程,这会带来一定的系统开销。进程间的上下文切换和通信需要消耗额外的资源,尤其是在资源紧张的环境下,可能会导致性能下降。
  2. 进程间通信的复杂性
    • IPC(进程间通信)开销:由于每个进程运行在独立的内存空间中,它们之间需要通过 IPC(进程间通信) 来交换数据。这种通信往往比较复杂,可能导致效率低下,特别是在需要频繁交换数据时。例如,浏览器的渲染进程和浏览器进程需要通过 IPC 共享网页数据和状态,增加了复杂性和性能负担。

二、渲染进程(多线程)

渲染进程的工作

渲染进程的工作包括:

  • 解析HTMl
  • 解析CSS
  • 计算样式
  • 布局
  • 处理图层
  • 绘制
  • 执行JS
  • 执行事件回调
  • 执行计时器的回调
  • ……

三、事件循环

异步任务在主线程执行吗?

任务由其他线程来完成, 其他线程再向消息队列提交任务

比如计时器任务由外部计时器线程进行计时, 计时完成后向消息队列末尾添加任务

事件循环是异步的解决方案

事件循环(Event Loop)是用于处理异步操作的机制,它是一种循环过程, 异步任务处理完后回调会加入消息队列挂起, 等主线程空闲时会执行队列中的任务。

异步任务包括(setTimeout、SetInterval、XHR、Fetch、addEventListener)

1
2
3
while (queue.waitForMessage()) {
queue.processNextMessage();
}

消息队列类型

每个任务都有一个任务类型
同⼀个类型的任务必须在⼀个队列,
一个队列可以有不同类型的任务

  • 延时队列:⽤于存放计时器到达后的回调任务,优先级「中」
  • 交互队列:⽤于存放⽤户操作后产⽣的事件处理任务,优先级「⾼」
  • 微队列:⽤户存放需要最快执⾏的任务,优先级「最⾼」

四、渲染流程

HTML字符串 -> 解析HTML -> 样式计算 -> 布局 -> 分层 -> 绘制 -> 分块 -> 光栅化 -> 画 -> 像素信息

当游览器的网络线程收到HTML文档后, 会产生一个渲染任务, 并加入消息队列, 渲染主线程取出消息队列的渲染任务, 开始进行渲染。

1、解析html

HTML的解析:边下载边解析

  • 流式解析:浏览器采用**增量解析(Incremental Parsing)**策略,在接收到HTML数据时立即开始解析,无需等待整个文件下载完成。这种机制允许浏览器逐步构建DOM树,优化页面加载速度。
  • 阻塞情况:当遇到外部JavaScript脚本(未标记asyncdefer<script>标签)时,HTML解析会暂停,直到脚本下载并执行完成。这是因为脚本可能修改DOM结构,需确保解析正确性。
  • 渲染机会:浏览器在解析过程中可能多次触发渐进式渲染,将已解析的部分内容先行显示给用户,提升体验。

CSS的解析:下载完成后解析

  • 全量解析:浏览器需等待完整的CSS文件下载完成后,才开始解析并构建CSSOM(CSS Object Model)。这是因为CSS的层叠规则(如后续样式覆盖前面样式)需要全局信息才能正确计算。
  • 阻塞渲染:CSS解析会阻塞页面渲染(但通常不阻塞HTML解析),以避免“无样式内容闪烁”(FOUC)。浏览器在CSSOM构建完成后,才会将DOM与CSSOM合并为渲染树,触发布局和绘制。
  • 并行运行:尽管CSS解析需全量数据,但其文件下载和解析是并行进行的,不会阻塞HTML解析(除非后续脚本依赖CSSOM)。

渲染主线程会解析接收的html字符串, 遇到同步的css和js会直接执行

如果解析到错误的格式, 不会进行报错, 解析会自动进行处理, 处理规则

预解析线程

  • 并行处理:当主解析器(Main Parser)逐行构建DOM树时,预解析线程会快速扫描后续的HTML内容,识别需要加载的外部资源(如脚本、样式表、图片等)。
  • 提前下载预解析线程发现资源链接后,立即触发资源的下载,而无需等待主解析器处理到相应位置。这减少了资源加载的等待时间。
  • CSS解析:css的解析和下载都在预解析器内进行, 不会阻塞主线程解析html。

解析的过程遇到外部link标签

link标签的解析在预解析线程进行, 不会阻塞主线程解析html

把link放在head

css尽量放在head, 提前加载样式, 不然可能会导致回流和重绘, 页面布局加载出来, 但是页面样式还没渲染(FOUC), 样式闪烁

css放在body会导致两次渲染

  • 在第一次渲染中,仅加载和渲染最低限度的 HTML。
  • 在第二次渲染中,将应用样式并再次渲染页面。这可能会使 HTML 元素的大小、形状、颜色发生变化,并可能使网页闪烁。

解析到</body>的时候会进行安排重新计算样式的时间

这里虽然link放在head不会阻塞HTMl的解析, 但是会阻塞渲染树的构建,因为渲染树需要css的构建完成。

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
</head>
<body>
test
</body>
</html>

把link放在body

js尽量放在body结束之前或者使用defer属性和anysc, 因为js阻塞了DOM构建, 会导致js内部获取DOM元素获取失败, 页面加载更慢, 因为需要等待js下载并且执行, 这样没有的异步, ansyc属性可以让js下载好后执行, defer可以让js等待DOM构建完成后执行。

这里解析到link会阻塞HTMl的解析(触发加上media)

然后会触发一次渲染, 仅加载和渲染最低限度的 HTML(渐进式渲染)。

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
test
</body>
</html>

解析的过程遇到外部script标签

只有script

在解析中遇到<script>标签会暂停HTML解析, 因为js有可能改变页面结构document.write(), 必须等js执行后才会继续渲染 所以建议把js放在body结束标签之前, 这样对阻塞的影响较小, 设置defer属性后可以不进行阻塞, ansyc属性可以让js下载好后执行

遇到script标签暂停html的解析, 下载并且执行js

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
test1
<script src="test.js"></script>
test2
</body>
</html>
1
2
3
4
5
6
7
8
9
10
function sleep(ms) {
let start = new Date().getTime();
while (true) {
if (new Date().getTime() - start > ms) {
break;
}
}
}
sleep(3000);
console.log(document.documentElement.outerHTML);

同时有script和link

遇到script, 如果前面有link标签没有被解析会等待下载和解析完成后执行js, 因为js可能操作css属性, 所以必须完成css解析后执行js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css" />
</head>
<body>
test1
<script src="test.js"></script>
test2
</body>
</html>

2、DOM构建

构建DOM,并且提供DOM操作DOM的接口。

1
2
3
4
5
6
7
8
<html>
<body>
<p> Hello World </p>
<div>
<img src="example.png"/>
</div>
</body>
</html>

3、CSSOM构建

这一步构建CSSOM并且提供给js处理css的接口。


通过document.styleSheets可以查看最后的结构, 每增加一个样式表(内联, 内部, 外部, 游览器默认样式表), styleSheets子节点就会增加一个

4、样式计算

样式来源

  • 用户代理样式表:浏览器默认的样式表
  • 用户样式表:用户自定义的样式表
  • 作者样式表:开发者编写的样式表

优先级顺序为:

  • 作者样式表 > 用户样式表 > 用户代理样式表

标准化阶段

在此阶段,非标准单位会被转换为标准单位。例如:

  • width: 80% 转为 width: 1000px
  • color: red 转为 color: rgb(255, 0, 0)

二、属性计算

根据以下优先级规则来计算CSS属性值:

  • !important: 最高优先级
  • Inline Style: 内联样式,优先级高于作者样式表
  • 明确性: CSS选择器的优先级
  • 继承性: 某些属性可以继承(如 color, font-*, line-* 等)
  • 浏览器默认样式: 最低优先级

解析样式表

查看作者样式表

  • 如果有样式,根据优先级(!important > inline-style > 明确性)来计算。
  • 如果多个样式选择器有相同优先级,后面的样式会覆盖前面的样式。

查看继承的属性

  • 如果作者样式表没有定义某些属性,则检查是否有继承的属性(如 color, font-*, line-* 等可继承属性)。

查看用户样式表

  • 检查用户样式表中是否有覆盖或定义的属性。

查看用户代理样式表

  • 用户代理样式表包含浏览器为标准HTML元素定义的默认样式(如 h1, a 等元素的默认样式)。

示例(来自 Chromium源代码):

1
2
3
4
5
6
7
8
9
h1 {
display: block;
font-size: 2em;
margin-block-start: 0.67em;
margin-block-end: 0.67em;
margin-inline-start: 0;
margin-inline-end: 0;
font-weight: bold;
}

最后生成计算后的css样式(Computed Style), 每个元素的所有css样式都有会值,

通过getComputedStyle可以获取计算样式。

5、生成Layout树

等待CSSOM和DOM生成完成后会结合它们变为Layout树, Layout树包含了元素的位置大小, 位置是相对于包含块的位置, Layout里面的对象是c++对象, 无法被js获取的, 只能获取document.body.clientWidth, document.body.clientHeight等部分信息。

DOM树和Layout树不一定是对应的
元素 DOM树 Layout树
display: none
::after
匿名盒

由于:

  • 内容必须在行盒内
  • 行盒和块盒不能相邻

所以下面的代码会在dom和layout树中呈现不同的结构

1
2
3
4
5
<div>
<p>a</p>
b
<p>c</p>
</div>

6、分层 Layer

游览器会使用一套复杂的策略堆整个布局树中进行分层。

分层是为避免页面更新后, 整个页面进行重新绘制, 仅对该层处理, 比如滚动条单独为一层, 当对页面上的元素进行某些操作(比如3D变换)时也会进行分层。

叠上下文有关的属性会影响分层的结果: z-index, opacity, transform, 也可以通过will-change更大程度来影响分层的结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.test1{
width: 100px;
height: 100px;
border-radius: 50%;
background-color: red;
animation: move 3s infinite alternate linear;
}
.test2{
width: 100px;
height: 100px;
background-color: blue;
will-change: transform;
}
@keyframes move {
from {
transform: translateX(0);
}
to {
transform: translateX(300px);
}
}
</style>
</head>
<body>
<div class="test1">
</div>
<div class="test2">
</div>
</body>
</html>

元素 原因 原因
document is the document.rootScroller.
is a scrollable overflow element using accelerated scrolling.
作为 document.rootScroller 成为浏览器默认的滚动容器
test1 has an active accelerated transform animation or transtion. 正在执行 transform 相关的 CSS 动画/过渡
test2 has a will-change: transform compositing hint. will-change: transform 显式声明变化预期

7、绘制 Paint(下面的工作不是主线程来完成了)

将每一层的结果转化为一堆绘制指令集, 用于描述这一层内容该如何绘制。

8、分块 Tiling

完成绘制后, 主线程将每个图层的绘制信息提交给合成线程, 剩余工作交给合成线程来完成。

分块会将每一层分为多个小的区域(通常是 256x256 像素的 Tile), 分块的工作是交给多个线程同时进行的, 合成线程会从线程池启动多个分块线程来完成。

9、光栅化 Raster

合成线程会将块信息交给GPU进程, 以极高的速度完成光栅化。

GPU进程会开启多个线程来完成光栅化, 并且优先处理靠近视口的块。

光栅化的结果是一块一块的位图。

10、画 Draw

合成线程拿到每个层、每个块的位图后, 生成一个个指引(quad)信息。

指引会标识出每个位图应该画到屏幕的哪个位置, 以及会考虑到旋转和缩放等变形。

合成线程会把quad提交给GPU进程, 由GPU进程产生系统调用, 提交给GPU硬件, 完成最终的屏幕成像。

因为合成线程工作在渲染进程中, 并且渲染进程工作在沙盒环境, 所以需要先提交给GPU进程, 然后GPU进程再交给显卡渲染。

五、回流 reflow

reflow会影响布局, 它的本质就是重新计算Layout树。

当进行了影响布局树的操作后, 需要重新计算布局树, 为了避免连续的多次操作导致布局树反复计算, 游览器会合并这些操作, 当js代码全部执行完成后再进行统一计算。

所以JS获取布局属性的时候, 可能无法获取到最新的布局信息, 所以游览器决定获取布局属性的时候, 立即reflow。

六、重绘 repaint

repaint不会影响布局, 它的本质就是根据分层信息重新计算了绘制指令。

当改动了可见样式后, 就需要重新计算, 会引发repaint。

reflow一定会导致repaint。

七、transform

transform改变后只需要在合成线程画的过程中重新计算一下位置, 不影响主线程的使用, 所以transform不会阻塞主线程, 滚动条也是只需要重新计算一下绘画的位置, 所以主线程被阻塞也不会影响transform和滚动条的运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>title</title>
<style>
.ball1 {
display: flex;
justify-content: center;
align-items: center;
width: 100px;
height: 100px;
background-color: red;
border-radius: 50%;
position: absolute;
top: 50px;
left: 0;
animation: move1 3s infinite alternate linear;
}

.ball2 {
display: flex;
justify-content: center;
align-items: center;
width: 100px;
height: 100px;
background-color: blue;
border-radius: 50%;
position: absolute;
top: 200px;
left: 0;
animation: move2 3s infinite alternate linear;
}

@keyframes move1 {
from {
left: 0;
}
to {
left: 300px;
}
}

@keyframes move2 {
from {
transform: translateX(0);
}
to {
transform: translateX(300px);
}
}
</style>
</head>
<body>
<button onclick="wait(5000)">
点击阻塞主线程5s
</button>
<div class="ball1">
ball1
</div>
<div class="ball2">
ball2
</div>
<script>
function wait(time) {
let start = Date.now();
while (Date.now() - start < time) {}
}
</script>
</body>
</html>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>title</title>
<style>
h1{
animation: color 1s infinite alternate linear;
}
@keyframes color {
from {
background-color: red;
}
to {
background-color: blue;
}
}

</style>
</head>
<body>
<button onclick="wait(5000)">
点击阻塞主线程5s
</button>
<h1 style="text-align: center;">测试网站</h1>
<p>1</p>
<p>2</p>
<p>3</p>
<p>4</p>
<p>5</p>
<p>6</p>
<p>7</p>
<p>8</p>
<p>9</p>
<p>10</p>
<p>11</p>
<p>12</p>
<p>13</p>
<p>14</p>
<p>15</p>
<p>16</p>
<p>17</p>
<p>18</p>
<p>19</p>
<p>20</p>
<p>21</p>
<p>22</p>
<p>23</p>
<p>24</p>
<p>25</p>
<p>26</p>
<p>27</p>
<p>28</p>
<p>29</p>
<p>30</p>
<p>31</p>
<p>32</p>
<p>33</p>
<p>34</p>
<p>35</p>
<p>36</p>
<p>37</p>
<p>38</p>
<p>39</p>
<p>40</p>
<p>41</p>
<p>42</p>
<p>43</p>
<p>44</p>
<p>45</p>
<p>46</p>
<p>47</p>
<p>48</p>
<p>49</p>
<p>50</p>
<p>51</p>
<p>52</p>
<p>53</p>
<p>54</p>
<p>55</p>
<p>56</p>
<p>57</p>
<p>58</p>
<p>59</p>
<p>60</p>
<p>61</p>
<p>62</p>
<p>63</p>
<p>64</p>
<p>65</p>
<p>66</p>
<p>67</p>
<p>68</p>
<p>69</p>
<p>70</p>
<p>71</p>
<p>72</p>
<p>73</p>
<p>74</p>
<p>75</p>
<p>76</p>
<p>77</p>
<p>78</p>
<p>79</p>
<p>80</p>
<p>81</p>
<p>82</p>
<p>83</p>
<p>84</p>
<p>85</p>
<p>86</p>
<p>87</p>
<p>88</p>
<p>89</p>
<p>90</p>
<p>91</p>
<p>92</p>
<p>93</p>
<p>94</p>
<p>95</p>
<p>96</p>
<p>97</p>
<p>98</p>
<p>99</p>
<p>100</p>
<script>
function wait(time) {
let start = Date.now();
while (Date.now() - start < time) {}
}
</script>
</body>
</html>