Serenader

Learning by sharing

Preload: What Is It Good For?

原链接:https://www.smashingmagazine.com/2016/02/preload-what-is-it-good-for/
作者:Yoav Weiss
Preload 是新推出的一个 web 标准,其目的主要是用来优化 Web 性能,并且给 Web 开发者提供更强大的网络加载控制权。它为 Web 开发者提供了自定义页面资源加载逻辑的能力,使得可以免于遭受类似脚本资源加载器所碰到的性能问题。
几周前,我在 Chrome Canary 上发布了 Preload 的功能支持,如无意外 bug 出现的话,它会在四月份中旬在 Chrome 稳定版发布。在这之前你可能会问,这个 preload 是什么?它能做什么?以及它能够帮助你什么?
实际上,<link rel="preload"> 是一个显式的资源加载指令。
用人话来说,就是它可以告诉浏览器去加载某个具体的资源,因为我们作为页面作者(或者是服务器管理员,或者是服务端开发人员)知道浏览器在接下来很快时间内会需要使用到该资源。

我们不是已经有这种能力了吗?

算是吧,但是也不全是。<link rel="prefetch"> 已经被用在 Web 很长一段时间了,并且浏览器支持都很不错。更重要的是,我们在 Chrome 上支持 <link rel="subresource"> 有一段时间 了。那这个 preload 有什么新鲜的呢?它跟前面这两种指令有什么区别?毕竟他们都是告诉浏览器去加载资源,对吧?
是的,没错,但是他们之间有着非常重要的差别。差别就在于这个新指令解决了很多旧指令没有解决的场景。
<link rel="prefetch"> 这个指令是用来告诉浏览器去加载下个页面访问有可能会用到的资源。这几乎意味着这个资源会被以极低的优先级去加载(毕竟浏览器所知道的资源都是当前页面所需要的,并且会比我们猜测它可能会在下一次用到的资源更重要)。这意味着 prefetch 的主要用途是用来加速下一次页面访问,而不是当前页面访问。
<link rel="subresource"> 原本的计划是用来解决当前页面访问的资源加载问题,但是它在某些特定的场景下并没有很好地做到。因为开发者没有能力可以定义每个资源的优先级,因此浏览器(只有 Chrome 以及基于 Chromium 的浏览器,其实)以非常低的优先级去下载它,这意味着大部分时候,资源的实际请求在某个时候真正触发时,subresource 压根都还没有下载。

那 Preload 是怎么做的更好的?

和 subresource 一样,Preload 注定是为当前页面访问服务的,但是它与 subresource 有个很小但是非常重要的差别,那就是它有个 as 属性,它能够做到一系列 subresource 和 prefetch 都无法做到的事情:
  • 浏览器可以为资源设置正确的优先级,以便相应的加载它,并且不会阻塞其他重要,或者标签后面没有那么重要的资源加载。
  • 浏览器可以确保请求受到正确的 Content-Security-Policy 指令的约束,如果它不应该被发出的话则请求不会到达服务器。
  • 浏览器可以根据资源类型发送合适的 Accept 请求头(比如,在加载图片资源的时候,表明浏览器支持 “image/webp” 格式)
  • 浏览器知道资源的类型,所以当后续有请求相同的资源时它可以判断是否可以复用当前资源。
Preload 与前面两者不同的地方还在于它有 onload 事件(至少在 Chrome 上,其他两个 rel 类型的资源加载并没有该事件)。
最重要的是,Preload 不会阻塞页面的 onload 事件,除非当前资源被阻塞该事件的资源给引用到。
结合上面这些特点,Preload 为我们赋予了许多之前我们无法做到的能力。
我们一起来看看这些新能力吧!

加载较晚被发现的资源(Late-Discovered Resource)

Preload 最基本的用法就是用来提前加载页面较晚被发现的资源。尽管大部分以 HTML 标签形式存在的资源都可以在非常早的时机里被浏览器的 preloader 发现,但是并不是所有资源都是以标签形式存在的。有部分资源隐藏在 CSS 文件以及 JS 文件里面,所以浏览器在相当晚的时机里面才能知道这些资源是需要被加载的。所以大部分时候,这些资源会导致延迟首次渲染,文本的渲染,或者页面重要部分的加载。
现在你有办法可以这样告诉浏览器,“嘿,浏览器!这里有一个你待会会用到的资源,所以现在开始加载它吧。”
这种方法以代码形式表达的话会是这样的:
<link rel="preload" href="late_discovered_thing.js" as="script" >
这个 as 属性告诉浏览器它将会下载一个什么类型的文件。as 属性的值可以是以下几种:
  • "script"
  • "style"
  • "image"
  • "media"
  • 以及 "document"
