关键资源加载实践
Published: 2023-08-22 05:49:16


作为开发人员，您知道哪些文件最重要。你可以提前标记这些关键资源，加快加载过程。

本文介绍了如何达成此目的。

包括：

预加载资源在真实世界的应用场景。

何时以及如何使用preload。

如何在你的构建工具中标记这些关键资源，以及现有方案的不足。

## 发现问题

在一个应用里，我们发现当前页面真实能响应用户交互的时间非常晚。

让我们用一张图视图化这个问题

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/u9uromwyti9ucxbfl4bg.png)
为了使的client side渲染进行快速响应，渲染和响应交互的事件分了包，当用户渲染的脚本开始执行时，才会触发响应用户交互资源的加载。

```jsx
const button = <Button onclick=async() => {
				await import('/.real-click-response');
// This is the real time to interactive that user feel
}/>
render(button);
```

而这些代码里，可能会有其他响应交互所需的资源被进一部分包。这部分资源，在前面下载的资源实际开始执行的时候才被发现。

这个被称为**sequential network requests**问题。

在真实世界里，网络环境越差，造成的结果就越严重。这个问题可以在performance面板被发现。典型的特征是，主线程是空的，没有要处理的内容。空闲时间和下载时间正好好差不多。并且下一个任务正是 evaluate刚刚下载的script.


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/r9ff79fkzghz1nocluov.png)

## 怎么做预加载

我们期望可以达成这样的目标。

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/de3egnu78oq9omgzllo5.png)

在网络请求有余力的时候，提早下载接下来的重要资源。

### 错误的做法❌

实践中，有些人可能会在浏览器空闲的时候，尝试await import马上需要的资源。在应用的开头加入如下的话

```jsx
requestIdleCallback(() => {
		 await import('./resource-need-pretty-soon');
});
```

这个错误的原因在：

我们期望的是触发他的下载，而不是执行。import会触发文件的evaluation。执行写在该文件top level的语句。这可能会非常耗时，取决于你引入的module在top level写了多少自执行代码。实际观察中，最差可能会出现500ms以上。

client side render 以及应用初始化的时候，可能会产生很多微任务。我们不期望待会才需要的文件的evaluation会发生在 client side render的任何任务之前。

**预加载只应该占用网络线程的空闲资源而不是主线程的资源。**

### 没那么好的做法

自定义preload implementation，如fetch API。

 fetch需要被调用它的script在download, parse, compile, evaluate后才能被看见，实际上也产生了**sequential network requests。**即使inline了script，仍然需要compile 和执行。这仍然没有我们接下来要介绍的做法效率高。

### 正确的做法

幸运的是，浏览器提供了**[rel=preload**的<link>](https://web.dev/preload-critical-assets/)标签，去触发资源的下载。

虽然名字里有个load，但他只会触发下载和缓存，并不会真正执行。

具体可以被preload的资源，和该标签支持的属性可以[参考mdn的描述](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/preload)，在此不加以赘述。

但是关于preload，以下有一些你需要注意的事：

#### 1. Don’t preload everything

只preload那些你知道接下来绝对会使用到的资源。把不需要的资源preload只会浪费用户带宽。另外一次性preload过多的资源，可能会造成资源竞争。

如果资源下载下来后，大概三秒没有用到，浏览器会出以下告警。

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0tsuq4ccs42dxbg3u4n1.png)

如果下一个页面或者navigation才需要的资源，您可能需要的是[prefetch](https://developer.mozilla.org/en-US/docs/Glossary/Prefetch).

#### 2. 有必要preload在html的script吗？

在html嵌入的资源我们称之为initial assets，有些资源是动态加载的(譬如await import的资源)。我们称其为 async assets.

script标签有可能会出现在body的末尾，我们需要对这些 initial assets 增加preload标签吗？

**通常不需要。**


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/dhusc3qh7vgkn0c4qbh4.png)

现代浏览器的html parser 在阻塞资源面前的确会暂停。 例如不是async，defer或者module的script标签，或是link下载的stylesheet。

但是同时他会有一个另一个html parser: **[preload scanner](https://web.dev/preload-scanner/)**，提前发现当前页面里需要的资源。所以刚刚说到的， 放在body末尾的script标签也会被发现，即使前面有阻塞资源。所以是没必要对这些资源加preload标签的。

然而 ，目前的preload scanner只作用于html。假设你的css会触发字体下载，或者需要背景图片。那即使这些是渲染关键资源的确也发现不了。如果这是阻碍您性能的关键问题，的确可以尝试preload这些资源。

如果你的关键渲染资源被异步加载。用script去加载另一些js和css资源，preloads scanner就会不起作用。如果执行加载的script前面有block资源（例如首屏css), **sequential network request**依然存在。即使你尝试使用preload标签，这些加载preload标签的script也只会在blocking resource执行完才开始运行。

