如何让网站的不同版本可以同时运行(附代码示例)

287 阅读8分钟

用户很容易运行你的网站的旧版本。不仅如此,一个用户可能同时在不同的标签中运行你的网站的许多不同版本,这有点可怕。比如说:

  1. 一个用户打开你的网站
  2. 你部署了一个更新
  3. 用户在一个新标签页中打开你的一个链接
  4. 你部署了一个更新

现在你的网站有三个版本在运行。两个在用户的机器上,另一个在你的服务器上。

这可能会导致我们不常考虑的各种令人兴奋的故障。

但这不太可能,对吗?

呃,这取决于。有许多事情可以增加这种情况发生的机会。

你经常部署:我们被教导说这是一件好事,对吗?尽早部署,经常部署。但如果你每天多次更新你的网站,这就增加了服务器上的版本与用户标签中的版本不一致的机会。这也增加了用户可能在不同的标签中运行多个版本的机会。

你的导航是客户端的:全页面刷新是一个从服务器上获取最新信息的好机会。然而,如果你的导航是由JS管理的,它会让用户在当前版本的网站上停留更长时间,使他们更有可能与服务器不同步。

用户很可能在你的网站上有多个标签打开:例如,我有3个标签指向HTML规范的各个部分,11个指向GitHub,2个指向Twitter,2个指向谷歌搜索,9个指向谷歌文档。其中有些是我独立打开的,有些是我通过在 "新标签 "中打开链接创建的。其中一些打开的时间比其他的长,增加了他们运行这些网站的不同部署的机会。

你的网站通过一个服务工作者使用离线优先模式:由于你从缓存中加载内容,而这个缓存可能是几天前填充的,所以它很可能与服务器不同步。然而,由于在一个注册中只能有一个服务工作者处于活动状态,它减少了用户在不同标签中运行多个版本的网站的机会。

如果将其中几个结合起来,突然间,用户就有可能与服务器不同步,或者他们最终在多个标签上运行不同的版本。什么会出错?

东西改变了

如果用户加载了你的一个页面,你部署了一个更新,然后用户点击了一个按钮:

btn.addEventListener('click', async () => {
  const { updatePage } = await import('./lazy-script.js');
  updatePage();
});

在你的页面上运行的JavaScript是V1,但./lazy-script.js 现在是V2。这可能会使你出现各种潜在的错误匹配:

  • updatePage 添加的内容依赖于V2的CSS,所以结果看起来是破碎的/奇怪的。
  • updatePage 试图更新类名为 的元素,但在V1中,该元素被命名为 ,所以脚本会抛出。main-content main-page-content
  • updatePage 已经改名为 ,所以 抛出了一个错误。updateMainComponent updatePage()

这是与页面上其他东西共同依赖的任何懒人加载的问题,包括CSS和JSON。

解决方案

你可以使用一个服务工作者来缓存当前版本。服务工作者的生命周期不会让新的版本接管,直到使用当前版本的一切都消失。然而,这意味着先期缓存lazy-script.js ,这也许违背了节省带宽的初衷。

另外,你也可以遵循缓存的最佳实践,修改你的文件,使其内容不可改变。与其说是./lazy-script.js ,不如说是./lazy-script.a837cb1e.js 。但是...

东西丢失了

如果用户加载了你的一个页面,你部署了一个更新,然后用户点击了一个按钮:

btn.addEventListener('click', async () => {
  const { updatePage } = await import('./lazy-script.a837cb1e.js');
  updatePage();
});

在最新的部署中,懒惰的脚本被更新了,所以它的URL变成了lazy-script.39bfa2c2.js ,或者其他什么。

在过去的日子里,我们会把不可变的资产推送到某种静态主机上,如Amazon S3,作为部署脚本的一部分。这意味着lazy-script.a837cb1e.js lazy-script.39bfa2c2.js 都会存在于服务器上,并且一切都会正常工作。

然而,在较新的容器化/无服务器构建系统中,旧的静态资产可能已经消失,所以上述脚本将以404失败。

这不仅仅是懒惰加载的脚本的问题,它是任何懒惰加载的问题,甚至像这样简单的东西:

<img src="article.7d62b23c.jpg" loading="lazy" alt="…" />

解决方案

回到独立的静态主机似乎是一种退步,尤其是在HTTP/2的世界里,通过多个HTTP连接提供服务对性能不利。