(完整的列表可以在 fetch 的规范里面查阅)
如果忽略 as 属性,或者赋值了一个非法的值,那么它会相当于一个 XHR 请求,因为浏览器不知道它在加载的是什么资源,并且会以非常低的优先级去加载。

字体文件的提前加载

“较晚被发现的关键资源”模式的一个常见体现就是网络字体。一方面来说,大部分时候它对于页面上的文字渲染是非常关键的(除非你正在使用崭新的 font-display CSS 属性值)。另一方面,它被深深埋在 CSS 文件当中,另外就算是浏览器的 preloader 解析了 CSS 文件,它也无法确定这些字体文件是否就是需要的,除非浏览器也知道依赖这些字体的 CSS 规则选择器也确实应用到了某些 DOM 节点上。理论上来说,浏览器可以知道这些,但是没有一个浏览器会这样做,而且即使浏览器会这样做的话,也会造成无意义的下载,如果字体规则被后面的规则覆盖了的话。
简单来说,它很复杂。
但是,你可以通过添加你需要的字体的 preload 指令来绕过这些麻烦事。类似这样:
<link rel="preload" href="font.woff2" as="font" type="font/woff2" crossorigin>
有一点需要指出:当加载字体的时候你需要添加一个 crossorigin 属性,因为他们是使用匿名模式的 CORS 加载的。是的,即使你的字体文件跟你的页面同源也需要。抱歉。
还有,这个 type 属性是用来确保这个资源只会在支持这个文件类型的浏览器上才会预加载。截至目前为止,只有 Chrome 支持 preload(译者注:原文写于2016年),并且它也支持 WOFF2 格式,但是将来会有更多的浏览器支持 preload ,不过我们无法假设他们都会支持 WOFF2 。对于其他你想预加载的资源情况也是一样,不同浏览器的支持程度并非一致。

动态加载,但是不执行

另外一个很有趣的场景现在变得可能,那就是当你想下载一个资源,因为你知道你需要它,但是你不想立即执行它。比如,想象有这么一个场景,你想在整个页面生命周期的某个时间点执行某段脚本,但是不具备控制脚本内容的能力(因此无法向其添加类似 runNow() 的函数)。
目前来说,想要实现这种目的方法非常有限。如果你仅仅只是在当你想执行这段脚本的时候动态注入脚本,那么浏览器需要在执行这段脚本之前先下载它,这意味着这个操作可能会持续一段时间。你也可以事先通过 XHR 去下载这段脚本,但是浏览器会拒绝复用它,因为你再次请求这个资源时的文件类型与你之前下载好的不同。
那么我们有什么办法呢?
在没有 preload 之前,基本没有好的办法。(在某些情况下你可以使用 eval() 来执行脚本内容,但是这种方式并不是一直可行的,并且有副作用。)好在,使用 preload 可以做到这一点。
var preload = document.createElement('link');
link.href = 'myscript.js';
link.rel = 'preload';
link.as = 'script';
document.head.appendChild(link);
你可以在页面加载的过程中执行这段脚本,比你想要执行这段脚本的时机要早得多(但是前提是你相当确信这个脚本的加载不会影响其他更加重要的资源的加载)。然后,当你需要运行这段脚本的时候,你只需要简单地插入一个 script 标签就行了。
var script = document.createElement('script');
script.src = 'myscript.js';
document.body.appendChild(script);

基于标签的异步加载

另外一个很酷的 hack 是利用 onload 事件回调来实现类似基于标签的异步加载器。 Scott Jehl第一个尝试使用这个方法的人,在他的 loadCSS 库里面。简单的说,你可以编写类似这样的代码:
<link rel="preload" as="style" href="async_style.css" onload="this.rel = 'stylesheet'">
这样你就可以实现基于标签的样式异步加载了!Scott 还写了一个很棒的 demo 来展示这个功能。
这个功能同样也可以用在脚本的异步加载。
什么?你说我们已经有了 <script async> ?其实,<script async> 很棒,但是它会阻塞 window 的 onload 事件。在某些场景下,这可能是你期望的结果,但是在其他场景下你可能并不想要这样。
假设你想要下载一个统计脚本。你想尽快地下载这个脚本(尽可能减少脚本没有加载完成导致无法统计到这部分用户),但是你不想让它引起用户体验相关指标的下降,特别是你不会想让它阻塞页面的 onload 。(你可以说 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);">
(在 onload 属性里面引入非常长的 JS 函数恐怕不是一个好主意,所以你可能需要将那部分代码定义成一个行内函数。)