这种**sequential network request**被称为 [critical request chains](https://developer.chrome.com/zh/docs/lighthouse/performance/critical-request-chains/), 我们应该避免它。

所以比起preload标签，请先确认一定要用script去下载关键渲染资源吗？为什么不是直接把它们inline到html里？

#### 3. 会重复下载吗

会，如果你不足够小心。

preload会下载，并且放入缓存。但是有时，preload资源和下载的资源并不会复用，触发**double-fetch。**这不仅代表你做的preload优化没用，还代表浪费了用户的带宽。

这就是仔细阅读preload属性的必要时候了

1. 一定要带上[as](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attributes)属性。as描述了要被下载的资源类型，会影响Accept request header, request匹配等等。更重要的是 as还确定了preload 资源的fetch priority。
    
    关于preload资源的priority详细可以参看 [此表](https://web.dev/fetch-priority/#browser-priority-and-fetchpriority)。
    
2. 预加载的字体如过没用crossorigin attribute 会被加载两次
3. 如果使用了integrity，一些浏览器下不支持

Addy Osmani关于double fetch问题有[非常出色的文章](https://medium.com/reloading/preload-prefetch-and-priorities-in-chrome-776165961bbf)。

#### 4.  import preloads 只应该在下载他们的资源开始被import的时候才开始触发。

import preload, 包括 dynamic `import()` 或 `modulepreload`，preload资源的时机应该在加载他们的script被加载之后。确保加载他们的script顺序是在前面的。script A 加载script B。只要scriptA被加载的时候scriptB才被需要。 尤其是modulepreload除了下载缓存以外，还会compile指定的资源。

## 如何在工程里使用preload

在现代项目里，我们通常会使用打包工具将项目里的module组合成更大的文件，也就是bundle。所以在源码中，构建过程还未完成，我们是不可能自己加link标签的。所以我们需要借助打包工具将我们需要预加载的资源用link preload预加载。

对于bundler原理其实很相似，但值得注意的是vite会有auto preload的功能。不过问题依旧是相似的。

以下将用webpack 做一下说明：

什么是关键资源其实需要我们自己决定, webpack利用事先定义好的comment来约定用户如何告诉webpack这是需要被preload的文件。也可以告诉webpack他的优先级是什么

```jsx
import(
/* webpackPreload: true */
/* webpackFetchPriority: "high"*/
 "CriticalChunk")
```

在构建的时候，他会解析到这个comment，明白用户想要加载这个module。于是这个module所属于的chunk group都会被预加载。

不仅包含这个module本身，也包含这个module直接依赖的其他资源，如css，依赖的其他module等。

加载的时机是由webpack runtime，在require引入preload的资源的文件的时机加载， 也就是加载他们的script被加载时。

### **只在需要的时候preload**

所以以下场景，不应该加preload标志

```jsx
// Don't do this
if (someRuntimeCondition) {
	import(_/* webpackPreload: true */_ "CriticalChunkA")
} else {
	import(_/* webpackPreload: true */_ "CriticalChunkB")
}
```

由于bundler只能静态分析，他并不知道RuntimeCondition在runtime的值是什么。所以他无法判断需要preload哪个文件，只好两个都下载，这会极大程度浪费用户带宽.

在这个场景下我们该怎么做？

it depends.

**对于关键资源，可以在服务端触发主动下载。**

如果我们可以不依赖客户端知道这个condition是什么，并且我们可以确定这个资源关键关键路径一定会用到。

我们可以事先把构建工具产生的bundle信息上传，在服务器预先判断好条件，通过不同条件下发不同bundle。

这时会有两种方式: Server push 和 html 上附加`<link rel=preload>`。

Server push是http2 的一个新功能。他的一般做法是，在服务器上在html头部添加

```jsx
link:</push.css>; as=style; rel=preload
```

如果server支持它，就会把他看作要push的对象。否则客户端就会把它视为一个preload assets。如果你使用了cdn，cdn也要支持。

另一个做法是下发html的 上拼接`<link rel=preload>`

这两个做法的关键区别在：

1. server push不需要依赖preload scanner. 所以原理上他会快一点点（但preload scanner本身也不慢）
2. server push 的资源很容易被浪费。如果他已经push下来，但还没有被任何请求认领这个http session就被关闭的话，push缓存就没有了。如果这个请求已经在客户端缓存的话，server是没有办法知道的，依然push下来，造成带宽的浪费。

另外，即使不在服务器上，<link rel=preload>也可以在浏览器运行的任何时间被添加。

综上所述，可能大部分时间拼接`<link rel=preload>`是个比较好的方法。

### 在entrypoint不起效

下面这段代码<link rel=preload>不会生效，这和webpack的生态有关。

```jsx
async function main() {
    const { awaitImportByInitialChunk } = await import(/* webpackPreload: true */ './await-imported-by-intial-chunk');
    awaitImportByInitialChunk();
}

main();
```

webpack runtime只会去[加载被其他dynamic import的文件](https://medium.com/webpack/link-rel-prefetch-preload-in-webpack-51a52358f84c)await import 的资源。

正如上文所说，被preload的资源下载的时机，应该是加载它的文件被触发加载之后。而initial chunk是在html上通过script标签下载的。所以这种<link rel=preload>的位置就应该在这个script标签之后，html上。

在webpack的生态里，这是由其插件https://github.com/jantimon/html-webpack-plugin 完成。

遗憾的是，这个功能还没有被实现。不过html-webpack-plugin本身是支持插件的，所以我们可以[自己实现](https://github.com/principalstudio/html-webpack-inject-preload)它。（还未发布）

### 避免会触发资源竞争的写法

在实际生产中，还发现一种会让自动添加<link rel=preload>可能是负效果的写法。

 假设当前的应用分几个关键的stage加载自身。

```jsx
// entry.js
const firstStageResources = [
	import(/* webpackPreload: true */'./1'),
	import(/* webpackPreload: true */'./2'),
	//...
	import(/* webpackPreload: true */'./10'),
];
const secondStageResources = [
	import(/* webpackPreload: true */'./11'),
	import(/* webpackPreload: true */'./12'),
	//...
	import(/* webpackPreload: true */'./20'),
];
const thirdStageResources = [
	import(/* webpackPreload: true */'./21'),
	import(/* webpackPreload: true */'./22'),
  //...
	import(/* webpackPreload: true */'./30'),
];

function load(resources) {
		Promise.all(preloadResources)
}

function async load() {
	await load(firstStageResources);
	await load(secondStageResources);
	await load(secondStageResources);
}
```

我们假设想要预下载其中的文件，对每个import()都做了preload标记，会发现由于所有的动态import其实都由一个文件加载。 当这个文件是entry，也就是html加载的initial chunk的时候，所有资源会一起被preload下来。

本身[优先级很低](https://web.dev/fetch-priority/#browser-priority-and-fetchpriority)的标签，甚至高过页面本身就有的initial script 标签，造成和其他资源的竞争。如果不改变这种pattern继续preload, 在资源紧张的时候可能是种负优化。

### 我有serviceWorker了，preload link有效吗

service worker 在fetch事件的时候，进行拦截。

如果cache里面没有，依旧可以访问http cache(disk cache)。这时如果已经preload资源就会存在在http cache里。依旧避免了网络请求的时间。所以整体来说，这仍然有意义。

另外根据Jake Archibald[这篇文章](https://jakearchibald.com/2017/h2-push-tougher-than-i-thought/)，preload有独立的cache, 优先级会更高。导致被preload的资源，在资源原本被请求的位置发出请求时，不会走到service worker。

[Addy Osmani的文章](https://medium.com/reloading/preload-prefetch-and-priorities-in-chrome-776165961bbf)也提到如果这个文件是cacheable将会放入http cache, 如果不是甚至会被提升到memory cache。memory cache将比从disk里读的效率更高。

另外如果是 module preload，preload的资源甚至已经被compile好。

所以无论从何种角度上来说，preload都能和service worker一起使用，并在sequential request的场景下，起到性能提升作用。

## 总结

1. 不要使用import预加载，import实际会让代码执行， 使用自带的preload机制做预加载
2. 只预加载过会一定要用到的资源，预加载的时机是想要加载预加载模块的代码被加载时。
3. 首屏资源没有必有主动添加<link rel=preload>，让preload scanner帮助你
4. 没有办法使用preload scanner的情况，可以在构建工具里标注， 动态的内容可以在服务器帮助判断。如果entryPoint发现标注preload失效，可以使用我们提供的webpack插件
5. 避免double fetch， 或者触发资源竞争的写法

ref:

[https://web.dev/preload-scanner/](https://web.dev/preload-scanner/)

[https://developer.chrome.com/zh/docs/lighthouse/performance/critical-request-chains/](https://developer.chrome.com/zh/docs/lighthouse/performance/critical-request-chains/)

[https://web.dev/fetch-priority/#conclusion](https://web.dev/fetch-priority/#conclusion)

[https://medium.com/reloading/preload-prefetch-and-priorities-in-chrome-776165961bbf](https://medium.com/reloading/preload-prefetch-and-priorities-in-chrome-776165961bbf)

[https://sking7.github.io/articles/332630583.html](https://sking7.github.io/articles/332630583.html)

[https://www.keycdn.com/blog/http2-push](https://www.keycdn.com/blog/http2-push)

[https://medium.com/webpack/link-rel-prefetch-preload-in-webpack-51a52358f84c](https://medium.com/webpack/link-rel-prefetch-preload-in-webpack-51a52358f84c)