预取模块

Qwik 能够快速加载页面并变得交互起来,这要归功于它能够在没有 JavaScript 的情况下启动。除此之外,预取模块是一项强大的功能,它允许 Qwik 在后台线程中预先填充浏览器的缓存。

Qwik 的目标不是预先填充整个应用程序的缓存,而是在那个时间点上已经缓存了可能的内容。当 Qwik 优化器拆分应用程序时,它能够理解可能的用户交互。从这个角度来看,同样重要的是它能够理解用户交互中不可能的部分,并避免加载这些捆绑包。

按页面预先填充缓存

每次页面加载时,都会使用用户在该页面上可能执行的捆绑包预先填充缓存。例如,假设页面上有一个按钮的点击监听器。当页面加载时,服务工作者的第一件事就是确保该点击监听器的捆绑包已经在缓存中等待。当用户点击按钮时,Qwik 发送请求到事件监听器的捆绑包,目标是该捆绑包已经准备好在浏览器的缓存中执行。

按交互预先填充缓存

您可以将页面加载视为第一个用户交互,它会预先填充缓存,以便下一个用户交互可能使用。当发生后续交互,例如打开模态框或菜单时,Qwik 将再次发出另一个事件,并附带自上次交互以来可能使用的其他捆绑包。预先填充缓存不仅发生在页面加载时,而且在用户与应用程序交互时持续发生。

预先填充缓存事件

推荐的策略是使用服务工作者来填充浏览器的缓存。Qwik 框架本身应该使用预取事件实现,这已经是默认设置。

按页面预先填充缓存

传统上,服务工作者用于缓存应用程序使用的大部分或全部捆绑包。服务工作者通常只被视为使应用程序离线工作的一种方式。

然而,Qwik City 使用服务工作者的方式与众不同,提供了一种强大的缓存策略。目标不是下载整个应用程序,而是使用服务工作者动态预先填充缓存,以便执行可能的内容。通过不下载整个应用程序,可以释放资源,使用户只需请求屏幕上当前任务所需的应用程序部分。

此外,服务工作者还会自动为 Qwik 发出的这些事件添加监听器。

后台任务

使用服务工作者的一个优点是它也是工作者的扩展,运行在后台线程中。

Web Workers 使得在与 Web 应用程序的主执行线程分离的后台线程中运行脚本操作成为可能。这样做的好处是可以在单独的线程中执行繁重的处理,允许主线程(通常是 UI 线程)在没有被阻塞/减慢的情况下运行。

通过在服务工作者中预先填充缓存(它本身就是一个工作者),我们能够在后台任务中运行代码,以避免干扰主 UI 线程。通过不干扰主 UI,我们能够提高用户的 Qwik 应用程序的性能。

交互式预先填充缓存

Qwik 本身应该配置为使用预取事件实现(这也是 Qwik 的默认设置)。当 Qwik 发出事件时,服务工作者注册会主动将事件数据转发给已安装和激活的服务工作者。

服务工作者(运行在后台线程中)然后获取模块并将其添加到浏览器的缓存中。"主线程只需要发出有关所需捆绑包的数据,而服务工作者的唯一重点是缓存这些捆绑包。为了实现这一点,服务工作者预先填充浏览器的缓存

  1. 如果浏览器已经缓存了它?太好了,什么都不用做!
  2. 如果浏览器还没有缓存此捆绑包,那么让我们开始获取请求。

了解更多关于缓存请求和响应对

此外,服务工作者确保不会同时发生对同一捆绑包的多个请求。有关此内容的更多信息,请参阅并行化网络请求文档。

缓存请求和响应对

在许多传统框架中,首选的策略是使用带有 rel 属性的 <link>,例如 prefetchpreloadmodulepreload。然而,由于存在足够多已知问题,Qwik 更倾向于不将 link 设置为默认的预取策略(尽管仍然可以配置)。

相反,Qwik 更喜欢使用一种较新的方法,充分利用浏览器的缓存 API,这也比modulepreload更好地得到支持。

缓存 API

缓存 API通常与服务工作者一起使用,它是一种将请求和响应对存储在其中以使应用程序离线工作的方法。除了使应用程序能够在没有连接的情况下工作之外,同样的缓存 API 也为 Qwik 提供了一种极其强大的缓存机制。

使用已安装和激活的服务工作者来拦截请求,Qwik 能够处理特定的已知捆绑包请求。与通常使用服务工作者的方式相反,默认设置不会尝试处理所有请求,而只处理 Qwik 生成的已知捆绑包。站点的已安装服务工作者仍然可以由每个站点自定义