我希望看到像Netlify和Firebase这样的团队提供一个解决方案。也许他们可以提供一个选项,在模式匹配的资源从最新的构建中消失后,在一定的时间内保留它们。当然,你仍然需要一个清除选项来清除含有安全漏洞的脚本。

另外,你也可以处理导入错误:

btn.addEventListener('click', async () => {
  try {
    const { updatePage } = await import('./lazy-script.a837cb1e.js');
    updatePage();
  } catch (err) {
    // Handle the error somehow
  }
});

但这里没有给你失败的原因。在上面的代码中,由于用户离线而导致的连接失败,脚本中的语法错误,以及获取过程中的404都是无法区分的。然而,一个建立在fetch() 上的自定义脚本加载器将能够做出区分。

try/catch 需要在V1的代码中,这意味着V1需要为V2引入的错误做好准备,我不知道你怎么样,但我并不总是对这种东西有完全的预见性。

同样,你可以在这里使用一个服务工作者,因为它让V2对如何处理V1的页面有一些控制,即使只是强行重新加载它们:

// In the V2 service worker
addEventListener('activate', async (event) => {
  for (const client of await clients.matchAll()) {
    // Reload the page
    client.navigate(client.url);
  }
});

这对用户来说是相当具有破坏性的,但是如果V1对V2完全没有准备,而你又需要它消失的话,它就给了你一条出路。

存储模式改变

如果用户加载了你的一个页面,你部署了一个更新,然后用户在另一个标签中加载了你的另一个页面,怎么办?现在,用户有两个版本的网站在运行。

两者都有:

function saveUserSettings() {
  localStorage.userSettings = getCurrentUserSettings();
}

但如果V2引入了一些新的用户设置呢?如果它改变了一些设置的名称呢?我们经常记得把存储的数据从V1迁移到V2格式,但是如果用户继续与V1互动呢?当V1试图保存用户设置时可能会发生一些有趣的事情:

  • 它可以用旧的名字保存东西,这意味着这些设置在V1中可以使用,但与V2不同步--设置已经分叉了。
  • 它可以丢弃任何它不认识的东西,这意味着它删除了由V2创建的设置。
  • 它可以把存储放到一个V2无法理解的状态(特别是如果V2认为它已经迁移了数据),在V2中造成断裂。

解决方案

你可以给你的部署提供版本号,并让它们在存储被比当前标签版本更晚的版本改变时显示错误:

function saveUserSettings() {
  if (Number(localStorage.storageVersion) > app.version) {
    // WHOA THERE!
    // Display some informative message to the user, then…
    return;
  }
  localStorage.userSettings = getCurrentUserSettings();
}

IndexedDB在设计上让你走这条路。它有内置的版本管理,在所有 V1 的连接关闭之前,它不会让 V2 连接到数据库。然而,V2不能强迫V1连接关闭,这意味着V2被留在一个阻塞的状态,除非你为V2的到来充分准备好V1。

如果你使用一个服务工作者并从缓存中提供所有内容,服务工作者的生命周期将阻止两个标签运行不同的版本,除非你另有规定。而且,如果V1对V2的到来完全没有准备,V2可以强行重新加载V1的页面。

API响应变化

如果用户加载了你的一个页面,你部署了一个更新,然后用户点击了一个按钮,怎么办?

btn.addEventListener('click', async () => {
  const data = await fetch('/user-details').then((r) => r.json());
  updateUserDetailsComponent(data);
});

然而,/user-details 返回的响应格式在部署后发生了变化,因此updateUserDetailsComponent(data) 抛出或表现为通常的方式。

解决方案

在本文的所有场景中,这是在野外通常处理得很好的一种。

最简单的做法是对你的应用程序进行改版,并将其与请求一起发送:

btn.addEventListener('click', async () => {
  const data = await fetch('/user-details', {
    headers: { 'x-app-version': '1.2.3' },
  }).then((r) => r.json());

  updateUserDetailsComponent(data);
});

现在你的服务器可以返回一个错误,或者返回该版本的客户端所要求的数据格式。服务器分析可以监控旧版本的使用情况,所以你知道什么时候删除处理旧版本的代码是安全的。

你为你的网站的多个版本同时运行做好准备了吗?

我并不是想指手画脚--我工作的很多东西都使用无服务器构建,所以它们至少容易受到我所概述的一些破坏,我在这里提出的一些解决方案有点弱,但它们是我所得到的最好的。我希望我们有更好的工具来处理这个问题。

你有更好的方法来处理这些情况吗?请在评论中告诉我。