引: 在浏览器地址栏输入URL到页面展示,这中间的大致过程为:
- 用户输入URL并回车;
- 1.1 如果没有监听
onbeforeunload
事件,进入流程2 - 1.2 如果有监听
onbeforeunlaod
事件,则执行其内部逻辑,若用户同意此次导航,进入流程2,若拒绝直接返回。
- 1.1 如果没有监听
- 浏览器进程解析输入内容:
- 2.1 如果识别输入的是一个URL,就尝试请求该URL
- 2.2 否则视为搜索关键字,浏览器尝试将关键字发送给默认的搜索引擎(合成新的URL并请求)
- URL请求过程,浏览器进程通过进程间通信(IPC)把URL请求发送给网络进程,网络进程接收到URL请求后,检查本地缓存是否命中该请求资源:
- 3.1 如果命中,则将该缓存资源直接返回给浏览器进程,进入流程6
- 3.2 如果没有命中,进入流程4
- 网络进程向web服务器发起HTTP请求,请求流程如下:
- 4.1 进行DNS解析,获取服务器IP地址和端口
- 4.2 利用IP地址和服务器建立TCP连接
- 4.3 构建请求行、请求头(请求体)信息,附加Cookie
- 4.4 发送请求信息,服务器根据浏览器的请求信息准备相应的内容(响应行、响应头和响应体)发送给网络进程
- 网络进程接收并解析响应数据:
- 5.1 服务器返回响应行(协议版本和状态码)
- 5.2 检查状态码,如果是301/302
(301永久重定向:将原始请求的缓存标记为永久失效,立即更新缓存,再次遇到此请求,直接打到重定向之后的URL,不会再经过重定向流程,302临时重定向:将原始请求的缓存标记为临时失效,浏览器会更新缓存,但仍保留原始请求的缓存标识)
,则需要重定向,从Location字段中读取地址,重新进行流程3,如果是200,则继续处理请求。 - 5.3 检查响应类型Content-Type,如果是字节流类型,则将该请求提交给下载管理器,该导航流程结束,不再进行 后续的渲染,如果是text/html,则通知浏览器进程 准备渲染进程(准备进行渲染)。
- 准备渲染进程
- 6.1 网络进程读取响应头数据,将其转发给浏览器进程,浏览器接收到网络进程的响应头数据后,携带响应头等基本信息发送CommitNavigation到渲染进程,让其准备接收数据(此时文档数据还在网络进程中);
- 提交文档阶段
- 7.1 浏览器进程将网络进程接收到的响应头数据提交给渲染进程(提交文档消息),渲染进程收到消息后和网络进程建立传输数据的“管道”
- 7.2 渲染进程接收完数据后,向浏览器进程返回“确认提交”消息
- 7.3 浏览器进程接收到“确认提交”消息后,更新浏览器界面状态(包含安全状态、地址栏URL、前进后退的历史状态)并更新web页面。
- 渲染阶段
- 解析HTML、CSS、Javascript数据,和子资源加载,将页面数据交给浏览器进程,完成页面显示。
截至渲染阶段,相当于浏览器已经获取到了HTML数据,渲染阶段就是把这些资源数据变成页面的过程。
在此之前,先简单了解HTML、CSS和JS的含义:
HTML(HyperText Markup Language)的内容由标记和文本组成。标记也称为标签,每个标签都有自己的语义,浏览器会根据标签的语义来正确展示HTML内容。(比如上面<p>
标签是告诉浏览器在这里的内容需要创建一个新段落,中间的文本信息就是段落中需要显示的内容)
如果需要改变HTML的文本颜色、大小等信息,就需要用到CSS。CSS(Cascading Style Sheets)是层叠样式表,是由选择器和属性组成。(如图中的p
选择器,它会把HTML里面<p>
标签的内容选择出来,然后再把选择器的属性值应用到<p>
标签内容上。选择器里面有个color属性,它的值是red,就是告诉渲染引擎把<p>
标签内容显示为红色)
至于 JS(Javascript),可以使用它让网页的内容产生变化。(如上图,可以通过JS来修改CSS样式值,从而让文本颜色变为灰色)
渲染流程是一个十分复杂的过程,在执行阶段会被划分为很多子阶段,输入的HTML等资源经过这些子阶段的处理,最终输出位图,用于页面的成像。整个这一处理流程可以称为渲染流水线。
渲染流水线可以分为如下子阶段: 构建DOM树、样式计算、布局阶段、分层、绘制、分块、光栅化和合成。
每个子阶段:
- 在开始,都有其输入内容;
- 然后有其处理过程;
- 最终,产生输出内容;
1. 构建DOM树
浏览器无法直接理解和使用HTML,所以需要将HTML转换为浏览器能够理解的结构--DOM树。顾名思义,它是一个树形结构,其构建过程大致如下图:
其 输入内容 为 HTML文件;
经由 HTML 解析器解析;
最终,输出内容为树形结构的DOM。
可以通过开发者工具,在控制台输入“document”来直观的感受DOM树结构。
DOM 和 HTML 内容几乎是一样的,不同的是,DOM是保存在内存中的树状结构。可以通过Javascript 来查询或修改其内容。
document.getElementsByTagName("p")[0].innerText = "black"
这行代码的作用就是把第一个<p>
标签的内容修改为black,执行完毕后,界面如下图所示:
从图中可见,在执行了JS代码后, DOM的第一个p
节点的内容被修改,同时页面内容有被修改。
2. 样式计算
有了DOM树, 但是DOM节点的样式并不清楚,要让DOM节点拥有正确的样式, 就需要样式计算。
样式计算的目的是为了计算出DOM节点中每个元素的具体样式,这个阶段大致可分为三步。
2.1 把 CSS 转换为浏览器能够理解的结构
CSS样式的来源主要有三种:
- 通过link引用的外部CSS文件;
<style>
标签内的CSS;- 元素的 style 属性内嵌的CSS;
和HTML文件一样,浏览器无法直接理解这些纯文本的CSS样式, 当渲染引擎接收到CSS文本时,会执行一个转换操作,将CSS文本转换为浏览器可以理解的结构 -- styleSheets。
相应地,可以在控制台输入“document.styleSheets”查看其结构。
样式表包含了很多样式,已经把三种来源的样式都包含进去了。且该结构同时具备查询和修改样式功能。
2.2 转换样式表中的属性值,使其标准化
至此,从结构上,浏览器可以理解styleSheets,但是一些属性值为了让开发者易读而不被浏览器理解。
body { font-size: 2em }
p {color:blue;}
span {display: none}
div {font-weight: bold}
div p {color:green;}
div {color:red; }
如上面代码中的 2em、blue、bold。需要将这些值转换为渲染引擎容易理解的值, 这个过程就是属性值的标准化。
最终,2em被解析成32px, red被解析成rgb(255,0,0),bold被解析成700...
2.3 计算出DOM树中每个节点的具体样式
DOM节点的样式是根据CSS的继承规则和层叠规则来计算的。
2.3.1 CSS 样式继承
CSS 继承就是每个DOM节点都包含有父节点的样式。
body { font-size: 20px }
p {color:blue;}
span {display: none}
div {font-weight: bold;color:red}
div p {color:green;}
这张样式表最终应用到DOM节点的效果如图:
可以看出, 所有子节点都继承了父节点样式,比如body节点的font-size属性是20,body节点下的所有子节点的font-size都是20。这就是样式计算的继承特性。
可以通过Chrome开发者工具,查看 Elements 标签的 Styles 子标签
- 可以通过点击区域1中的任意元素,来查看其元素的样式(区域2),如选择的元素是
<p>
标签,位于html.body.div这个路径下; - 可以从样式来源(区域3)中查看样式的具体来源(源于样式文件,还是UserAgent样式表)。其中UserAgent样式是浏览器提供的一组默认样式,如果未提供任何样式,则默认使用的就是UserAgent样式。
- 结合区域2和区域3 可以查看样式继承的具体过程。
2.3.2 CSS 样式层叠
层叠规则是CSS基本特性,在CSS中处于核心地位(CSS全称“层叠样式表”正是强调了这一点)。它指定了在应用多个样式定义到同一个元素时,如何确定最终的样式结果。这些规则决定了样式的优先级和应用顺序。
层叠的规则如下:
- 选择器特异性(Specificity): 当多个选择器都应用到同一个元素时,选择器特异性用于确定哪个样式具有更高的优先级。特异性通过计算选择器中的各个部分的权重来决定,例如类选择器的特异性低于id选择器。
- 内联样式(inline Styles): 是通过在HTML元素的style属性中直接定义样式。内联样式具有最高优先级,会覆盖其他类型的样式定义。
- 内部样式表(internal styleSheets): 是在HTML文档的
<head>
标签内使用<style>
标签定义的样式表。内部样式表的优先级高于外部样式表。 - 外部样式表(external styleSheets): 是通过
<link>
标签或@import
规则引入的外部CSS文件。外部样式表的优先级低于内联样式和内部样式表。 - 样式规则顺序:当存在相同特异性的样式规则时,后面的规则会覆盖前面的规则。
- !important:通过在样式规则后添加 !important 声明,可以将该规则的优先级提升到最高。但是,滥用 !important 可能会导致样式难以维护和调试,应谨慎使用!
样式计算阶段的目的是为了计算出DOM节点中每个元素的具体样式,在计算过程中需要遵守CSS的继承和层叠两个规则。这个阶段 最终输出的内容是每个DOM节点的样式,并被保存在ComputedStyle的结构内。
可以通过Chrome开发者工具,查看 Elements
标签的 Computed
子标签来查看最终的计算样式。
问: 如果下载 CSS 文件阻塞了,会阻塞 DOM 树的合成吗?会阻塞页面的显示吗?
阻塞DOM树的合成
当从服务器接收HTML页面的第一批数据时,DOM解析器就开始工作了,在解析过程中,如果遇到了JS脚本,那么DOM解析器会先执行JavaScript脚本,执行完成之后,再继续往下解析。如果这中间遇到需要下载的js或css文件,则先要下载,再执行,再解析。
阻塞页面的显示
在现代浏览器中,通常有一个默认的样式表(User Agent stylesheet),用于为那些还没有加载或解析CSS的元素提供基本的样式。这意味着即使CSS文件被阻塞,页面上的元素也不会完全没有样式,而是使用浏览器提供的默认样式显示。
优化建议
优化CSS文件
:压缩CSS文件大小,减少HTTP请求,例如通过合并多个CSS文件为一个。使用媒体查询
:通过媒体查询加载非关键CSS,例如打印样式或者大屏幕设备的样式,从而确保基础样式的快速加载。异步加载CSS
:使用<link rel="stylesheet" async>
标签来异步加载CSS文件,这样即使CSS文件下载被阻塞,也不会影响HTML的解析和DOM树的构建。关键渲染路径优化
:识别并优先加载对首屏渲染最重要的CSS资源,以最小化页面渲染的延迟。使用相对路径
:尽量使用相对路径而不是绝对路径来引用CSS文件,这样可以减少DNS查询的时间。
然而,如果CSS文件的下载被长时间阻塞,页面的最终样式将无法应用,这会导致页面在加载过程中显示为默认样式,直到CSS文件被成功加载和解析。这种情况下,用户可能会看到一个“闪烁”的效果,即页面从默认样式突然转变为最终样式。
3. 布局阶段
经过前两个阶段,DOM树以及DOM树中元素的样式已经有了,接下来是确定DOM树中可见元素的几何位置,这个过程叫做布局。
3.1 创建布局树
DOM树中含有很多不可见的元素,比如 head
标签,还有使用了 display:none
属性的元素。在显示之前,我们还需要额外地构建一棵只包含可见元素的布局树。
DOM树中所有不可见的节点都没有包含在布局树中。(布局树是DOM树的子集)
为了构建布局树,浏览器大致完成了这些工作:
- 遍历DOM树中的所有可见节点,并把这些节点加到布局树中;
- 不可见的节点会被布局树忽略掉
3.2 布局计算
获取到完整的布局树后,就要计算布局树各节点的坐标位置和大小了。浏览器会从布局树的顶部开始,递归地计算每个元素的布局信息
- 对于块级元素,浏览器会根据元素的
width
、height
、margin
、padding
、border
等属性计算其大小和位置。 - 对于行内元素,浏览器会根据元素的字体大小、字体宽度和其他相关样式属性来确定它们的大小,并按照文档流进行排列。
Chrome在老的布局框架中,Blink使用一种称为“可变树”的布局系统,在这个系统里,输入和输出并不分离。
新的布局框架LayoutNG中引入了不可变的 “片段树”,明确的区分出了输入和输出结果,从而允许在增量布局时重用布局树中的大部分节点
4. 分层
有了布局树,每个元素的具体位置信息也计算出来了,是否可以直接开始着手绘制页面了?
答案是否定的。
因为页面中有很多复杂的效果,如一些复杂的3D变换、页面滚动或者使用z-indexing做z轴排序等,为了更加方便地实现这些效果(或者说以最高性能标准去实现这些效果),渲染引擎还需要为特定的节点生成专用的图层,并生成对应的图层树(LayerTree)。
可以通过Chrome开发者工具,选择“Layers”标签,就可以可视化页面的分层情况。
渲染引擎给页面分了很多图层,这些图层按照一定顺序叠加在一起,就形成了最终的页面。
这些图层与布局树节点之间的关系如下:
通常,并不是布局树中的每一个节点都包含一个图层,如果一个节点没有对应的图层,那么这个节点就从属于父节点的图层。
那么满足什么条件,渲染引擎才会为特定节点创建新的图层呢?
-
第一点,拥有层叠上下文属性的元素会被提升为单独的一层。
页面是个二维平面,但是层叠上下文能够让HTML元素具有三维概念,这些HTML元素按照自身属性的优先级分布在垂直于这个二维平面的z轴上。
如拥有position属性、z-index属性、filter属性、opacity不为1的元素等,可能会被创建为单独图层(以便它们可以独立于其他元素进行渲染和合成)。
-
第二点,需要裁剪的地方也会被创建为图层。
当我们限定div大小,而div内的文字内容又比较多时,文字所显示的区域超出限定大小,这时就会产生裁剪,渲染引擎会把裁剪文字内容的一部分用于显示在div区域。为文字部分单独创建一个层,如果出现滚动条,滚动条也会被提升为单独的层。
- 其他使用硬件加速的属性(transform、perspective)、关键帧动画、video元素、网页的root节点 这些也可能会创建更多的单独的图层。
5. 图层绘制
完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制。绘制的过程遵循画家算法。即由远及近,从离屏幕最远的图层开始,依次绘制。
渲染引擎将图层的绘制拆分成很多小的绘制指令, 然后再把这些指令按照顺序组成一个待绘制列表。
绘制一个元素通常需要好几条绘制指令,因为每个元素的背景、前景、边框
都需要单独的指令去绘制。所以在图层绘制阶段,输出的内容就是这些待绘制列表。
可以通过Chrome开发者工具的 Layers
标签,选择 document
层,来实际感受绘制列表。
区域1就是 document 的绘制列表, 拖动区域2中的进度条可以重现列表的绘制过程。
6.栅格化(raster)
绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上的绘制操作是由渲染引擎中的合成线程
来完成的。下图揭示了渲染引擎主线程和合成线程之间的关系。
当图层的绘制列表准备好之后,主线程会把该绘制列表提交(commit)给合成线程。
那么合成线程如何工作呢?
通常一个页面可能很大,但是用户只能看到其中的一部分,剩余部分需要滚动才能看到,用户能看到的区域称为 视口(viewport)
如果页面需要滚动很久才能到底部,要绘制出所有图层内容的话,开销会很大,也没有必要,所以通常,合成线程会将图层划分为图块
(tile),然后优先将视口附近的图块生成位图。
实际生成位图的操作是由栅格化
来执行的,所谓栅格化,就是指将图块转换为位图的过程。图块是栅格化执行的最小单位。渲染进程维护了一个栅格化的线程池,所有图块栅格化都是在线程池内执行的。
通常,栅格化过程会使用GPU来加速生成,使用GPU生成位图的过程叫快速栅格化,或者GPU栅格化,生成的位图保存在GPU内部中。
GPU操作是运行在GPU进程中的,这其中还涉及跨进程操作。
如图, 渲染进程把生成图块的指令发给GPU, GPU中执行生成图块的位图,并保存在GPU的内存中。
7. 合成和显示
一旦所有的图块都被光栅化,合成线程就会生成一个绘制图块的命令-“DrawQuad”,然后将该命令提交给浏览器进程。
图块光栅化过程通常是逐步进行的,而不是等待所有图块全部完成才提交渲染。
在合成过程中,每个图层都会被光栅化,浏览器会优先光栅化视口(viewport)附近的图块,这样可以更快地将内容显示给用户。
现代浏览器通常采用异步渲染策略,这意味着浏览器会根据优先级和资源可用性来决定光栅化的顺序。如果视口附近的图块已经光栅化并准备就绪,浏览器可以立即将它们渲染到屏幕上,而不需要等待整个页面的所有图块都完成。当页面上的元素发生变化,或者用户与页面交互时(如滚动、缩放等),浏览器会更新布局树和图层,并重新进行光栅化和合成,以反映这些变化。
浏览器进程中有一个叫viz(Visuals)的组件,用来接收合成线程发送过来的DrawQuad命令,然后将其页面内容绘制到内存(显存)中,最后将其显示在屏幕上。
在每一帧的工作周期中,浏览器会根据当前的图层状态和任何新的变化来更新图层,然后将其合成到一起。这个过程是异步的,并且通常会在浏览器的主渲染线程之外进行,以避免阻塞页面的交互。这包括:
- 帧同步:
- 浏览器会根据显示器的刷新率(通常是60Hz)来同步图层的更新。
- 这意味着浏览器会尝试每16.67毫秒(大约)渲染一帧,以匹配显示器的刷新周期。
- 帧开始:
- 在每一帧的开始,浏览器会检查是否有待处理的动画、滚动或其他需要更新的操作。
- 如果有,浏览器会触发相应的JavaScript事件(如
requestAnimationFrame
)来更新页面内容。
- 帧更新:
- 浏览器会根据需要更新图层的内容,这可能包括重新计算布局、应用样式更改、处理用户交互等。
- 更新操作会触发Layer Compositor 对图层进行重新绘制和光栅化。
- 帧提交:
- 一旦图层更新完成,浏览器会将它们提交给Layer Compositor 进行合成。
- Layer Compositor 会将所有更新的图层合并成最终的页面视图。
- 帧显示:
- 合成后的页面视图会被发送到显示器,用户看到的是更新后的页面。
- 如果浏览器能够稳定地保持每16毫秒渲染一帧,用户会感受到流畅的动画和交互。
至此,从Web服务器上获取到的HTML,CSS,Javascript等文件,经过整个渲染流水线的处理,最终显示出完整页面。
一个完整的渲染流程大致可总结为如下:
-
- 渲染进程将HTML内容转换为浏览器能够识别的 DOM树 结构;
-
- 渲染引擎将CSS样式表转换为浏览器可以理解的styleSheets,并计算出DOM节点的样式;
-
- 创建布局树,并计算元素的布局信息;
-
- 对布局树进行分层,生成图层树;
-
- 为每个图层生成绘制列表,并提其提交到合成线程;
-
- 合成线程将图层分为图块,并在光栅化线程池中将图块转换成位图;
-
- 合成线程发送绘制图块命令DrawQuad给浏览器进程;
-
- 浏览器进程根据DrawQuad消息生成页面,并显示到显示器上。
重排、重绘和合成
1. 重排 - 更新了元素的几何属性
如果通过 JS 或者 CSS 修改元素的几何位置属性,如改变元素的宽度、高度等,那么浏览器会触发重新布局,解析从布局阶段开始的后续一系列子阶段,此过程就叫做重排。重排需要更新完整的渲染流水线,所以开销很大。
2. 重绘 - 更新元素的绘制属性
如果通过JS更改某些元素的背景颜色,布局阶段并不会执行,但是会触发渲染流水线绘制及之后的一系列阶段,此过程就叫做重绘。相较于重排,重绘省去了布局和分层阶段,所以执行效率会比重排操作要高一些。
3. 直接合成阶段
如果更改一个既不要布局也不要绘制的属性,如使用CSS的 transform 来实现动画效果,渲染引擎将跳过布局和绘制,只执行后续的合成操作,这个过程就叫做合成。
该过程直接在非主线程上执行动画操作,并没有占用主线程资源,所以,相对于重排和重绘,合成性能效率大大提升。
4. 如何减少重排和重绘
重排(Reflow)和重绘(Repaint)是Web页面性能优化中的两个重要概念,这两个过程都是资源密集型的,可能导致页面卡顿或性能下降,减少重排和重绘,相当于减少了渲染进程的主线程和非主线程的很多计算和操作,对于性能要求较高的页面, 需要适当规避重排和重绘。
- 避免频繁的样式变更:
频繁改变元素的样式属性(尤其是影响布局的属性,如width
、height
、margin
、padding
等)会导致浏览器不断进行重排。尽量减少这些属性的更改次数,或者将它们合并到一次操作中(如:1)使用class来集中修改样式,而不是通过style一个个的修改;2)对DOM属性的读写要分离)。div.style.left = '10px';// 写 console.log(div.offsetLeft);// 读 会触发立即执行渲染队列的任务 div.style.top = '10px'; console.log(div.offsetTop); div.style.width = '20px'; console.log(div.offsetWidth); div.style.height = '20px'; console.log(div.offsetHeight); // 以上代码会触发4次重排+重绘 修改为读写分离操作,触发一次重排 div.style.left = '10px'; div.style.top = '10px'; div.style.width = '20px'; div.style.height = '20px'; console.log(div.offsetLeft); console.log(div.offsetTop); console.log(div.offsetWidth); console.log(div.offsetHeight);
- 慎用table布局:
1) 由于浏览器使用流式布局,对 Render Tree 的计算通常只需要遍历一次就可以完成,但 Table 及内部元素除外,通常需要多次计算且需花费3倍同等元素时间。
2) 其次,很小的改动可能会导致整个 table 都重新布局。 - 批量DOM操作:
例如createDocumentFragment
,或者使用框架,例如 React:批量操作 DOM 可以减少重排重绘的次数。 - Debounce window resize 事件:
防抖可以防止频繁触发重排重绘,例如 window resize 可以设置为 1s 内只可以触发一次。 - 使用
transform
代替top
和left
:
使用transform
属性进行元素的移动和定位可以避免触发重排,因为transform
通常只会引起重绘。 - 优化JavaScript动画:
在动画中使用requestAnimationFrame
来确保动画在浏览器的绘制周期中运行,这样可以减少不必要的重绘。 - 使用
will-change
属性:
will-change
属性可以告诉浏览器哪些元素可能会发生变化,浏览器可以预先准备这些变化,减少实际变化时的重排和重绘。 - 合理使用
display: none
和visibility: hidden
:
当需要隐藏元素时,使用display: none
可以避免元素占据布局空间,从而减少重排。而visibility: hidden
则会使元素不可见但仍占据空间。根据具体情况选择最合适的方法。 - 避免使用绝对定位:
绝对定位的元素可能会导致重排,因为它们的位置是相对于最近的已定位祖先元素。尽量使用相对定位或CSS Grid和Flexbox等现代布局技术。 - 使用CSS3属性:
CSS3提供了许多新的属性,如flexbox
和grid
,它们提供了更高效的方式来进行布局,可以减少重排的发生。 - 优化DOM结构:
减少DOM元素的数量和深度可以降低重排的复杂性。合并DOM节点、使用更少的嵌套和优化HTML结构都有助于提高性能。 - 使用开发者工具进行性能分析:
使用浏览器的开发者工具(如Chrome的Performance面板)可以帮助你识别哪些操作导致了重排和重绘,从而进行针对性的优化。