浏览器渲染页面的过程
一个问题
从用户输入浏览器输入url到页面最后呈现 有哪些过程?考的是基本网络原理,和浏览器加载 css,js 过程。答案大致如下:
- 用户输入URL地址
- 浏览器解析URL解析出主机名
- 浏览器将主机名转换成服务器ip地址(浏览器先查找本地DNS缓存列表 没有的话 再向浏览器默认的DNS服务器发送查询请求 同时缓存)
- 浏览器将端口号从URL中解析出来
- 浏览器建立一条与目标Web服务器的TCP连接(三次握手)
- 浏览器向服务器发送一条HTTP请求报文
- 服务器向浏览器返回一条HTTP响应报文
- 关闭连接 浏览器解析文档
如果文档中有资源 重复6 7 8 动作 直至资源全部加载完毕
以上答案基本简述了一个网页基本的响应过程背后的原理。
但这只是浏览器获取数据的部分,至于浏览器拿到数据之后,怎么渲染页面的呢。
- 浏览器对内容的渲染过程:(渲染树构建、布局及绘制)
- HTML解析出DOM Tree
- CSS解析出Style Rules
- 将二者关联生成Render Tree
- Layout 根据Render Tree计算每个节点的信息
- Painting 根据计算好的信息绘制整个页面
这是一个渐进的过程。为达到更好的用户体验,呈现引擎会力求尽快将内容显示在屏幕上。它不必等到整个 HTML 文档解析完毕之后,就会开始构建呈现树和设置布局。在不断接收和处理来自网络的其余内容的同时,呈现引擎会将部分内容解析并显示出来。
一、HTML解析
HTML Parser的任务是将HTML标记解析成DOM Tree。(文档对象模型)
这个解析可以参考React解析DOM的过程,但是这里面有很多别的规则和操作,比如容错机制,识别</br>
和<br>
等等。
例如:
<html>
<head>
<title>Web page parsing</title>
</head>
<body>
<div>
<h1>Web page parsing</h1>
<p>This is an example Web page.</p>
</div>
</body>
</html>
将文本的 HTML 文档,提炼出关键信息---嵌套层级的树形结构。便于计算拓展,这就是 HTML Parser 的作用。
二、CSS 解析
CSS Parser 将 CSS 解析成 Style Rules,Style Rules 也叫 CSSOM(CSS Object Model,样式对象模型)。
StyleRules 也是一个树形结构,根据CSS文件整理出来的类似DOM Tree的树形结构:
于HTML Parser相似,CSS Parser作用就是将很多个CSS文件中的样式合并解析出具有树形结构Style Rules。(Style Rules则包含选择器和声明对象,以及其他与 CSS 语法对应的对象)
脚本处理和样式表的顺序
1. CSS 不会阻塞 DOM 的解析(即HTML的解析,CSS 不会阻塞 DOM Tree的构建)
浏览器是解析 DOM 生成 DOM Tree,结合 CSS 生成的 CSS Tree,最终组成render tree,再渲染页面。由此可见,在此过程中 CSS 完全无法影响 DOM Tree,因而无需阻塞DOM解析。
即当遇到 <link>
标签,尽管外部CSS下载需要3s,但这个过程中,浏览器不会傻等着CSS下载完,而是会解析DOM的。
然而,DOM Tree 和 CSS Tree 会组合成 render tree,那 CSS 会不会页面阻塞渲染呢?
2. CSS 阻塞页面渲染
原因:如果CSS 不会阻塞页面渲染,那么 CSS 文件下载之前,浏览器就会渲染出一种样式的页面, CSS 文件下载完成之后样式改变了又渲染出另一种样式的页面。导致页面首先会呈现出一个原始的模样,待CSS下载完之后又突然变了一个模样。用户体验可谓极差,而且渲染是有成本的。
因此,基于性能与用户体验的考虑,浏览器会尽量减少渲染的次数,CSS 顺理成章地阻塞页面渲染,直至 CSSOM 构建完毕。
由于 CSS 不会阻塞 HTML 文档的解析,因此在下载 CSS 文件的时候,意味着<link>
和<script>
等其他的外部文件都会提前并行下载(加载)。即使 JS 会阻塞 html 文档的解析,但浏览器会"偷看"DOM,预先下载相关资源。总之,html 文档中的所有外部资源总是提前加载的(并行下载)(浏览器不会傻等到解析到那里时才下载)。
<header>
<link rel="stylesheet" href="/css/sleep3000-common.css">
<script src="/js/logDiv.js"></script>
</header>
我们知道 CSSOM 的构建不会阻塞 DOM 的解析,并且外部资源都会提前加载(下载)。需要注意的是,Js 的执行会等到前面 CSSOM 的构建完毕才开始。
原因:如果脚本的内容是获取元素的样式,宽高等 CSS 控制的属性,浏览器是需要计算的,也就是依赖于CSS。所以浏览器需要前面所有的样式下载完后,再执行JS。
优化点(CSS 优化):
- 如果
<script>
中基本内容不是用来获取 DOM 元素或者 CSS样式属性的话,<script>
与<link>
同时在头部的话,让<script>
标签处于<link>
之前。(如果<link>
的内容下载更快的话,是没影响的,但反过来的话,JS就要等待了,然而这些等待的时间是完全不必要的) - 不使用
@import
- 不使用
- 选择器的嵌套的优化:避免使用复杂的选择器,层级越少越好(建议选择器的嵌套最好不要超过三层)
- 合并CSS文件时,去除无用的css代码并且保证重复的css文件只引入一次
- 利用CSS继承减少代码量
- 规范css书写顺序
【不使用@import
】
比如一个 CSS 文件index.css包含了以下内容:@import url("reset.css")。那么浏览器就必须先把index.css下载、解析和执行后,才下载、解析和执行第二个文件reset.css。
【为什么要避免层级或过度限制的CSS】
浏览器对 CSS 选择器是从右到左解析。.nav h3 a{font-size: 14px;}
首先找到所有的a,沿着a的父元素查找h3,然后再沿着h3,查找.nav。中途找到了符合匹配规则的节点就加入结果集。如果找到根元素html都没有匹配,则不再遍历这条路径,从下一个a开始重复这个查找匹配(只要页面上有多个最右节点为a)。
简洁的选择器不仅可以减少css文件大小,提高页面的加载性能,浏览器解析时也会更加高效,也会提高开发人员的开发效率,降低了维护成本。过度的嵌套会导致代码变得臃肿、沉余、复杂,导致 CSS 文件体积变大,造成性能浪费,影响渲染的速度,而且过于依赖 HTML 文档结构。这样的 CSS 样式,维护起来,极度麻烦,如果以后要修改样式,可能要使用!important覆盖。
【CSS中的可继承属性与不可继承属性】:
- 所有元素可继承:visibility和cursor
- 内联元素和块元素可继承:letter-spacing、word-spacing、white-space、line-height、color、font、 font-family、font-size、font-style、font-variant、font-weight、text- decoration、text-transform、direction
- 块状元素可继承:text-indent和text-align
- 列表元素可继承:list-style、list-style-type、list-style-position、list-style-image
- 表格元素可继承:border-collapse
- 不可继承的:display、margin、border、padding、background、height、min-height、max- height、width、min-width、max-width、overflow、position、left、right、top、 bottom、z-index、float、clear、table-layout、vertical-align、page-break-after、 page-bread-before和unicode-bidi
渲染树(Render-Tree)的关键渲染路径中,要求同时具有 DOM 和 CSSOM,之后才会构建渲染树。即,HTML 和 CSS 都是阻塞渲染的资源。HTML 显然是必需的,因为包括我们希望显示的文本在内的内容,都在 DOM 中存放,那么可以从 CSS 上想办法。
最容易想到的当然是精简 CSS 并尽快提供它。除此之外,还可以用媒体类型(media type)和媒体查询(media query)来解除对渲染的阻塞。
<link href="index.css" rel="stylesheet">
<link href="print.css" rel="stylesheet" media="print">
<link href="other.css" rel="stylesheet" media="(min-width: 30em) and (orientation: landscape)">
第一个资源会加载并阻塞。
第二个资源设置了媒体类型,会加载但不会阻塞,print 声明只在打印网页时使用。
第三个资源提供了媒体查询,会在符合条件时阻塞渲染。
3. JS 阻塞 DOM 解析
网络的模型是同步的,浏览器解析文档,当遇到<script>
标签的时候,会立即解析(执行)脚本,停止解析文档(停止DOM Tree的构建,因为 JS 可能会改动 DOM 和 CSS ,万一脚本内全删了后面的DOM,浏览器就白干活了。所以继续解析会造成浪费)。
如果脚本是外部的,会等待脚本下载完毕并执行完毕,再继续解析 HTML 文档。现在可以在 script 标签上增加属性 defer或者async,来使脚本的处理相对 DOM Tree 的构建是异步的。注意,如果脚本是内联在 html 文档内,无法更改脚本的执行顺序,只能是立即解析脚本,并停止 DOM Tree 的构建,直到脚本执行完毕。像这样的脚本:
<script>
console.log("3");
</script>
<script src="script.js"></script>
<script async src="script.js"></script>
<script defer src="script.js"></script>
蓝色线代表网络读取,红色线代表执行时间,这俩都是针对脚本的;绿色线代表 HTML 解析。
此图告诉我们以下几个要点:
- defer 和 defer 在网络读取(下载)这块儿是一样的,都是异步的(相较于 HTML 解析)
- 它俩的差别在于脚本下载完之后何时执行,显然 defer 是最接近我们对于应用脚本加载和执行的要求的
- 关于 defer,此图未尽之处在于它是按照加载顺序执行脚本的,这一点要善加利用
- async 则是一个乱序执行的主,反正对它来说脚本的加载和执行是紧紧挨着的,所以不管你声明的顺序如何,只要它加载完了就会立刻执行
- 仔细想想,async 对于应用脚本的用处不大,因为它完全不考虑依赖(哪怕是最低级的顺序执行),不过它对于那些可以不依赖任何脚本或不被任何脚本依赖的脚本来说却是非常合适的,最典型的例子:Google Analytics
4. 浏览器遇到 <script>
标签时,会触发页面渲染
原因:浏览器不知道脚本的内容,因而碰到脚本时,只好先渲染页面,确保脚本能获取到最新的DOM元素信息。
例子:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
div {
width: 100px;
height: 100px;
background: lightgreen;
}
</style>
</head>
<body>
<div></div>
<script src="/js/sleep3000-logDiv.js"></script>
<style>
div {
background: lightgrey;
}
</style>
<script src="/js/sleep5000-logDiv.js"></script>
<link rel="stylesheet" href="/css/common.css">
</body>
// js 文件
function sleep(time) {
return new Promise(function(res) {
setTimeout(() => {
res()
}, time);
})
}
sleep(time);
// common.css
div {
background: pink;
}
答案:先浅绿色,再浅灰色,最后粉红。(浏览器渲染速度较大,只能微微看到两面的颜色一闪而过)
即浏览器遇到 <script>
且没有 defer 或 async 属性的 标签时,会触发页面渲染,因而如果前面CSS资源尚未加载完毕时,浏览器会等待它加载完毕在执行脚本。
5. Speculative parsing(预解析/预加载)
当执行脚本时,其它线程会解析剩下的文档,找出里面的外部资源(script/style/img)来提前加载(可以并行加载)。这种解析只是去查找需要加载的外部资源,不会修改content tree。
所以我们可以看到多个外部资源并行下载。
6. document.createElement
使用 document.createElement 创建的 script 默认是异步的,示例如下。
console.log(document.createElement("script").async); // true
所以,通过动态添加 script 标签引入 JavaScript 文件默认是不会阻塞页面的。如果想同步执行,需要将 async 属性人为设置为 false。
脚本解析会将脚本中改变 DOM 和 CSS 的地方分别解析出来,追加到 DOM Tree 和 Style Rules 上。
优化方案(对 JS 文件的优化)
- 当遇到JS文件体积较大(下载慢),或者有耗时较长的异步操作时(执行慢),为了让更快的渲染出页面,让
<script>
脚本放在 HTML 文档底部(</body>
前),或者给<script>
增加async
ordefer
属性。 - 尽量将样式文件放在文档头部,脚本文件放在文档底本。(因为
<script>
会触发页面的渲染,所以不要把 css 文件放在 脚本之后。) - JS 应尽量少影响 DOM 的构建。
三、Render Tree(渲染树包含带有视觉属性(如颜色和尺寸)的矩形们)
这是由可视化元素按照其显示顺序而组成的树,也是文档的可视化表示。它的作用是保证按照正确的顺序来绘制内容。
渲染树的每个节点(renderer)代表一个矩形区域——对应 DOM 元素的 CSS Box。
四、Layout(回流:Reflow)
在渲染树创建后进入 Layout 阶段,给渲染树的每个节点设置在屏幕上的位置信息
renderer 在创建完成并添加到 render tree 时,并不包含 位置和大小 信息。计算这些值的过程称为布局或重排(Layout/Reflow)。
五、Paint (Repaint)
Paint 阶段,通过 UI backend 绘制 render tree 到屏幕。
一般来说,可以把普通文档流看成一个图层。特定的属性可以生成一个新的图层。不同的图层渲染互不影响,所以对于某些频繁需要渲染的建议单独生成一个新图层,提高性能。但也不能生成过多的图层,会引起反作用。
通过以下几个常用属性可以生成新图层
3D 变换:translate3d、translateZ
will-change
video、iframe 标签
通过动画实现的 opacity 动画转换
position: fixed
重绘与回流
重绘是当节点需要更改外观而不会影响布局的,比如改变 color 就叫称为重绘
回流是布局或者几何属性需要改变就称为回流。
回流必定会发生重绘,重绘不一定会引发回流。回流所需的成本比重绘高的多,改变深层次的节点很可能导致父节点的一系列回流。
减少重绘和回流:
- 使用 translate 替代 top
- 使用 visibility 替换 display: none ,因为前者只会引起重绘,后者会引发回流(改变了布局)
- 把 DOM 离线后修改,比如:先把 DOM 给 display:none (有一次 Reflow),然后你修改100次,然后再把它显示出来
- 不要把 DOM 结点的属性值放在一个循环里当成循环里的变量
- 不要使用 table 布局,可能很小的一个小改动会造成整个 table 的重新布局
- 动画实现的速度的选择,动画速度越快,回流次数越多,也可以选择使用 requestAnimationFrame
- CSS 选择符从右往左匹配查找,避免 DOM 深度过深
- 将频繁运行的动画变为图层,图层能够阻止该节点回流影响别的元素。比如对于 video 标签,浏览器会自动将该节点变为图层。
References
原来 CSS 与 JS 是这样阻塞 DOM 解析和渲染的
CSS选择器从右向左的匹配规则
浏览器的工作原理
从浏览器输入一个 url 到页面渲染,涉及的知识点及优化点
浏览器中输入url后发生了什么
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
上一篇: 深入理解 Promise(下)
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论