本文是一篇译文,原文链接: https://jakearchibald.com/2020/multiple-versions-same-time/
原文标题:Different versions of your site can be running at the same time
原文作者:Jake Archibald。Google Chrome的工程师
本文由 Alec He 翻译。Alec He是我的大学同学,目前也在某公司搬砖。
正文开始
对于一个用户来说,运行网站的旧版本是件非常容易的事情。不仅如此,用户也可以在不同的标签页下同时运行你网站的不同版本,这听起来是一件令人可怕的事情。比如说:
- 一个用户打开你的网站
- 你部署了一个更新
- 该用户在另一个标签页中打开你网站中的一个链接
- 你又部署了一个更新
现在该网站有三个不同的版本在同时运行,其中两个在用户的机器上,一个在你的服务器上。
这样可能会导致各种我们不常考虑到的问题。
但这不太可能发生,对吧?
额,这也看情况。有许多情况可以提升这种问题发生的可能性:
网站经常部署更新。我们都知道经常更新部署是件对的事情,对吧?经常并且尽早的部署更新。但是如果你一天部署更新很多次,这样会大大提高服务器上最新的版本与用户标签页里头的网页版本不同的可能性。同时,这也会大大提高用户在不同标签页下运行你网站不同版本的几率。
网站跳转是基于客户端的。网页的完全刷新会从服务端更新最新的内容,但如果你的网站的跳转是由JS控制的,它会使用户更长时间地停留在站点的当前版本,这样也提高了与服务端不同步的可能性。
用户有可能在多个标签页打开你的网站。例如,我有3个标签页是关于各种各样的HTML说明,1个标签页是GitHub,1个是Twitter,1个是Google搜索,还有9个是Google文档。它们中的一些是被分别打开的,一些是通过链接“在一个新的标签页”中打开的,一些比其他的标签页更早被打开,这样提升了它们运行着不同版本站点的几率。
网站通过service worker采用离线优先模式。因为网站从缓存中加载的内容可能是好几天以前的,所以很有可能已经和服务端不同步了。然而由于同一个service worker只能在一个registration中保持激活状态,这样也降低了用户在不同标签页中运行着网站不同版本的概率。
基于以上几个原因,突然好像用户打开的网页与服务端不同步或者他们打开的不同标签页运行着网站的不同版本的可能性也是存在的。那么,这样会有什么坏处呢?
内容的变化
如果用户打开了你其中一个网页,然后你部署了一个更新,之后用户点击了旧版本网页上的一个按钮:
btn.addEventListener('click', async () => { const { updatePage } = await import('./lazy-script.js'); updatePage();});
在旧版本网页上运行的JS代码是V1,但是./lazy-script.js
现在是V2。这样可能会导致各种各样不匹配的结果:
updatePage
加载的内容是依赖于V2的CSS,所以加载的结果可能会看起来很奇怪。updatePage
尝试更新类名是main-content
的元素,但是在V1中,这些元素的类名是main-page-content
。因此,你会看到异常被抛出。updatePage
在V2中已经被重命名为updateMainComponent
,所以updatePage()
也会抛出错误。
任何内容(包括CSS和JSON)如果使用延迟加载并与网页中的其他东西有相互依赖的关系,都存在着这个问题。
解决方案
你可以用一个service worker来缓存当前的版本内容,这个service worker不会在所有用户都运行着新版本之前就消失。然而,这意味着缓存的lazy-script.js
是之前的,这就失去了节约带宽的意义。
或者,你可以参考最优缓存策略去重新命名你的文件并让他们是不可被修改的。比如你可以用./lazy-script.a837cb1e.js
,而不是./lazy-script.js
。但是。。。
内容的消失
如果用户打开了你其中一个网页,然后你部署了一个更新,之后用户点击了旧版本网页上的一个按钮:
btn.addEventListener('click', async () => { const { updatePage } = await import('./lazy-script.a837cb1e.js'); updatePage();});
lazy scirpt在最新的部署中被更新了,所以它的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团队能为这个问题提供一个解决方案。或许他们可以提供一个方案,能够使那些在最新build中消失的资源文件在一段时间之内被重新找回匹配。当然,你还是需要一个选项去清除那些包含安全问题的脚本。
或者,你可以处理这些导入错误:
btn.addEventListener('click', async () => { try { const { updatePage } = await import('./lazy-script.a837cb1e.js'); updatePage(); } catch (err) { // Handle the error somehow }});
但你不知道这些失败的原因,或许是由于用户离线的连接失败,或许是脚本的语法错误,又或许是获取过程中的404错误,这些都是在上述代码里头不能区别的。但是,一个基于fetch()
的自定义脚本加载器就可以区分不同的原因。
这意味着存在于V1的try/catch
需要为在V2中引入的错误提前做好准备,但是我不了解V2的开发人员,我不能总是成功地预见未来将要发生的事。
同样地,你也可以利用一个service worker去让V2能够控制V1的页面,尽管它只是强制让它们重新加载:
// In the V2 service workeraddEventListener('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。
如果你用一个service worker去提供所有缓存的内容,那么这个service worker的生命周期会阻止两个标签页面同时运行不同的版本。同样地,V2可以强制重新加载V1的页面如果V1完全没有做好V2到来的准备。
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);});
现在服务端要么返回错误,要么返回该版本要求的请求格式给客户端。服务端的分析服务也能够监控旧版本的使用情况,这样你能够知道什么时候你才能移除处理旧版本的代码。
你做好马上网站的不同版本将被同时运行的准备了吗?
我并不是想吓唬你,我现在工作中的许多系统都是无服务器的build,所以他们至少面临着我上述罗列的某几个问题。我提供的解决方案也有不足之处,但是它们是目前我能够想出的最好办法。我希望我们有更好的工具去解决这些问题。
- 正文结束 -
写在后面
本文主要介绍在某些特定场景下产生的页面错误和用户体验问题以及这些问题的解决方案。本公众号之前还翻译过该作者的其他文章: