性能优化之 preload、prefetch、preconnect

请注意区分 dns-prefetch 和 prefetch 的区别。这两种本文都会有讲到。

先说结论:这几种方式都可以提高网站的浏览体验,减少用户等待资源加载的时间。
具体来说:

  • preload、prefetch 都是为了提前加载资源,以减少用户后续浏览时的资源下载时间,两者选用一个即可;前者会令浏览器尽快加载资源,后者则是尝试在闲时自动后台下载;Webpack 支持这两种优化手段,可以通过特定的代码注释来开启;
  • dns-prefetch、preconnect 都是只在资源和主域名不同的场景下用于加速浏览器连接的,两者选用一个即可;前者预解析其他域名的 DNS,后者则更激进,还会对其他域名预先建立 TCP、TLS 握手等。

如果你读过 《特殊的代码注释》 这一篇博文,你可能对 preload 和 prefetch 比较熟悉。

preload 和 prefetch

preload 表示 “预加载”,prefetch 表示 “预获取”。

先来说说背景:

SPA 站点的特点就是会代码打包,这样才能提供更好的体验。但如果把全站的 JS、CSS 打包在一起,首屏加载会很久,所以我们会进行 “代码分割” ,按照一定的模块划分来对代码进行拆分成多个 chunk(代码块),部分页面是只有用户访问到时才会尝试加载,这样就可以大大减少首屏的加载时间。

例如,商店页面可以只把首页单独做成一个 chunk,而用户、订单、购物车页都分割出去,等到用户访问时再去加载。

Webpack 本身支持代码分割,此时会注入代码,在用户浏览时会根据用户的访问情况自动加载用到的 chunk。具体语法是这样:

// 以下以商店页面的 “关于” 页举例

// 不使用代码分割,直接把 “关于” 页和首页打包在一起
import AboutPage from '@/pages/about'

// 使用代码分割,把 “关于” 页分割出去,不要一次性加载
import loadable from '@loadable/component'
const AboutPageAsync = loadable(() => import('@/pages/about'))

可以看出,实现代码分割只需我们使用 import() 这种模块引入方式,且一般这个操作都是在路由注册的时候,所以也被称为 “懒路由”。


目的和原理:

继续上面的例子:商店页面可以只把首页单独做成一个 chunk,而用户、订单、购物车页都分割出去,等到用户访问时再去加载。
而如果用户浏览首页时,点击了购物车链接,那么浏览器加载购物车页面的资源,这也需要加载时间,会造成短暂的延时从而影响用户体验。

我们想要在用户浏览过程中,浏览器在后台把某些页面的资源加载完毕,用户后续点击链接便可以瞬间跳转过去,无需等待资源加载。
这便是 “预加载” 或者说 “预获取” 资源。

Webpack 给我们提供了简便的配置 preload 和 prefetch 的方式,我们只需要在 import() 函数括号中添加特定的注释即可:

// 这个关于页会被预加载
const AboutPage1 = loadable(() => import(/* webpackPreload: true */ '@/pages/about'))

// 这个关于页会被预获取
const AboutPage2 = loadable(() => import(/* webpackPrefetch: true */ '@/pages/about'))

放置 /* webpackPreload: true */ 注释开启某个模块的预加载;
放置 /* webpackPrefetch: true */ 注释开启某个模块的预获取。

只要使用了这些功能,Webpack 便会在打包的产物中注入对应的代码,使得我们在浏览网站的过程中自动预加载或预获取一些模块的代码。


先来讲 prefetch,因为它的逻辑比较简单:

假设任意页面 A 通过 import(/* webpackPrefetch: true */ '...') 这种方式引入了页面 B,那么在页面 A 的代码装载完成之后,Webpack 注入的代码会自动往网页的 <head> 内加入以下内容:

<head>
<link rel="prefetch" as="script" href="模块B的文件.js">
</head>

这个标签的作用是提示浏览器:用户将来可能需要某些资源,所以浏览器可以在网络空闲时在后台默默加载好这些资源。MDN 文档说明
浏览器会以较低的优先级尝试后台加载这些资源,并短时间缓存。

理论上来说,网站所需的大多数 JS 文件都可以添加到这个标签中,小型或静态网站甚至可以直接把这个标签写死进 HTML 里。
Webpack 则更为智能,只需要按照上面说的格式对需要 prefetch 的模块添加对应的注释,打包时便会生成代码,根据用户访问的页面来动态的向 <head> 里面添加 prefetch 标签。

以商店网站来举例,具体流程是这样的:


然后就是 preload,它的逻辑和 prefetch 相似,但有不同之处:

假设任意页面 A 通过 import(/* webpackPreload: true */ '...') 这种方式引入了页面 B,那么在页面 A 刚开始被装载时,Webpack 注入的代码便会自动往网页的 <head> 内加入以下内容:

<head>
<link rel="preload" as="script" href="模块B的文件.js">
</head>

preload 标签的作用是提示浏览器:用户很快就要用到某些资源,浏览器会以较高的优先级去加载这些资源。MDN 文档说明

和前面说的一样,小型或静态网站可以直接把这个标签写死在 HTML 里。
Webpack 也会根据注释和页面加载情况动态的添加 preload 标签。

以商店网站来举例,流程是这样的:

Webpack 在动态设置 preload 和 prefetch 标签时,行为上有一个巨大的区别:

  • 设置 prefetch 标签的时机是在当前页面加载完成后;
  • 而设置 preload 标签的时机非常早,当前页面刚开始加载的一瞬间,其他模块的 preload 标签便已经被设置好了。

