在 上一篇文章 中,我们介绍了浏览器的渲染流程,这篇文章中,我们将重点聚焦在渲染阻塞上,来详细看一下渲染阻塞以及一些常见的解决方法。
渲染阻塞回顾
HTML 用于描述网页的整体结构。为了理解 HTML,浏览器必须将它转为自己能够理解的格式,也就是 DOM(文档对象模型)
浏览器的 HTML 解析器一点一点地解析 HTML,将节点添加到树结构中,构建 DOM。
构建出来的 DOM 对象,实际上有 2 个作用:
- HTML 文档的结构以对象的方式体现出来,用于页面渲染
- 作为接口供外界使用,例如 JavaScript
CSS 样式会被映射为 CSSOM(CSS 对象模型),它和 DOM 很相似,但是针对的是 CSS 而不是 HTML。
在构建 CSSOM 的时候,无法进行增量构建(不像构建 DOM 一样,解析到一个 DOM 节点就扔到 DOM 树结构里面),因为 CSS 规则是可以相互覆盖的,浏览器引擎需要经过复杂的计算才能弄清楚 CSS 代码如何应用于 DOM。
当浏览器正在构建 DOM 时,如果它遇到 HTML 中的 <script>...</script>
标记,它必须立即执行它。如果脚本是外部的,则必须先下载脚本(预解析线程)。
过去,为了执行脚本,必须暂停解析。解析会在 JavaScript 引擎执行完脚本中的代码后再次启动。
为什么解析必须停止呢?
原因很简单,这是因为 Javascript 脚本可以改变 HTML 以及根据 HTML 生成的 DOM 树结构。例如,脚本可以通过使用 document.createElement()
来添加节点从而更改 DOM 结构。
这也是为什么我们建议将 script
标签写在 body
元素结束标签前面的原因。
接下来回头来看一下 CSS 是否会阻塞渲染。
看上去 JavaScript 会阻止解析,是因为它可以修改文档。那么 CSS 不能修改文档,所以它似乎没有理由阻止解析,对吧?
但是,如果脚本中需要获取一些尚未解析的样式信息怎么办?在 JavaScript 中完全可以访问到 DOM 节点的某些样式,或者使用 JavaScript 直接访问 CSSOM。
因此,CSS 可能会根据文档中外部样式表和脚本的顺序阻止解析。如果在文档中的脚本之前放置了外部样式表,则 DOM 和 CSSOM 对象的构建可能会相互干扰。
当解析器到达一个脚本标签时,在 JavaScript 执行完成之前无法继续构建 DOM,然而如果这一段 JavaScript 中涉及到访问和使用 CSSOM,那么必须等待 CSS 文件被下载、解析并且 CSSOM 可用。如果 CSSOM 处于未可用状态,则会阻塞 JavaScript 的执行。
(上图中 JavaScript 的执行被 CSS 构建 CSSOM 的过程阻塞了)
另外,虽然 CSS 不会阻塞 DOM 的构建,但是也会阻塞渲染。
前面讲过 要 DOM 树和 CSSOM 树都准备好,才会生成渲染树( Render Tree ),浏览器在拥有 DOM 和 CSSOM 之前是不会显示任何内容。
这是因为没有 CSS 的页面通常无法使用。如果浏览器向你展示了一个没有 CSS 的凌乱页面,那么片刻之后就会进入一个有样式的页面,不断变化的内容和突然的视觉变化会给用户带来混乱的用户体验。
这种糟糕的用户体验有一个名字,叫做“无样式内容闪现”(Flash of Unstyled Content),或者简称 FOUC
为了解决这些问题,所以我们需要尽快的交付 CSS。
这也解释了为什么“顶部样式,底部脚本”被称之为“最佳实践”。
随着现代浏览器的普及,浏览器为我们提供了更多强大的武器——资源提示关键词,合理利用,方可大幅提高页面加载速度。
兼容性还是不够好,但这些基本都是渲染优化的内容,不兼容也不会有太大影响,只是慢一点而已
defer
和 async
现代浏览器引入了 defer
和 async
。
async
(异步)表示 JS 可以异步加载。也就是说下载 JS 文件的时候不会阻塞 DOM 树的构建,但是执行该 JS 代码会阻塞 DOM 树的构建。
<script async src="script.js"></script>
defer
(推迟)表示 JS 可以异步加载,但是 JS 的执行要在所有元素解析完成之后,DOMContentLoaded
事件触发之前完成。也就是说,下载 JS 文件的时候不会阻塞 DOM 树的构建,然后等待 DOM 树构建完毕后再执行此 JS 文件。
<script defer src="script.js"></script>
具体加载瀑布图如下图所示:
preload
preload
顾名思义就是一种预加载的方式,它通过声明向浏览器声明一个需要提前加载的资源,当资源真正被使用的时候立即执行,就无需等待网络的消耗。
<!-- 前面使用 preload -->
<link rel="preload" href="style.css" as="style">
<link rel="preload" href="main.js" as="script">
<!-- 之后使用到的地方就更快可用了 -->
<link rel="stylesheet" href="style.css">
<script src="main.js"></script>
在上面的代码中,会先加载 style.css
和 main.js
文件(但不会生效),在随后的页面渲染中,一旦需要使用它们,它们就会立即或更快可用。
可以使用 as
来指定将要预加载的内容类型:
script
style
image
font
document
preload
指令的一些优点如下:
- 允许浏览器设置资源优先级,从而允许 Web 开发人员优化某些资源的交付。
- 使浏览器能够确定资源类型,因此它可以判断将来是否可以重用相同的资源。
- 浏览器可以通过引用
as
属性中定义的内容来确定请求是否符合内容安全策略。 - 浏览器可以根据资源类型发送合适的
Accept
头(例如:image/webp
)
prefetch
prefetch
是一种利用浏览器的空闲时间加载页面将来可能用到的资源的一种机制,通常可以用于加载非首页的其他页面所需要的资源,以便加快后续页面的首屏速度。
prefetch
加载的资源可以获取非当前页面所需要的资源,并且将其放入缓存至少 5 分钟(无论资源是否可以缓存)。并且,当页面跳转时,未完成的 prefetch
请求不会被中断。
它的用法跟 preload
是一样的:
<link rel="prefetch" href="/path/to/style.css" as="style">
dns-prefetch
DNS prefetching 允许浏览器在用户浏览时在后台对页面执行 DNS 查找。这最大限度地减少了延迟,因为一旦用户单击链接就已经进行了 DNS 查找。
通过将 rel="dns-prefetch"
标记添加到链接属性,可以将 DNS prefetching 添加到特定 URL。建议在诸如 Web 字体、CDN 之类的东西上使用它。
<!-- Prefetch DNS for external assets -->
<link rel="dns-prefetch" href="//fonts.googleapis.com">
<link rel="dns-prefetch" href="//www.google-analytics.com">
<link rel="dns-prefetch" href="//cdn.domain.com">
prerender
prerender
与 prefetch
非常相似,prerender
同样也是会收集用户接下来可能会用到的资源。
不同之处在于 prerender
实际上是在后台渲染整个页面。
<link rel="prerender" href="https://www.keycdn.com">
preconnect
preconnect
指令允许浏览器在 HTTP 请求实际发送到服务器之前设置早期连接。
浏览器要建立一个连接,一般需要经过 DNS 查找,TCP 三次握手和 TLS 协商(如果是 HTTPS 的话),这些过程都是需要相当的耗时的。所以 preconnet
,就是一项使浏览器能够预先建立一个连接,等真正需要加载资源的时候就能够直接请求了。
以下是为 CDN URL 启用 preconnect
的示例。
<link href="https://cdn.domain.com" rel="preconnect" crossorigin>
在上面的代码中,浏览器会进行以下步骤:
- 解释
href
的属性值,判断是否是合法的 URL。如果是合法的 URL,然后继续判断 URL 的协议是否是 HTTP 或者 HTTPS,如果不是合法的 URL,则结束处理。 - 如果当前页面 host 不同于
href
属性中的 host,那么将不会带上 cookie,如果希望带上 cookie 等信息,可以加上crossorign
属性。