【第1159期】CSS预加载Preload
前言
看天气预报,今天好多地方都开始下雪了。今日早读文章由@李斌分享。
正文从这开始~
Preload 作为一个新的web标准,旨在提高性能和为web开发人员提供更细粒度的加载控制。Preload使开发者能够自定义资源的加载逻辑,且无需忍受基于脚本的资源加载器带来的性能损失。
在 HTML 代码中,它看上去大概是下面这样的一段声明式获取指令(declaratiev fetch directive)。
<link rel=“preload”>
拿我们的话来说,通过这一方法我们告诉浏览器开始获取某一特定资源,毕竟我们是作者,知道浏览器很快就会用到这一资源。
与现有的类似技术的区别
事实上,关于预加载,我们已经有<link rel=“prefetch”>
,而且浏览器支持情况还不错。
除此之外,Chrome还支持过<link rel=“subresource”>
。
但Preload与这两者不同,<link rel=“prefetch”>
的作用是告诉浏览器加载下一页面可能会用到的资源,注意,是下一页面,而不是当前页面。因此该方法的加载优先级非常低(自然,相比当前页面所需的资源,未来可能会用到的资源就没那么重要了),也就是说该方式的作用是加速下一个页面的加载速度。
<link rel=“subresource”>
的设计初衷是处理当前页面,但最后还是壮烈牺牲了。因为开发者无法控制资源的加载优先级,因此浏览器(其实也只有 Chrome 和基于 Chrome 的浏览器)在处理此类标签时,优先级很低,到底有多低呢?这么说吧,在大多数情况下,用了等于没用。
为什么 Preload 更好
Preload是为处理当前页面所生,这点和 subresource 一样,但他们之间有着细微且意义重大的区别。Preload 有 as 属性,这让浏览器可做一些 subresource 和 prefetch 无法实现的事:
浏览器可以设置正确的资源加载优先级,这种方式可以确保资源根据其重要性依次加载, 所以,Preload既不会影响重要资源的加载,又不会让次要资源影响自身的加载。
浏览器可以确保请求是符合内容安全策略的,比如,如果我们的安全策略是Content-Security-Policy: script-src 'self'
,只允许浏览器执行自家服务器的脚本,as 值为 script 的外部服务器资源就不会被加载。
浏览器能根据 as 的值发送适当的 Accept 头部信息
浏览器通过 as 值能得知资源类型,因此当获取的资源相同时,浏览器能够判断前面获取的资源是否能重用。
Preload 的与众不同还体现在 onload 事件上(至少在 Chrome 中,prefetch 和 subresource 是不支持的)。也就是说你可以定义资源加载完毕后的回调函数。
<link rel="preload" href="..." as="..." onload="preloadFinished()">
此外,preload 不会阻塞 windows 的 onload 事件,除非,preload资源的请求刚好来自于会阻塞 window 加载的资源。
结合上面所有这些特征,preload 给我们带来了一些以前不可能实现的功能。
资源的提前加载:
preload 一个基本的用法是提前加载资源,尽管大多数基于标记语言的资源能被浏览器的预加载器(Preloader)尽早发现,但不是所有的资源都是基于标记语言的,比如一些隐藏在 CSS 和 Javascript 中的资源。当浏览器发现自己需要这些资源时已经为时已晚,所以大多数情况,这些资源的加载都会对页面渲染造成延迟。
Preloader 简介
HTML 解析器在创建 DOM 时如果碰上同步脚本(synchronous script),解析器会停止创建 DOM,转而去执行脚本。所以,如果资源的获取只发生在解析器创建 DOM时,同步脚本的介入将使网络处于空置状态,尤其是对外部脚本资源来说,当然,页面内的脚本有时也会导致延迟。
预加载器(Preloader)的出现就是为了优化这个过程,预加载器通过分析浏览器对 HTML 文档的早期解析结果(这一阶段叫做“令牌化(tokenization)”),找到可能包含资源的标签(tag),并将这些资源的 URL 收集起来。令牌化阶段的输出将会送到真正的 HTML 解析器手中,而收集起来的资源 URLs 会和资源类型一起被送到读取器(fetcher)手中,读取器会根据这些资源对页面加载速度的影响进行有次序地加载。
现在,有了 preload,你可以通过一段类似下面的代码对浏览器说,”嗨,浏览器!这个资源你后面会用到,现在就加载它吧。“
<link rel="preload" href="late_discovered_thing.js" as="script">
as 属性的作用是告诉浏览器被加载的是什么资源,可能的 as 值包括:
“script”
“style”
“image”
“media”
“document”
更多请参考fetch spec
忽略 as 属性,或者错误的 as 属性会使 preload 等同于 XHR 请求,浏览器不知道加载的是什么,因此会赋予此类资源非常低的加载优先级。
对字体的提前加载
web 字体是较晚才能被发现的关键资源(late-discovered critical resources)中常见的一类 。web 字体对页面文字的渲染资至关重要,但却被深埋 CSS 中,即便是预加载器有解析 CSS,也无法确定包含字体信息的选择器是否会真正应用在 DOM 节点上。理论上,这个问题可以被解决,但实际情况是没有一个浏览器解决了这个问题。而且,即便是问题得到了解决,浏览器能对字体文件做出合理的预加载,一旦有新的 css 规则覆盖了现有字体规则,前面的预加载就多余了。
总之,非常复杂。
但有了 preload 这个标准,简单的一段代码就能搞定字体的预加载。
<link rel="preload" href="font.woff2" as="font" type="font/woff2" crossorigin>
需要注意的一点是:crossorigin 属性是必须的,即便是字体资源在自家服务器上,因为用户代理必须采用匿名模式来获取字体资源。
type 属性可以确保浏览器只获取自己支持的资源。尽管Chrome 支持 WOFF2,也是目前唯一支持 preload 的浏览器,但未来或许会有更多的浏览器支持 preload,而这些浏览器支不支持 WOFF2 就不好说了。
动态加载,但不执行
另外一个有意思的场景也因为 preload 的出现变得可能——当你想加载某一资源但却不想执行它。比如说,你想在页面生命周期的某一时刻执行一段脚本,而你无法对这段脚本做任何修改,不可能为它创建一个所谓的 runNow()函数。
在 preload 出现之前,你能做的很有限。如果你的方法是在希望脚本执行的位置插入脚本,由于脚本只有在加载完成以后才能被浏览器执行,也就是说你得等上一会儿。如果采用 XHR 提前加载脚本,浏览器会拒绝重用这段脚本,有些情况下,你可以使用 eval 函数来执行这段脚本,但该方法并不总是行得通,也不是完全没有副作用。
现在有了 preload,一切变得可能
var link = document.createElement("link");
link.href = "myscript.js";
link.rel = "preload";
link.as = "script";
document.head.appendChild(link);
上面这段代码可以让你预先加载脚本,下面这段代码可以让脚本执行
var script = document.createElement("script");
script.src = "myscript.js";
document.body.appendChild(script);
基于标记语言的异步加载
先看代码
<link rel="preload" as="style" href="asyncstyle.css" onload="this.rel='stylesheet'">
preload 的 onload 事件可以在资源加载完成后修改 rel 属性,从而实现非常酷的异步资源加载。
脚本也可以采用这种方法实现异步加载
难道我们不是已经有了
<script async>
虽好,但却会阻塞 window 的 onload 事件。某些情况下,你可能希望这样,但总有一些情况你不希望阻塞 window 的 onload 。
举个例子,你想尽可能快的加载一段统计页面访问量的代码,但又不愿意这段代码的加载给页面渲染造成延迟从而影响用户体验,关键是,你不想延迟 window 的 onload 事件。
有了preload, 分分钟搞定。
<link rel="preload" as="script" href="async_script.js"
onload="var script = document.createElement('script');
script.src = this.href; document.body.appendChild(script);">
响应式加载
preload 是一个link,根据规范有一个media 属性(现在 Chrome 还不支持,不过快了),该属性使得选择性加载成为可能。
有什么用处呢?假设你的站点同时支持桌面和移动端的访问,在使用桌面浏览器访问时,你希望呈现一张可交互的大地图,而在移动端,一张较小的静态地图就足够了。
你肯定不想同时加载两个资源,现在常见的做法是通过 JS 判断当前浏览器类型动态地加载资源,但这样一来,浏览器的预加载器就无法及时发现他们,可能耽误加载时机,影响用户体验和 SpeedIndex 评分。
怎样才能让浏览器尽可能早的发现这些资源呢?还是 Preload!
通过 Preload,我们可以提前加载资源,利用 media 属性,浏览器只会加载需要的资源。
<link rel="preload" as="image" href="map.png" media="(max-width: 600px)">
<link rel="preload" as="script" href="map.js" media="(min-width: 601px)">
HTTP 头
Preload 还有一个特性是其可以通过 HTTP 头信息被呈现。也就是说上文中大多数的基于标记语言的声明可以通过 HTTP 响应头实现。(唯一的例外是有 onload 事件的例子,我们不可能在 HTTP 头信息中定义事件处理函数。)
Link: <thing_to_load.js>;rel="preload";as="script"
Link: <thing_to_load.woff2>;rel="preload";as="font";crossorigin
这一方式在有些场景尤其有用,比如,当负责优化的人员与页面开发人员不是同一人时(也就是说优化人员可能无法或者不想修改页面代码),还有一个杰出的例子是外部优化引擎(External optimization engine),该引擎对内容进行扫描并优化。
特征检查 (Feature Detection)
前面所有的列子都基于一种假设——浏览器一定程度上支持 preload,至少实现了脚本和样式加载等基本功能。但如果这个假设不成立了。一切都将是然并卵。
为了判断浏览器是否支持 preload,我们修改了 DOM 的规范从而能够获知 rel 支持那些值(是否支持 rel=‘preload’)。
至于如何进行检查,原文中没有,但 Github有一段代码可供参考。
var DOMTokenListSupports = function(tokenList, token) {
if (!tokenList || !tokenList.supports) {
return;
}
try {
return tokenList.supports(token);
} catch (e) {
if (e instanceof TypeError) {
console.log("The DOMTokenList doesn't have a supported tokens list");
} else {
console.error("That shouldn't have happened");
}
}
};
var linkSupportsPreload = DOMTokenListSupports(document.createElement("link").relList, "preload");
if (!linkSupportsPreload) {
// Dynamically load the things that relied on preload.
}
讨论地址:https://github.com/w3c/preload/issues/7
是否可以用 HTTP/2 Push 完成 preload 的工作?
当然不行,尽管有一些相同的特性,但总的来说,他们的关系是互补而不是取代。
HTTP/2 Push 的优势是能够主动推送资源给浏览器,也就是说,服务器甚至不需要等到资源请求就能将资源推送给浏览器。
而 Preload 的优势在于其加载过程是透明的,一旦资源加载完毕或出现异常,应用可以获得事件通知。这一点是 HTTP/2 Push所不具备的。另外,Preload 还能加载第三方资源,但 HTTP/2 Push 不能。
此外,HTTP/2 Push 没办法将浏览器的缓存和非全局 cookie (non-global cookie) 考虑进去。也就是说,服务器推送的内容可能已经存在于客户端的缓存中,从而导致毫无意义的网络传输。(不过一份新的规范旨在解决该问题——cache digest specification,Github 上的 一个轻量级 Web服务H2O器实现了该功能,H2O在1.5版中引入了基于cookie 的cache-aware server push,原理是在首次 Server Push 完成后,在客户端存一个指纹,服务端后续检查到指纹存在时,先在指纹中查询要 Push 的资源,没查到才推送),但是非全局的 cookie就没这么好运了。对于这类型的资源,Preload 才是你的朋友。
Preload还有一个HTTP/2 Push 所不具备的能力是可以进行内容协商(content negotiation),也就是说如果你想通过 Client-Hints或者 HTTP 头的 accept 信息获取最合适的资源格式,HTTP/2 Push 帮不了你。
优化核心依旧是减少下载时间
JS篇中的预先解析DNS(dns-prefetch)依旧适用,提前解析CSS文件所在域名的DNS。
Preload
因为CSS已经在head中,我们不需要为css加preload属性了,但是css中用到的字体文件,一定要在所有css之前proload上。
<link rel="preload" href="/webfont.woff2" as="font">
首页CSS内联,非必要CSS异步加载
首页用到的CSS内联写在<head>
中,其余CSS均采用异步加载,可以采用这种自己实现的加载CSS的方法,在合适的需要时加载需要的css
function LoadStyle(url) {
try {
document.createStyleSheet(url)
} catch(e) {
var cssLink = document.createElement('link');
cssLink.rel = 'stylesheet';
cssLink.type = 'text/css';
cssLink.href = url;
var head = document.getElementsByTagName('head')[0];
head.appendChild(cssLink)
}
}
如果你使用webpack,那就更轻松了,使用import函数,大致如下
// 在a.js模块中直接引入css
import 'style.css'
// 在需要a.js模块的地方
improt('path-of-a.js').then(module => {})
webpack打包后,其实是把style.css打包进了a.js,在异步加载a.js的时候,会将style.css中的代码插入haed
标签中。
终极完美结构
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Faster</title>
<link rel="dns-prefetch" href="//cdn.cn/">
<link rel="preload" href="//cdn.cn/webfont.woff2" as="font">
<link rel="preload" href="//cdn.cn/Page1-A.js" as="script">
<link rel="preload" href="//cdn.cn/Page1-B.js" as="script">
<link rel="prefetch" href="//cdn.cn/Page2.js">
<link rel="prefetch" href="//cdn.cn/Page3.js">
<link rel="prefetch" href="//cdn.cn/Page4.js">
<style type="text/css">
/* 首页用到的CSS内联 */
</style>
</head>
<body>
<script type="text/javascript" src="//cdn.cn/Page1-A.js" defer></script>
<script type="text/javascript" src="//cdn.cn/Page1-B.js" defer></script>
</body>
</html>
最后,为你推荐
【第1053期】Preload,Prefetch 和它们在 Chrome 之中的优先级
关于本文
作者:@李斌
原文:https://zhuanlan.zhihu.com/p/32561606