发送 & 接收信息
数据是以“数据包”的形式通过互联网发送,而数据包以字节为单位。当你编写一些 HTML、CSS 和 JS,并试图在浏览器中打开 HTML 文件时,浏览器会从你的硬盘(或网络)中读取 HTML 的原始字节。明白了吗?浏览器读取的是原始数据字节,而不是你编写的代码的实际字符。
让我们继续。浏览器接收字节数据,但是,它用这些数据什么都做不了。数据的原始字节必须转换为它所理解的形式,这是第一步。
从 HTML 的原始字节到 DOM
浏览器对象需要处理的是文档对象模型(DOM)对象。那么,DOM 对象是从何而来的呢?这很简单。首先,将原始数据字节转换为字符。这一点,你可以通过你所编写的代码的字符看到。这种转换是基于 HTML 文件的字符编码完成的。至此,浏览器已经从原始数据字节转换为文件中的实际字符。但这不是最终的结果。这些字符会被进一步解析为一些称为“标记(token)”的东西
那么,这些标记是什么?文本文件中的一堆字符对浏览器引擎而言没什么用处。如果没有这个标记化过程,那么这一堆堆字符只会生成一系列毫无意义的文本,即 HTML 代码——不会生成一个真正的网站。
当你保存一个扩展名为.html 的文件时,就向浏览器引擎发出了把文件解析为 HTML 文档的信号。浏览器“解释”这个文件的方式是首先解析它。在解析过程中,特别是在标记化过程中,浏览器会解析 HTML 文件中的每个开始和结束“标签(tag)”。解析器可以识别尖括号中的每个字符串,如“< html>”、“< p>”,也可以推断出适用于其中任何一个字符串的规则集。例如,表示锚标签的标记与表示段落标签的标记具有不同的属性。
从概念上讲,你可以将标记看作某种数据结构,它包含关于某个 HTML 标签的信息。本质上,HTML 文件会被分解成称为标记的小的解析单元。浏览器就是这样开始识别你所编写的内容的。
但标记还不是最终的结果。标记化完成后,接下来,标记将被转换为节点。你可以将节点看作是具有特定属性的不同对象。实际上,更好的解释是,将节点看作是文档对象树中的独立实体。但节点仍然不是最终结果。
现在,让我们看一下最后一点。在创建好之后,这些节点将被链接到称为 DOM 的树数据结构中。DOM 建立起了父子关系、相邻兄弟关系等。在这个 DOM 对象中,每个节点之间都建立了关系。现在,这是我们可以使用的东西了。
Bytes=> characters=>Tokens=>Node=>Dom
字节=>字符=>标记=>节点=>Dom树
根据 HTML 文件的大小,DOM 的构建过程可能需要一些时间。无论文件多小,它都需要一些时间。
CSS 如何转换?
DOM 已经创建。带有一些 CSS 的典型 HTML 文件会包含下面这样的样式表链接:
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" type="text/css" media="screen" href="main.css" />
</head>
<body>
</body>
</html>
当浏览器接收到原始数据字节并启动 DOM 构建过程时,它还会发出请求来获取链接的 main.css 样式表。当浏览器开始解析 HTML 时,在找到 css 文件的链接标签的同时,它会发出请求来获取它。可能你已经猜到,浏览器还是接收 CSS 数据的原始字节,从互联网或是本地磁盘。
但是,浏览器如何处理这些 CSS 数据的原始字节呢?
从 CSS 的原始字节到 CSSOM
当浏览器接收到 CSS 的原始字节时,会启动一个和处理 HTML 原始字节类似的过程。就是说,原始数据字节被转换成字符,然后标记,然后形成节点,最后形成树结构。
什么是树结构?大多数人都知道 DOM 这个词。同样,也有一种 CSS 树结构,称为 CSS 对象模型,简称为 CSSOM。
你知道,浏览器不能使用 HTML 或 CSS 的原始字节。必须将其转换成它能识别的形式,也就是这些树形结构。
CSS 有一个叫做级联的东西。级联是浏览器确定如何在元素上应用样式的机制。
由于影响元素的样式可能来自父元素,即通过继承,或者已经在元素本身设置,所以 CSSOM 树结构变得很重要。为什么?这是因为浏览器必须递归遍历 CSS 树结构并确定影响特定元素的样式。
一切顺利。浏览器有了 DOM 和 CSSOM 对象。现在,我们能在屏幕上呈现一些东西了吗?
渲染树
我们现在得到的是两个独立的树结构,它们似乎没有共同的目标。
DOM 和 CSSOM 树结构是两个独立的树结构。DOM 中包含关于页面 HTML 元素关系的所有信息,而 CSSOM 则包含关于元素样式的信息。好了,浏览器现在把 DOM 和 CSSOM 树组合成一棵渲染树。
DOM + CSSOM = 渲染树
渲染树包含页面上所有关于可见 DOM 内容的信息以及不同节点所需的所有 CSSOM 信息。注意,如果一个元素被 CSS 隐藏,例如使用 display; none,那么节点就不会包含在渲染树中。隐藏元素会出现在 DOM 中,但不会出现在渲染树中。这是因为渲染树结合了来自 DOM 和 CSSOM 的信息,所以它知道不能把隐藏元素包含在树中。
构建好渲染树之后,浏览器将继续下个步骤:布局!
布局
现在,我们有了屏幕上的内容和所有可见内容的样式信息——但是我们并没有实际在屏幕上渲染任何内容。首先,浏览器必须计算页面上每个对象的确切大小和位置。这就好比是,把要在页面上渲染的所有元素的内容和样式信息传递给一个有才华的数学家。然后,这位数学家用浏览器的视窗计算出每个元素的确切位置和大小。
这个布局步骤考虑了从 DOM 和 CSSOM 接收到的内容和样式,并执行了所有必要的布局计算。有时,你会听到人们把这个“布局”阶段称为“回流(reflow)”
艺术家出场
现在,每个元素的确切位置已经计算出来,剩下的就是将元素“绘制”到屏幕上。
考虑一下。我们已经得到了在屏幕上显示元素所需的所有信息。我们只要把它展示给用户。这就是这个阶段的全部工作。有了元素内容(DOM)、样式(CSSOM)和计算得出的元素的精确布局信息,浏览器现在就可以将节点逐个“绘制”到屏幕上了。元素现在终于呈现在屏幕上了!
渲染阻塞资源
当你听到“渲染阻塞(render-blocking)”时,你会想到什么?我猜你想的是,“有东西阻止了屏幕上节点的实际绘制”。如果你这么说,那你说的完全正确!
优化网站的第一准则是让最重要的 HTML 和 CSS 尽可能快地传递到客户端。在成功绘制之前,必须构造 DOM 和 CSSOM,因此,HTML 和 CSS 都是渲染阻塞资源。关键是,你应该尽快将 HTML 和 CSS 提供给客户端,以优化应用程序的首次渲染时间。
JavaScript 如何执行?
一个好的 Web 应用程序肯定会使用一些 JavaScript。这是一定的。JavaScript 的“问题”在于你可以使用 JavaScript 修改页面的内容和样式。通过这种方式,你可以从 DOM 树中删除元素和添加元素,还可以通过 JavaScript 修改元素的 CSSOM 属性。
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Medium Article Demo</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<p id="header">How Browser Rendering Works</p>
<div><img src="https://i.imgur.com/jDq3k3r.jpg"></div>
</body>
</html>
这是一个非常简单的文档。样式表 style.css 只有下面一个声明语句:
body {
background: #8cacea;
}
根据前面的解释,浏览器从磁盘(或网络)读取 HTML 文件的原始字节并将其转换为字符。字符被进一步解析为标记。当解析器遇到< link rel="stylesheet" href="style.css">时,就会请求获取 CSS 文件 style.css。DOM 构造继续进行,当 CSS 文件返回一些内容后,CSSOM 构造就开始了。
引入 JavaScript 后,这个过程会发生什么变化?要记住,其中最重要的一件事情是,每当浏览器遇到脚本标签时,DOM 构造就会暂停!整个 DOM 构建过程都将停止,直到脚本执行完成。
这是因为 JavaScript 可以同时修改 DOM 和 CSSOM。由于浏览器不确定特定的 JavaScript 会做什么,所以它采取的预防措施是停止整个 DOM 构造。
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Medium Article Demo</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<p id="header">How Browser Rendering Works</p>
<div><img src="https://i.imgur.com/jDq3k3r.jpg"></div>
<script>
let header = document.getElementById("header");
console.log("header is: ", header);
</script>
</body>
</html>
在脚本标签中,我将访问 id 为 header 的 DOM 节点,然后将其输出到控制台。可以正常运行。
但是,你是否注意到,这个脚本标签位于 body 标签的底部?让我们把它放在 head 中,看看会发生什么:一旦我这样做,header 就解析为 null。
为什么会这样?很简单。当 HTML 解析器正在构建 DOM 时,发现了一个脚本标签。此时,body 标签及其所有内容还没有被解析。DOM 构造将停止,直到脚本执行完成:
当脚本试图访问一个 id 为 header 的 DOM 节点时,由于 DOM 还没有完成对文档的解析,所以它还不存在。这把我们带到了另一个重要的问题。脚本的位置很重要。
这还不是全部。如果你将内联脚本提取到外部本地文件 app.js 中,行为是一样的。DOM 的构建仍然会停止:
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Medium Article Demo</title>
<link rel="stylesheet" href="style.css">
<script src="app.js"></script>
</head>
<body>
<p id="header">How Browser Rendering Works</p>
<div><img src="https://i.imgur.com/jDq3k3r.jpg"></div>
</body>
</html>
那么,如果 app.js 不是本地的而必须通过互联网获取呢?如果网速很慢,需要数千毫秒来获取 app.js,DOM 的构建也会暂停几千毫秒!!!这是一个很大的性能问题,而且还不止于此。JavaScript 还可以访问 CSSOM 并对其进行修改。例如,这是有效的 JavaScript 语句:
document.getElementsByTagName("body")[0].style.backgroundColor = "red";
那么,当解析器遇到一个脚本标签而 CSSOM 还没有准备好时,会发生什么情况呢?答案很简单。Javascript 执行将会停止,直到 CSSOM 就绪。因此,虽然 DOM 构造在遇到脚本标签时会停止,但 CSSOM 不会发生这种情况。对于 CSSOM,JS 执行会等待。没有 CSSOM,就没有 JS 执行。
async 属性
在默认情况下,每个脚本都是一个解析器阻断器!DOM 的构建总是会被打断。不过,有一种方法可以改变这种默认行为。如果将 async 关键字添加到脚本标签中,那么 DOM 构造就不会停止。DOM 构造将继续,脚本将在下载完成并准备就绪后执行。
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Medium Article Demo</title>
<link rel="stylesheet" href="style.css">
<script src="https://some-link-to-app.js" async></script>
</head>
<body>
<p id="header">How Browser Rendering Works</p>
<div><img src="https://i.imgur.com/jDq3k3r.jpg"></div>
</body>
</html>
关键渲染路径
目前为止,我们讨论了从接收 HTML、CSS 和 JS 字节到将它们转换为屏幕上的像素之间的所有步骤。这整个过程称为关键渲染路径。优化网站性能就是优化关键渲染路径。
一个经过良好优化的站点应该能够渐进式渲染,而不是让整个过程受阻。
这是 Web 应用程序慢或快的区别所在。周密的关键渲染路径(CRP)优化策略使浏览器能够通过确定优先加载的资源以及资源加载的顺序来尽可能快地加载页面。
常见引起回流属性和方法
任何会改变元素几何信息(元素的位置和尺寸大小)的操作,都会触发回流,
- 添加或者删除可见的DOM元素;
- 元素尺寸改变——边距、填充、边框、宽度和高度
- 内容变化,比如用户在input框中输入文字
- 浏览器窗口尺寸改变——resize事件发生时
- 计算 offsetWidth 和 offsetHeight 属性
- 设置 style 属性的值
常见引起重绘属性和方法
下面例子中,触发了几次回流和重绘?
var s = document.body.style;
s.padding = "2px"; // 回流+重绘
s.border = "1px solid red"; // 再一次 回流+重绘
s.color = "blue"; // 再一次重绘
s.backgroundColor = "#ccc"; // 再一次 重绘
s.fontSize = "14px"; // 再一次 回流+重绘
// 添加node,再一次 回流+重绘
document.body.appendChild(document.createTextNode('abc!'));
如何减少回流、重绘
- 使用 transform 替代 top
- 使用 visibility 替换 display: none ,因为前者只会引起重绘,后者会引发回流(改变了布局)
- 不要把节点的属性值放在一个循环里当成循环里的变量。
- 不要使用 table 布局,可能很小的一个小改动会造成整个 table 的重新布局
- 动画实现的速度的选择,动画速度越快,回流次数越多,也可以选择使用 requestAnimationFrame
- CSS 选择符从右往左匹配查找,避免节点层级过多
- 将频繁重绘或者回流的节点设置为图层,图层能够阻止该节点的渲染行为影响别的节点。比如对于 video 标签来说,浏览器会自动将该节点变为图层。
async和defer的作用是什么?有什么区别?
接下来我们对比下 defer 和 async 属性的区别:
(1)情况1<script src="script.js"></script>
没有 defer 或 async,浏览器会立即加载并执行指定的脚本,也就是说不等待后续载入的文档元素,读到就加载并执行。
(2)情况2<script async src="script.js"></script>
(异步下载)
async 属性表示异步执行引入的 JavaScript,与 defer 的区别在于,如果已经加载好,就会开始执行——无论此刻是 HTML 解析阶段还是 DOMContentLoaded 触发之后。需要注意的是,这种方式加载的 JavaScript 依然会阻塞 load 事件。换句话说,async-script 可能在 DOMContentLoaded 触发之前或之后执行,但一定在 load 触发之前执行。
(3)情况3 <script defer src="script.js"></script>
(延迟执行)
defer 属性表示延迟执行引入的 JavaScript,即这段 JavaScript 加载时 HTML 并未停止解析,这两个过程是并行的。整个 document 解析完毕且 defer-script 也加载完成之后(这两件事情的顺序无关),会执行所有由 defer-script 加载的 JavaScript 代码,然后触发 DOMContentLoaded 事件。
defer 与相比普通 script,有两点区别:载入 JavaScript 文件时不阻塞 HTML 的解析,执行阶段被放到 HTML 标签解析完成之后。在加载多个JS脚本的时候,async是无顺序的加载,而defer是有顺序的加载。
为什么操作 DOM 慢
因为 DOM 是属于渲染引擎中的东西,而 JS 又是 JS 引擎中的东西。当我们通过 JS 操作 DOM 的时候,其实这个操作涉及到了两个线程之间的通信,那么势必会带来一些性能上的损耗。操作 DOM 次数一多,也就等同于一直在进行线程之间的通信,并且操作 DOM 可能还会带来重绘回流的情况,所以也就导致了性能上的问题。
渲染页面时常见哪些不良现象?
由于浏览器的渲染机制不同,在渲染页面时会出现两种常见的不良现象----白屏问题和FOUS(无样式内容闪烁)
FOUC:由于浏览器渲染机制(比如firefox),再CSS加载之前,先呈现了HTML,就会导致展示出无样式内容,然后样式突然呈现的现象;
白屏:有些浏览器渲染机制(比如chrome)要先构建DOM树和CSSOM树,构建完成后再进行渲染,如果CSS部分放在HTML尾部,由于CSS未加载完成,浏览器迟迟未渲染,从而导致白屏;也可能是把js文件放在头部,脚本会阻塞后面内容的呈现,脚本会阻塞其后组件的下载,出现白屏问题。
总结
- 浏览器工作流程:构建DOM -> 构建CSSOM -> 构建渲染树 -> 布局 -> 绘制。
- 当浏览器接收到原始数据字节并启动 DOM 构建过程时,它还会发出请求来获取链接的 main.css 样式表,启动CSSOM构建
- 构建DOM的过程中,不是等所有Token都转换完成后再去生成节点对象,而是一边生成Token一边消耗Token来生成节点对象。换句话说,每个Token被生成后,会立刻消耗这个Token创建出节点对象
- DOM 和 CSSOM 树结构是两个独立的树结构。DOM 中包含关于页面 HTML 元素关系的所有信息,而 CSSOM 则包含关于元素样式的信息。
- 浏览器得递归 CSSOM 树,然后确定具体的元素到底是什么样式,注意:CSS匹配HTML元素是一个相当复杂和有性能问题的事情。所以,DOM树要小,CSS尽量用id和class,千万不要过渡层叠下去
- CSSOM会阻塞渲染,只有当CSSOM构建完毕后才会进入下一个阶段构建渲染树。
- 渲染树包含页面上所有关于可见 DOM 内容的信息以及不同节点所需的所有 CSSOM 信息,隐藏元素会出现在 DOM 中,但不会出现在渲染树中。这是因为渲染树结合了来自 DOM 和 CSSOM 的信息,所以它知道不能把隐藏元素包含在树中。
- 构建好渲染树之后,浏览器必须计算页面上每个对象的确切大小和位置,这个布局步骤考虑了从 DOM 和 CSSOM 接收到的内容和样式,并执行了所有必要的布局计算(回流或者自动重排)。
- 每当浏览器遇到脚本标签时,DOM 构造就会暂停!整个 DOM 构建过程都将停止,但 CSSOM 不会发生这种情况,直到脚本执行完成,当解析器遇到一个脚本标签而 CSSOM 还没有准备好时,Javascript 执行将会停止,直到 CSSOM 就绪,对于 CSSOM,JS 执行会等待。
- 通常情况下DOM和CSSOM是并行构建的,但是当浏览器遇到一个script标签时,DOM构建将暂停,直至脚本完成执行。但由于JavaScript可以修改CSSOM,所以需要等CSSOM构建完毕后再执行JS。
- JS文件不只是阻塞DOM的构建,它会导致CSSOM也阻塞DOM的构建。原本DOM和CSSOM的构建是互不影响,井水不犯河水,但是一旦引入了JavaScript,CSSOM也开始阻塞DOM的构建,只有CSSOM构建完毕后,DOM再恢复DOM构建。在这种情况下,浏览器会先下载和构建CSSOM,然后再执行JavaScript,最后在继续构建DOM。
- 如果你想首屏渲染的越快,就越不应该在首屏就加载 JS 文件,建议将 script 标签放在 body 标签底部。
- 重绘:当render tree中的一些元素需要更新属性,而这些属性只是影响元素的外观、风格,而不会影响布局的,比如background-color。
- 回流:当render tree中的一部分(或全部)因为元素的规模尺寸、布局、隐藏等改变而需要重新构建
- 回流必定会发生重绘,重绘不一定会引发回流。重绘和回流会在我们设置节点样式时频繁出现,同时也会很大程度上影响性能。回流所需的成本比重绘高的多,改变父节点里的子节点很可能会导致父节点的一系列回流。