Qwik 优化器的一个优点是它还生成了一个 q-manifest.json 文件。该清单提供了一个详细的模块图,不仅显示了捆绑包的关联方式,还显示了每个捆绑包中的符号。这个相同的模块图数据提供给了服务工作者,它允许处理已知捆绑包的每个网络请求。

动态导入和缓存

当 Qwik 请求一个模块时,它使用动态的 import()。例如,假设发生了用户交互,需要 Qwik 执行 /build/q-abc.js 的动态导入,代码如下所示:

const module = await import('/build/q-abc.js');

这里重要的是,Qwik 本身对预取和缓存策略一无所知。它只是请求一个 URL。然而,由于我们安装了一个服务工作者,并且服务工作者正在拦截请求,它能够检查 URL 并说:“看,这是一个对 /build/q-abc.js 的请求!这是我们的一个捆绑包!在进行实际的网络请求之前,让我们首先检查一下我们是否已经在缓存中拥有它。”

这就是服务工作者和缓存 API 的威力所在!Qwik 首先在另一个线程中预先填充用户可能很快请求的模块的缓存。更好的是,如果已经缓存了该模块,那么浏览器就不需要做任何事情。

其他好处包括并行化网络请求

并行化网络请求

缓存请求和响应对文档中,我们解释了缓存服务工作者 API 的强大组合。然而,我们可以通过确保不为同一捆绑包创建重复请求,并且可以防止网络瀑布效应,将其推进一步,所有这些都是在后台线程中完成的。

避免重复请求

举个例子,假设最终用户当前使用的是一个较慢的 3G 连接。当他们首次请求着陆页时,尽管这个慢速网络允许的速度很快,设备会下载 HTML 并呈现内容(这是 Qwik 真正擅长的领域)。在这种慢速连接下,如果他们还需要下载几百千字节的内容才能使他们的应用程序工作并变得交互,那将是一种遗憾。

然而,由于应用程序是使用 Qwik 构建的,最终用户不需要下载整个应用程序才能使其变得交互。相反,最终用户已经下载了 SSR 渲染的 HTML 应用程序,以及任何交互部分,例如“添加到购物车”按钮,可以立即预取。

请注意,我们只预取实际的监听器代码,而不是整个组件树渲染函数的堆栈。

在这个极其常见的真实世界示例中,设备立即开始为最终用户可见的可能交互预先填充缓存。然而,由于他们的连接速度较慢,即使我们在后台线程中尽快缓存模块,获取请求本身仍然可能在进行中。

为了演示目的,假设获取此捆绑包需要两秒钟。然而,在查看页面一秒钟后,用户点击了按钮。在传统框架中,很有可能什么都不会发生!如果框架还没有完成下载、水合和重新渲染,那么事件监听器就无法添加到按钮上。这反过来意味着用户的交互将会丢失。

然而,使用 Qwik 的缓存功能,如果用户点击了按钮,并且我们已经在一秒钟前开始了一个请求,并且还剩下一秒钟才能完全接收到它,那么最终用户只需要等待一秒钟。请记住,在这个演示中,他们使用的是一个较慢的 3G 连接。幸运的是,用户已经收到了完整的渲染的着陆页,所以他们已经在看一个完成的页面。接下来,他们只是在预先填充缓存中添加了他们可以交互的应用程序部分,并且他们的慢速连接只用于这些捆绑包。这与他们的慢速连接下载整个应用程序,只为执行一个监听器的情况形成了对比。

Qwik 能够拦截对已知捆绑包的请求,如果在后台线程中已经有一个获取请求正在进行中,然后用户请求相同的捆绑包,它将确保第二个请求能够重用最初的请求,该请求可能已经完成下载。如果尝试使用link执行任何此操作,也可以看出为什么 Qwik 更倾向于不将其设置为默认值,而是使用缓存 API 并使用服务工作者拦截请求。

减少网络瀑布效应

网络瀑布效应是指多个请求一个接一个地发生,就像楼梯一样,而不是并行发生。网络请求的瀑布通常会影响性能,因为它增加了下载所有模块的时间,而不是每个模块同时开始下载。

下面是一个具有三个模块 A、B 和 C 的示例。模块 A 导入 B,B 导入 C。HTML 文档是通过首先请求模块 A 来启动瀑布的。

import './b.js';
console.log('Module A');
import './c.js';
console.log('Module B');
console.log('Module C');
<script type="module" src="./a.js"></script>

在这个示例中,当首次请求模块 A 时,浏览器不知道它还应该开始请求模块 BC。它甚至不知道在模块 A 完成下载之前,它需要开始请求模块 B。这是一个常见的问题,即浏览器事先不知道它应该开始请求什么,直到每个模块都完成下载。