从上面的第二条行为可以看出,主 chunk 中所有的 preload 其实都是无效的。
举例:主 chunk 页面 A 异步加载 B,B 异步加载 C,且都使用 preload 来加载,此时 B 是无法被 preload 的,但是 C 可以。

原因:
preload 的代码会在页面 B 刚开始被装载时便会设置 C 的 preload 标签,所以实际上页面 C 的资源下载是和页面 B 同步进行的。这意味着在页面 B 的前一个页面(也就是 A),便已被注入了 Webpack 的预加载代码逻辑了
而换做是页面 A,它本身就被打包在主 chunk 里,主 chunk 必定首先加载,没有比它更前的页面了,那么实际上并没有一个时机来为 B 放置 preload 的标签,所以此时 B 的 preload 是无法生效的

Github 上同样有很多人对此有疑问:链接1链接2

如果真想实现主 chunk 引入其它模块的 preload,则需要 Webpack 分析所有主 chunk 用到的 preload 加载的资源,并提前注入到 HTML 文件的 <head> 里,这也需要 html-webpack-plugin 插件来与之配合,通过 插件作者的回复 来看,目前看来这个功能并没有任何计划提供支持。


关于 <link rel="preload/prefetch"> 这个标签:

浏览器对不同域名的缓存往往不能共通,所以给 prefetch 和 preload 提供了跨域的资源可能无法达成效果。

此外,被预加载或预获取的资源如果响应头的 Cache-Control 被设为 no-storeno-cache,那么也是没有效果的。

此外,这个标签的 as 属性是必须的。它的值一般为 "script",但也支持 "style""font""image" 等值。使用这个属性,会为浏览器的预加载或预获取请求添加 Accept 头。
值得一提的是,因为 CSS 自身有能力引入其它资源(使用 @import() 语法),可能会导致加载一大堆文件,所以 Webpack 没有计划为 CSS 文件提供预加载或预获取的功能。

dns-prefetch(DNS 预解析)

注意 dns-prefetch 和 prefetch 有巨大的区别,不可混为一谈。

dns-prefetch 我们称为 “DNS 预解析”,它只适用于存在 CDN 且 CDN 域名与主域名不同的情况
它的作用是让浏览器提前为其它的域名做 DNS 解析,提高后续访问的速度。

以知乎为例:

这里知乎为自己的几个 CDN 域名都做了 dns-prefetch,这样后续浏览时便可以优化连接速度。
如果需要对 dns-prefetch 的优化进行指标化的度量,这篇文章 有提到各个浏览器查看 DNS 解析性能指标的方式。

可以看出,DNS 预解析是通过在 <head> 中放置标签的方式来实现的:

<head>
<link rel="dns-prefetch" href="域名">
</head>

因为网站打开后马上就会加载资源,所以 DNS 预解析生效的越早越好,一般是希望把标签直接放置在 HTML 中,而不是通过 JS 来放置。


实现方式:

因为 DNS 预解析通常是直接放在 HTML 的,所以我们需要使用 Webpack 插件来实现。

使用 html-webpack-tags-plugin 插件:

const HtmlWebpackTagsPlugin = require('html-webpack-tags-plugin')

// 放入 webpack 的 plugins 中:
new HtmlWebpackTagsPlugin({
usePublicPath: false, // ← 如果不加这一行,href 前面会多出一个 "/"
links: [
{ path: 'https://example.com', attributes: { rel: 'dns-prefetch' } },
],
})

preconnect(预连接)

preconnect 我们称为 “预连接”,它同样适用于某些资源和主域名不同的场景,它会使浏览器提前发起 DNS 解析、TCP 握手,如果是 https 的网站还会提前进行 TLS 握手。

推荐的做法是,只将最关键的域名进行 “预连接”。因为预连接的域名太多会导致占用很多网络带宽,可能会导致适得其反。
以 bilibili 视频播放页为例:

可以看到只有两个域名开启了预连接。


实现方式:

预连接也是推荐直接放在 HTML 的,方法和 DNS 预解析类似:

const HtmlWebpackTagsPlugin = require('html-webpack-tags-plugin')

// 放入 webpack 的 plugins 中:
new HtmlWebpackTagsPlugin({
usePublicPath: false, // ← 如果不加这一行,href 前面会多出一个 "/"
links: [
{ path: 'https://example.com', attributes: { rel: 'preconnect' } },
],
})

通常来说,preconnect 覆盖了 dns-prefetch 的功能,但是后者的兼容性更好,早期的浏览器也能良好的支持。
所以,很多网站只要使用了 preconnect,也会同时提供一个相同域名的 dns-prefetch,这样遇到低版本浏览器不支持 preconnect 时,也能让 dns-prefetch 兜底。

知乎上有一篇 文章 对 preconnect 和 dns-prefetch 做了测试,可以看出这两者都能明显提升性能,且 preconnect 更加明显。而且 preconnect 和 dns-prefetch 同时开启时,和只开启 preconnect 是没有区别的(2ms 区别应该是误差)。

modulepreload

自 Chrome 66 开始,新增了 <link rel="modulepreload"> 的支持,可以称之为 “模块预加载”。

它和 preload 类似,但是相比之下它除了下载和缓存资源之外,还对资源代码提供了原生 ES Module 的支持,会解析和编译它并把结果放入 module map 中,为后续的执行代码做好准备。
可以理解为:这是为 ES Module 提供的专用的 preload。

在本地开发时,Vite 利用了这个功能,使得本地开发调试 JS 的速度得到进一步加快。