浏览器是如何渲染页面的
当浏览器的网络线程收到 HTML 文档后,会产生一个渲染任务,并将其传递给渲染主线程的消息队列。在事件循环机制下,渲染主线程取出消息队列中的渲染任务,开启渲染流程。
整个渲染流程分为多个阶段:HTML 解析、样式计算、布局、分层、绘制、分块、光栅化、画。每个阶段都有明确的输入输出,上一个阶段的输出就是下一个阶段的输入,整个流程类似流水线一样。
下面针对每个阶段做详细的研究。
一、解析 HTML
解析的过程中遇到 CSS 便解析 CSS,遇到 JS 执行 JS。为了提高解析效率,浏览器会在开始解析器安,启动一个预解析线程,率先下载 HTML 中和外部 CSS、外部 JS 文件。
如果主线程解析到 link 标签,此时外部的 CSS 文件还没下载解析好,主线程不会等待,会继续解析后续的 HTML。这是因为下载和解析 CSS 的工作是在预解析线程中进行的。这就是 CSS 不会阻塞 HTML 解析的核心原因。
如果主线程解析到 script 位置,会停止解析 HTML,转而等待 JS 文件下载好,直到脚本加载和解析完成后,才能继续解析 HTML。这是因为 JS 代码执行过程可能会修改当前的 DOM,所以 DOM 树的生成必须暂停。这就是 JS 会阻塞 HTML 解析的根本原因。
第一步完成后,会得到 DOM 树和 CSSOM 树,浏览器的默认样式、内部样式、外部样式、内联样式均会包含在 CSSOM 树中
QA:
-
为什么需要解析成 DOM、CSSOM?HTML 本身是字符串,无法操作,抽象成一层 JS 文档对象模型,并暴露一些 API 给 开发者,方便去操作。
-
为什么 HTML、CSS 都有自己对应的 DOM、CSSOM,但 JS 没有自己对应的模型?因为 CSS、HTML 是需要经常变化的,后续的逻辑会经常操作,所以有自己对应的树形模型,JS 执行一次就结束了。
HTML 解析过程中遇到 CSS 怎么处理
为了提高解析效率,浏览器会启动一个预解析器率先下载和解析 CSS
包含哪些样式
HTML 解析会生成 DOM、CSSOM。StyleSheetList 是所有样式的集合,那包含哪些样式呢?
- 浏览器默认样式(比如 div 独自占一行)
- 内联样式
- 内部样式
- 外部样式 浏览器默认样式怎么体现。翻阅 Chromium 的源代码可以查看到浏览器为 html 设置的默认样式,针对 div 标签设置了
display: block
所以才独占一行,而不是因为是 div 所以就该占一行。
HTML 解析过程中遇到 JS 代码怎么办
浏览器渲染主线程遇到默认的 script 标签(不带 async、defer 修饰)则会暂停解析 HTML,等待 JS 下载并执行完毕后方可继续解析 HTML。
预解析线程可以分担下载 JS 的任务。
QA:为什么 JS 不能设计成像 css 那样一边解析,一边执行呢? JS 可能会操作当前 DOM,DOM 不是全部的 HTML 解析完才创建的,解析多少 HTML 创建多少 DOM,所以需要暂停 HTML 解析(类似生产者消费者模型,不加锁则可能存在问题。HTML 解析就是在生产 DOM,JS 脚本可能一边在读取 DOM,可能还在增、删 DOM 在消费 DOM)
为什么浏览器遇到 script 会暂停解析?
浏览器在解析 HTML 的时候,如果遇到一个没有任何属性的 script 标签,就会暂停 HTML 解析,先发送网络请求获取该 JS 脚本的内容,然后让 JS 引擎执行该代码。 当 JS 执行完毕后恢复 HTML 的解析,整个过程如下
解决方式:defer
和 async
都是异步加载的外部 JS 脚本,不会阻塞页面的解析,因此这2个方案都可以规避因为大量 JS 下载影响 HTML 解析的问题
async
async 表示异步,当浏览器遇到带有 async 的 script 时,浏览器会采用异步的方式去解析,不会阻塞浏览器解析 HTML,一旦网络请求完成后,如果此时 HTML 还没解析完的话,立即暂停 HTML 的解析工作,让 JS 引擎执行 JS 脚本,全部 JS 执行完毕后再去解析后面的 HTML。
如果遇到 async 修饰的 js 脚本,在异步下载好之后 HTML 已经解析完毕,也一样,直接执行 JS
defer
defer 也表示异步延迟,当浏览器遇到带有 defer 的 script 时,会异步获取该脚本,不会阻塞浏览器解析 HTML,一旦网络请求回来后,如果 HTML 解析还未完成,则会继续解析 HTML,直到 HTML 全部解析完成后才执行 JS 代码。
如果存在多个 defer 标签修饰的 script 标签,浏览器会按照 script 顺序执行,不会破坏脚本之间的依赖关系
二、样式计算
主线程会遍历得到的 DOM 树,依次为树中的每个节点计算出最终的样式,称为 Computed Style。 在这一过程中,很多预设值会变成绝对值,比如 red 会变为 rgb(255, 0, 0),相对单位也会变成绝对单位,比如 em 变成 px。这一步完成后会得到一颗带有样式的 DOM 树。
什么叫计算后的样式(Computed Style):指的是元素在渲染时所采用的最终样式。 样式计算主要包括:CSS 属性值的计算过程(层叠、继承...)、视觉格式化模型(盒模型、包含块、BFC...)
这个计算涉及到:
- 确定声明值
- 层叠冲突
- 使用继承
- 使用默认值
CSS 属性计算过程
css
<div>
<p>p</p>
</div>
没有修改p 标签的样式,却可以看到他有一个颜色、字体等样式。 该元素上会有 css 的所有属性。在 Chrome 审查元素模式下,勾选 Computed 下的 Show all,就可以看到非常多的属性如下图:
也就是说:我们开发的任何一个 HTML 元素,实际上在浏览器显示出来的时候都有一套完整的 css 样式。如果没有显示声明样式,大概率会采用默认值。
确定声明值
html
p {
color: red;
}
<div>
<h1>H1标题</h1>
<p>段落</p>
</div>
css 代码明确了 p 标签使用红色,那么浏览器展示就会按照此属性进行展示。
这种开发者写的代码,叫做作者样式表。一般浏览器还会存在用户代理样式表,可以认为是浏览器内置了一份样式表。
可以看到 p 标签在审查元素模式下,除了作者样式表中设置的 color 属性,其他的都采用了用户代理样式表的属性。比如:display...
层叠冲突
前面看了确定声明值,但可能存在一种情况,声明的样式规则发生冲突。 此时会进入解决层叠冲突的流程,分为3个步骤:
- 比较源的重要性
- 比如优先级
- 比较次序
比较源的重要性
当不同的 css 样式来源拥有相同的声明时,此时就会根据样式表来源的重要性来确定该用哪一条样式规则。那,有几种样式表来源:
- 浏览器会有一个基础的样式表来给任何网页设置默认的样式。该样式被称为:用户代理样式
- 网页的作者可以定义文档的样式,这是最常见的样式,被称为:页面作者样式
- 浏览器的用户,可以使用自定义样式表定制使用体验。被称为用户样式
重要性的顺序为:页面作者样式 > 用户样式 > 用户代理样式 假设现在有页面作者样式表和用户代理样式表中存在属性冲突,那会以页面作者样式表优先。
html
p {
color: red;
display: inline-block;
}
<div>
<h1>H1标题</h1>
<p>段落</p>
</div>
可以看到 p 标签的 display 属性,在页面作者样式表和用户代理样式表中同时存在时,根据源的重要性判断,最终页面作者样式表优先级更高,display 采用页面作者样式表中的属性值。
比较优先级
如果同一个源中,样式声明一样的情况下,如何决策?此时进入了样式声明的优先级比较。
html
.container p {
color: #00ff00;
}
p {
color: red;
display: inline-block;
}
<div class="container">
<h1>H1标题</h1>
<p>段落</p>
</div>
可以看到,在上面的 css 代码中,都在页面作者样式表同一个源中,源一样,解下去根据选择器的权重来比较重要性。 根据选择器权重规则(ID 选择器 > 类选择器 > 类型选择器)来判断,单独一个标签选择器的 color 属性会被打败。
关于选择器的比较策略可以查看 MDN CSS Specificity
比较次序
当样式表同源,权重相同的情况下,进入第三阶段:比较声明的顺序
html
.container p {
color: #00ff00;
}
.container p {
color: #0000ff;
}
<div class="container">
<h1>H1标题</h1>
<p>段落</p>
</div>
都处于页面作者样式表中,选择器的权重也相同,但根据所处位置的不同,下面的样式声明会覆盖上面的值,最终采用 #0000ff。
样式声明冲突的情况解决了
使用继承
层叠冲突这一步完成后,解决了相同元素被声明的多条样式规则命中后,到底该采用哪一条样式规则的问题。 那假设一个元素没有声明的属性,该使用什么属性值?会存在继承这个策略。
html
.container {
color: #00ff00;
}
<div class="container">
<h1>H1标题</h1>
</div>
只对类名为 container color 进行了设置,针对 p 标签没有任何的设置,但由于 color 可以继承,所以 p 就从最近的 div 继承了颜色。
看另一个现象
html
.container {
color: blue;
}
.innerContainer {
color: red;
}
<div class="container">
<div class="innerContainer">
<h1>H1标题</h1>
</div>
</div>
这里继承了 container、innerContainer 2个的属性值,说了继承会选择更近的一个,innerContainer 胜出。
使用默认值
如果前面的步骤都走了,但属性值还没确定下来,就只能选用默认值了。
html
<div>
<h1>H1标题</h1>
</div>
任何一个元素要在浏览器上渲染出来,必须具备所有的 css 属性值,但很多属性我们没有去设置,用户代理样式表中也没有设置,也无法从继承中拿到,因此最终都是使用默认值的。
三、布局 - layout
布局完成后会得到布局树。 布局阶段会依次遍历 DOM 树的每一个节点,计算每个节点的几何信息,比如节点的宽高、相对包含块的位置。 元素的尺寸和位置,会受它的包含块所影响。对于一些属性,例如 width, height, padding, margin,绝对定位元素的偏移值(比如 position 被设置为 absolute 或 fixed),当我们对其赋予百分比值时,这些值的计算值,就是通过元素的包含块计算得来。
大部分时候,DOM 树和 Layout 树并非一一对应的,为什么?
比如某个节点的 display 设置为 none,这样的节点就没有几何信息,因此不会生成到 layout 树。有些使用了伪元素选择器,虽然 DOM 树中并不存在这些伪元素节点,但它们需要显示在浏览器上,所以会生成到 layout 树上。还有匿名行盒、匿名块盒等等都会导致 DOM 树和 layout 树无法一一对应。
html
div::before {
content: '';
}
<div></div>
四、分层 Layer
浏览器拿到布局后的 layout tree 会思考一些事情。大多数页面不是绘制后静止的,经常会有一些动画、用户点击后一些交互处理等,需要经常刷新页面。但如果整个页面直接重新刷新,这个性能开销是很大的。能不能提高效率?能,于是现在浏览器支持了分层。
主线程会使用一套复杂的策略对整个布局树进行分层。 分层的好处在于,将来在某一个层改变后,仅会对该层进行后续处理,从而提高效率。
滚动条、堆叠上下文有关的(z-index、transform、opacity )样式都会或多或少影响分层结果,也可以通过 will-change 属性更大程度的影响分层的结果。
html
<div>
100个<p>Lorem</p>
</div>
可以看到默认情况下浏览器对该代码分了2层。为什么滚动条需要单独设置一个 layer?因为100个 p 标签一定会超出一屏,所以会存在滚动条,且用户可能会高频滚动去查看内容,为了高效渲染,所以将滚动条单独设置了一个层。
假设我们已经通过技术手段得知某些内容经常会改变,但直接页面整体重刷会很耗费资源,那如何只对某一块区域设置一个 layer 呢?通过 will-change: transform
告诉浏览器,这块区域的 transform 属性经常会变。
will-change
是一个用于通知浏览器某个元素即将发生变化的CSS属性。它可以被应用到任何元素上,用于提前告知浏览器该元素将要有哪些属性进行改变,从而优化渲染性能。
通过在元素上设置will-change
属性,开发者可以明确指示浏览器对该元素进行优化处理。这样一来,浏览器可以提前分配资源和准备工作,以便在实际改变发生之前进行相应的合成操作。这样做有助于避免不必要的重绘和重排,提高页面的响应速度和动画的流畅度。
will-change
属性可以接受多个属性值,表示将要改变的属性。例如,will-change: transform
表示元素即将进行变形操作,will-change: opacity
表示元素的透明度即将发生变化。
需要注意的是,will-change
属性应在实际变化发生前的一段时间内被设置,以便浏览器有足够的时间进行准备和优化。另外,will-change
属性并不会自动触发硬件加速,但它可以为浏览器提供一种优化渲染的提示。
此外,will-change
属性的使用应谨慎,避免滥用。只有在明确知道元素即将发生某种变化,并且这种变化对性能有影响时,才应使用will-change
属性。过度使用will-change
属性可能会导致浏览器进行不必要的优化,反而降低性能。
总的来说,will-change
属性是一个用于优化渲染性能的CSS属性,通过提前告知浏览器元素即将发生的变化,使浏览器能够提前进行准备和优化,从而提高页面的响应速度和动画的流畅度
Demo:
css
div {
will-change: transform;
width: 200px;
background-color: red;
margin: 0 auto;
}
通过上面的 css 设置,告诉浏览器:这个 div 的 transform 经常会变,浏览器会为该元素创建一个独立的图层,将这个图层标记为"即将变换"。这样,在进行布局和绘制时,浏览器就可以更高效地处理这个元素,而无需重新计算整个渲染树。
五、绘制 - Paint
第四步的产出就是分层。第五步绘制阶段,会分别对每个层单独产生绘制指令集,用于描述这一层的内容该如何画出来(类似 canvas 代码,告诉计算机从哪个点经过中间几个点,最后到哪个点绘制一条线,中间内容用什么颜色填充)
渲染主线程的工作到此为止,剩余步骤交给其他线程完成。
六、分块 - Tiling
完成绘制之后,主线程将每个图层的绘制信息提交给合成线程,剩余的工作将由合成线程完成。
合成线程首先对每个图层分块(Tiling),将其划分为更多的小区域。合成线程类似一个任务调度者,它会从线程池中拿出多个线程来完成分块的工作。
QA:浏览器渲染过程中分块的作用是什么?
-
提高渲染性能:当浏览器需要渲染一个复杂的网页时,如果一次性将所有内容加载并渲染出来,可能会消耗大量的计算资源和时间,通过网页分成多个较小的块(tiling)浏览器可以并行地加载和渲染这些块,从而充分利用计算资源,提高渲染性能。
-
优化内存占用:如果浏览器一次性加载和渲染整个网页,可能会消耗大量的内存。通过将网页分成多个图块,浏览器可以更好的管理内存,避免不必要的内存消耗
-
实现懒加载:通过分块,浏览器可以实现懒加载,即只有当某个图块进入视口可见区域时,才开始加载和渲染该图块。这样可以减少不必要的加载和渲染,提高页面的加载速度和性能
-
方便进行并行处理和异步操作:通过将网页分成多个图块,浏览器可以更容易地进行并行处理和异步操作。例如,在移动端设备上,可以利用 GPU 进行图块的并行渲染,提高渲染效率。
分块 tiling 技术是浏览器渲染过程中提高性能、优化内存使用和实现懒加载的重要手段之一。
七、光栅化
分块完成后会进入光栅化阶段。光栅化是将每个块变成位图。 合成线程将分块后的块信息交给 GPU 进程,以极高的速度完成光栅化,并优先处理靠近视口的块。
八、画 - Draw
合成线程计算出每个位图在屏幕上的位置,交给 GPU 进行最终的呈现。
合成线程拿到每个层(Layer)、每个块(Tile)的位图(bitmap)后,生成一个个指引(quad)信息。指引信息标识出每个位图应该画到屏幕上的哪个位置,此过程会考虑到旋转、缩放、变形等。
因为变形发生在合成线程,与渲染主线程无关,这也是 transform 效率高的本质原因。 合成线程会把 quad 提交给 GPU 进程,由 GPU 进程产生系统调用,提交给 GPU 硬件,完成最终的屏幕显示
完成过程如下
reflow
比如某个业务逻辑,导致我们用 js 脚本修改了某个节点的宽度、颜色,这其实修改的是 cssom,假设业务逻辑导致操作了 DOM,DOM 节点增删了,cssom、DOM 改变了,则会触发一系列的的流程,比如重新计算样式 style、布局(layout)、分层 layer、绘制 paint、分块 tiling、光栅化 raster、画 draw、系统调用 GPU 去真正显示。
当渲染树 render tree 中的一部分(或者全部)因为元素的尺寸、布局、颜色、隐藏等改变而需要重新构建,这就情况被称为回流。图上的 style 这部分。
回流发生时,浏览器会让渲染树中受到影响的部分失效,并重新构建这部分渲染树。完成回流 reflow 后,浏览器会重新绘制受影响的部分到屏幕中,该过程称为重绘。
简单来说,reflow 就是计算元素在屏幕上确切的位置和大小并重新绘制。回流的代价远大于重绘。回流必定重绘,重绘不一定回流。
repaint
当渲染树 render tree 中的一些元素需要更新样式,但这些样式只是改变元素外观、风格,而不影响布局(位置、大小),则叫重绘。 简单来说,重绘就是将渲染树节点转换为屏幕上的实际像素,不涉及重新布局阶段的位置与大小计算
QA:为什么 transform 效率高? 假设在 css 中针对某个元素写了 transform: rotate(100deg)
,transform 既不会影响布局也不会影响绘制指令,它影响的是渲染流程的最后一个阶段,图上的 draw 阶段。由于 draw 阶段发生在合成线程中,不在渲染主线程,所以 transform 的任何变化几乎不会影响主线程。同样,不管主线程如何繁忙,都不影响合成线程中 transform 的变化。