然而,由于我们的服务工作者包含从清单生成的模块图,我们确实知道接下来将请求哪些模块。因此,当发生用户交互或对捆绑包的预取时,浏览器会启动将请求所有将要请求的捆绑包。这样我们就能大大减少请求所有捆绑包所需的时间。

用户服务工作者代码

由 Qwik City 安装的默认服务工作者仍然可以完全由应用程序控制。例如,源文件 src/routes/service-worker.ts 变成了 /service-worker.js,这是浏览器请求的脚本。请注意,它在 src/routes 中的位置仍然遵循基于目录的路由模式。

下面是 src/routes/service-worker.ts 源文件的示例:

import { setupServiceWorker } from '@builder.io/qwik-city/service-worker';
 
setupServiceWorker();
 
addEventListener('install', () => self.skipWaiting());
 
addEventListener('activate', (ev) => ev.waitUntil(self.clients.claim()));

src/routes/service-worker.ts 的源代码可以由开发人员根据需要进行修改。这包括选择是否使用 Qwik City 的服务工作者。

请注意,setupServiceWorker() 函数是从 @builder.io/qwik-city/service-worker 导入并在源文件顶部执行的。开发人员可以修改调用此函数的位置和方式。例如,开发人员可能希望首先处理 fetch 请求,这样他们会在 setupServiceWorker() 之前添加自己的 fetch 监听器。或者,如果他们根本不想使用 Qwik City 的服务工作者,他们只需从文件中删除 setupServiceWorker()

此外,默认的 src/routes/service-worker.ts 文件附带了一个installactivate事件监听器,每个监听器都添加在文件底部。提供的回调函数是推荐的回调函数。但是,开发人员可以根据自己应用程序的要求修改这些回调函数。

另一个重要的注意事项是,Qwik City 的请求拦截仅适用于 Qwik 捆绑包,它不会尝试处理任何不属于其构建的请求。

因此,虽然 Qwik City 提供了一种帮助预取和缓存捆绑包的方法,但它并不完全控制应用程序的服务工作者。这使开发人员能够添加自己的服务工作者逻辑,而不会与 Qwik 发生冲突。

在开发和预览期间禁用

在开发和使用 Vite 的预览模式期间,有一个需要注意的地方,即服务工作者被禁用,这也禁用了预取模块。在开发过程中,我们希望始终确保使用最新的开发代码,而不是之前的代码。

预取模块仅在生产构建中启用。要查看预取模块的效果,您需要在除开发或预览之外的服务器上运行生产构建。

HTTP 缓存与服务工作者缓存

预取模块可能看起来不起作用,部分原因是存在各种级别的缓存。例如,浏览器本身可能会在其HTTP 缓存中缓存请求,而服务工作者可能会在缓存 API中缓存请求。仅清空其中一个缓存可能不足以看到预取模块的效果。

误导性的清空缓存和硬刷新

当开发人员运行清空缓存和硬刷新时,它有点误导性,因为它实际上只清空了浏览器的 HTTP 缓存。然而,它并没有清空服务工作者的缓存。即使浏览器的 HTTP 缓存为空,服务工作者仍然具有先前缓存的请求。

此外,当使用“清空缓存和硬刷新”时,浏览器会在请求中发送一个 no-cache 的缓存控制头。由于请求带有 no-cache 的缓存控制头,服务工作者故意不使用自己的缓存,而是浏览器执行常规的 HTTP 获取。

清空服务工作者缓存

测试预取模块的推荐方法是:

  • 注销服务工作者:在 Chrome DevTools 中,转到 Application 选项卡,在 Service Workers 下,单击站点的服务工作者的“注销”链接。
  • 删除 "QwikBuild" 缓存存储:在 Chrome DevTools 中,转到 Application 选项卡,在左侧的 Cache Storage 下,右键单击 "QwikBuild" 缓存存储上的 "删除"。
  • 不要硬刷新:不要进行硬刷新,因为这会向服务工作者发送一个带有 no-cache 缓存控制的请求,而是单击 URL 栏并按回车。这将发送一个正常的请求,就像您是第一次访问一样。

请注意,此过程仅用于测试预取模块,对于新的构建不需要执行此过程。每次构建都会创建一个新的服务工作者,并且旧的服务工作者将自动注销。

Contributors

Thanks to all the contributors who have helped make this documentation better!

  • ulic75
  • mhevery
  • adamdbradley
  • hamatoyogi
  • manucorporat