DEV Community

icy0307
icy0307

Posted on

关键资源加载实践

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

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

包括:

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

何时以及如何使用preload。

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

发现问题

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

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

Image description
为了使的client side渲染进行快速响应,渲染和响应交互的事件分了包,当用户渲染的脚本开始执行时,才会触发响应用户交互资源的加载。

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

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

这个被称为sequential network requests问题。

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

Image description

怎么做预加载

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

Image description

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

错误的做法❌

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

requestIdleCallback(() => {
         await import('./resource-need-pretty-soon');
});
Enter fullscreen mode Exit fullscreen mode

这个错误的原因在:

我们期望的是触发他的下载,而不是执行。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的](https://web.dev/preload-critical-assets/)标签,去触发资源的下载。

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

具体可以被preload的资源,和该标签支持的属性可以参考mdn的描述,在此不加以赘述。

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

1. Don’t preload everything

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

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

Image description

如果下一个页面或者navigation才需要的资源,您可能需要的是prefetch.

2. 有必要preload在html的script吗?

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

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

通常不需要。

Image description

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

但是同时他会有一个另一个html parser: 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, 我们应该避免它。

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

3. 会重复下载吗

会,如果你不足够小心。

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

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

  1. 一定要带上as属性。as描述了要被下载的资源类型,会影响Accept request header, request匹配等等。更重要的是 as还确定了preload 资源的fetch priority。

    关于preload资源的priority详细可以参看 此表

  2. 预加载的字体如过没用crossorigin attribute 会被加载两次

  3. 如果使用了integrity,一些浏览器下不支持

Addy Osmani关于double fetch问题有非常出色的文章

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他的优先级是什么

import(
/* webpackPreload: true */
/* webpackFetchPriority: "high"*/
 "CriticalChunk")
Enter fullscreen mode Exit fullscreen mode

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

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

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

只在需要的时候preload

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

// Don't do this
if (someRuntimeCondition) {
    import(_/* webpackPreload: true */_ "CriticalChunkA")
} else {
    import(_/* webpackPreload: true */_ "CriticalChunkB")
}
Enter fullscreen mode Exit fullscreen mode

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

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

it depends.

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

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

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

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

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

link:</push.css>; as=style; rel=preload
Enter fullscreen mode Exit fullscreen mode

如果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>是个比较好的方法。

在entrypoint不起效

下面这段代码不会生效,这和webpack的生态有关。

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

main();
Enter fullscreen mode Exit fullscreen mode

webpack runtime只会去加载被其他dynamic import的文件await import 的资源。

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

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

遗憾的是,这个功能还没有被实现。不过html-webpack-plugin本身是支持插件的,所以我们可以自己实现它。(还未发布)

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

在实际生产中,还发现一种会让自动添加可能是负效果的写法。

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

// 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);
}
Enter fullscreen mode Exit fullscreen mode

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

本身优先级很低的标签,甚至高过页面本身就有的initial script 标签,造成和其他资源的竞争。如果不改变这种pattern继续preload, 在资源紧张的时候可能是种负优化。

我有serviceWorker了,preload link有效吗

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

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

另外根据Jake Archibald这篇文章,preload有独立的cache, 优先级会更高。导致被preload的资源,在资源原本被请求的位置发出请求时,不会走到service worker。

Addy Osmani的文章也提到如果这个文件是cacheable将会放入http cache, 如果不是甚至会被提升到memory cache。memory cache将比从disk里读的效率更高。

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

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

总结

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

ref:

https://web.dev/preload-scanner/

https://developer.chrome.com/zh/docs/lighthouse/performance/critical-request-chains/

https://web.dev/fetch-priority/#conclusion

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

https://sking7.github.io/articles/332630583.html

https://www.keycdn.com/blog/http2-push

https://medium.com/webpack/link-rel-prefetch-preload-in-webpack-51a52358f84c

Top comments (0)