响应式加载

既然 preload 本质上是一个 link 标签,根据 link 的标准它有一个 media 属性。(目前这个功能不在 Chrome 中支持,但是很快就会支持了。)这个属性可以做到资源的条件加载。
这样做有什么好处?我们假设你的网站初始视图里面在桌面端/宽屏设备有一个非常大的交互式地图,但是你只想在移动端/窄屏设备上展示一个静态的地图。
如果你足够聪明的话,那么你会想只加载所需要的那个资源,而不是加载两份。想要实现这样的目的你只能通过 JS 动态地去加载它。但是这样做的话,会使得这些资源相对于 preloader 来说不可见了,并且他们可能会加载得更晚,这样会影响用户的视觉体验效果,并且对你的 SpeedIndex 分数有负面影响
我们怎样做才能够让浏览器尽可能早地知晓这些资源呢?
你猜中了!Preload 。
我们可以使用 Preload 提前加载他们,并且我们可以利用它的 media 属性,这样只有需要的脚本才会被 preload 。
<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 响应头

另一个 link 标签带来的功能是它们可以以 HTTP 响应头的方式呈现。这意味着上面我展示的大多数例子,你都可以使用一个 HTTP 响应头来实现相同的功能。(唯一的例外是 onload 相关的例子。你没办法为 HTTP 响应头定义 onload 事件的回调函数。)
这种 HTTP 响应头的例子看起来就像这样:
Link: <thing_to_load.js>; rel="preload"; as="script"
Link: <thing_to_load.woff2>; rel="preload"; as="font"; crossorigin
当负责做性能优化的人和负责处理 HTML 标签的人不是同一个人时,HTTP 响应头就显得很有用。一个很重要的例子是外部优化引擎通过扫描内容并且对其进行优化(我负责过一个类似的工作)。
其他例子可以包括想要添加此类优化的独立性能优化团队,或者优化构建过程来减少 HTML 内容的操作,以此来显著减少复杂度。

能力探测

最后一点:上面的一些例子,类似脚本或样式文件的加载,我们依赖于一个事实是 preload 功能是被浏览器支持的。如果浏览器不支持这个功能呢?
所有东西都停止工作了!
我们并不想这样。所以作为 preload 的一部分,我们还修改了 DOM 的标准,所以对于所支持的 rel 关键字的能力探测成为了可能。
一个能力探测的示例函数可以是这样子的:
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.
}
如果浏览器不支持 preload 会破坏你的站点的话,这提供了一种 fallback 的加载机制,真方便!

HTTP/2 Push 不是可以处理上面这些使用场景吗?

并不全是。尽管在部分功能上它们会存在重叠,但是绝大多数它们是互补的关系。
HTTP/2 Push有个优点是能够主动推送浏览器尚未发送请求的资源。这意味着 Push 甚至可以在 HTML 发送到浏览器之前就推送一些资源给浏览器。它还可以用在已连接的 HTTP/2 连接上推送资源,而无需依赖能够附加 HTTP Link 响应头的响应。
另一方面来说,preload 可以用来解决 HTTP/2 解决不了的场景。如我们所见,有了 preload 之后程序知道资源正在加载当中,并且当资源加载完成后可以接收到通知。这并不是 HTTP/2 所能够做到的事情。而且 HTTP/2 Push 不能够用在第三方资源上,但是 preload 可以跟使用第一方资源一样便捷地加载第三方资源。
此外,HTTP/2 Push 无法考虑浏览器的缓存以及非全局 cookie 的状态。尽管缓存状态也可以通过新的缓存摘要标准来解决,但是对于非全局 cookie ,它没有什么可操作的余地,所以 Push 不适合用在依赖此类 cookie 的资源。对于这样的资源,preload 就是你最好的朋友。
Preload 的另外一个好处是他可以进行内容协商,而 HTTP/2 Push 不行。这意味着如果你想使用 Client-Hints 来确定要发送到浏览器的正确图像,或者为了找出最佳格式而使用 Accept: 请求头,那么 HTTP/2 Push 帮不了你。

所以...

我希望你现在能够确信 Preload 开辟了一套以前无法做到的新的加载能力,并且你对使用它感到兴奋。
我想让你们做的是打开你们的 Chrome Canary 吧,开始折腾 Preload ,仔细研究它然后向我抱怨。这是一个新功能,同其他新功能一样,它可能存在缺陷。请帮我找到它并且尽早修复它们。
译者注:截止2019/01/08,Preload 的兼容性如下图: