关于 PWA 更新模式
关于 PWA 更新模式
关于 PWA 更新模式
2020年了。服务人员的研发仍是一门火箭科学。人类仍未搞定服务人员的更新。探索仍在继续。
TL;DR: 跳至代码。
没人有时间做这个,别等了!
Service Worker 更新是一个尚未深入探讨的课题。许多网站每当新的 Service Worker 接管页面时,都会重新加载,这让很多用户感到懊恼。这种模式通常被称为“skipWaiting 模式”, Dean Hume 的这篇博文对此进行了精彩的描述。在您最喜欢的开发者论坛上,它也经常 被 提及。
skipWaiting 模式适用于大多数内容网站,如博客或文档网站,但如果做错了,也会令人沮丧;
即使你拥有极简状态,比如带有可扩展菜单项的菜单,也可能发生这种情况:用户访问你的页面,展开菜单项,然后新的 Service Worker 接管,跳过等待,刷新页面……菜单项又被关闭了。这对用户来说可不是什么好事。

幸运的是,我们可以使用sessionStorage API来解决这个问题。以下是我们在open-wc.org上解决这个问题的方法:
if (sessionStorage.getItem('openItems')) {
const openItems = JSON.parse(sessionStorage.getItem('openItems'));
const menuItems = [...document.querySelectorAll('section > p.sidebar-heading')];
if (openItems.length === menuItems.length) {
menuItems.forEach((item, i) => {
if (openItems[i]) {
item.click();
}
});
sessionStorage.removeItem('openItems');
}
}
let refreshing;
navigator.serviceWorker.addEventListener('controllerchange',
function() {
if (refreshing) return;
refreshing = true;
const openItems = [...document.querySelectorAll('section > p.sidebar-heading')]
.map(item => item.classList.contains('open'));
sessionStorage.setItem('openItems', JSON.stringify(openItems));
window.location.reload();
}
);
让我们一步一步来:
- 当有新的 Service Worker 可用时,
controllerchange
将触发该事件 - 但在重新加载页面之前,我们会检查用户是否与菜单进行了交互,并将其保存到会话存储中
- 然后我们重新加载页面
- 在页面加载时,我们检查 sessionStorage 中是否有任何条目,如果有,我们就恢复状态。
您可能想知道为什么我们使用 sessionStorage 来实现这种功能,而不是使用 localStorage 之类的存储方式。原因是 sessionStorage 的数据仅与当前会话或标签页相关,并且会话关闭后数据就会过期。在这种情况下,使用 sessionStorage 更合理,因为在新的 Service Worker 接管之前,用户很可能只与当前标签页进行了交互。
现在,当用户在服务工作线程更新之前已经与菜单进行过交互时,页面仍然会重新加载,但它会恢复用户状态,使得重新加载几乎不引人注意,并使用户的体验变得有趣而不是令人沮丧。
但还有更多...
我们最近遇到了这个模式的另一个怪癖。在对网站进行一些更新时,我们注意到我们的灯塔评分在“最佳实践”部分出现了问题:
要理解为什么会发生这种情况,我们必须仔细查看 skipWaiting 模式的客户端代码:
let refreshing;
navigator.serviceWorker.addEventListener('controllerchange', function () {
if (refreshing) return;
window.location.reload();
refreshing = true;
});
这里发生的情况是,每当事件触发时controllerchange
,页面都会重新加载。当你已经有一个 Service Worker,并且希望新的 Service Worker 接管时,这很好;但这也意味着页面会在用户首次访问时重新加载,从而导致 Lighthouse 出现错误。
请考虑以下情形:
- 用户首次访问你的页面,此时还没有活跃的控制 Service Worker
- Service Worker 将开始安装并预缓存您的所有资产
- 由于当前没有服务工作者,服务工作者将激活并触发
controllerchange
事件 - 该页面现在会完全重新加载,但由于所有资产都已缓存,因此几乎察觉不到
诚然,Lighthouse 出现错误似乎是Lighthouse 本身的一个bug ,但在用户第一次访问你的页面后立即重新加载似乎也有点奇怪。
以下是我们解决这个问题的方法:
async function handleUpdate() {
if ("serviceWorker"in navigator) {
let refreshing;
// check to see if there is a current active service worker
const oldSw = (await navigator.serviceWorker.getRegistration())?.active?.state;
navigator.serviceWorker.addEventListener('controllerchange', async () => {
if (refreshing) return;
// when the controllerchange event has fired, we get the new service worker
const newSw = (await navigator.serviceWorker.getRegistration())?.active?.state;
// if there was already an old activated service worker, and a new activating service worker, do the reload
if(oldSw === 'activated' && newSw === 'activating') {
refreshing = true;
window.location.reload();
}
});
}
}
handleUpdate();
现在我们的灯塔分数正如我们预期的那样:
并且,我们消除了用户首次访问我们页面时不必要的重新加载。
有可用更新!
另一种流行的模式是每当有更新可用时显示一个提示,如下所示。
这种模式不太理想,原因有很多,首先是……似乎没人喜欢 Toast,无论是开发者还是用户。我的意思是,谁会喜欢浏览网页时弹出弹窗呢?难道我们就不能做得更好吗?此外,Toast 的可访问性也很难做到完美。
正如Jad Joubran在他的演讲《类原生 PWA 的秘密》(时间戳:25:49)中所解释的那样,或许“更新可用模式”的一个更微妙、更用户友好的实现方式是,在有更新可用时显示一个非常微妙的指示。这样不太可能让用户措手不及,也不需要用醒目的弹窗来刺激用户。
pwa-helper-components
提供了一个addPwaUpdateListener
辅助函数,当有更新可用时,它会执行回调,这样您就可以轻松地向用户显示一个微妙的指示。

如果您有兴趣了解更多相关内容,我在之前的博客文章中对这种模式进行了更详细的介绍。
但“更新可用”模式的第二个问题可能更为严重;更新本身就是一个黑匣子。用户点击更新按钮时,根本不知道自己注册了什么。他们只知道,一旦点击更新按钮,整个 Web 应用可能会完全改变。你喜欢巨大的、意想不到的变化吗?我知道我不喜欢!
你不觉得这有点反常吗?我们难道不应该给用户一个好的体验,让他们提前知道注册了什么吗?
变更日志更新模式
相反,我们为什么不向用户提供完全透明的信息,而只向他们展示更新日志呢?这样,用户就能确切地知道他们选择的是哪种更新。这种模式比在页面上添加 6 行代码片段稍微复杂一些,但我们会逐步讲解。
想直接跳到代码?没问题。你可以在GitHub上找到代码仓库。
步骤1:
让我们首先使用 open-wc 生成一个新项目:
npm init @open-wc
第 2 步:
为了使这种模式发挥作用,我们需要以某种方式在客户端维护 PWA 的当前版本号,因此我们要做的第一件事就是version.js
在我们的文件夹中创建一个模块src/
:
./src/version.js
:
export default 'dev';
在构建时,我们将从version
中提取属性package.json
,并将其重写'dev'
为该版本号。我们还需要预缓存此模块,稍后再讲解。
步骤3:
'dev'
让我们实现将我们的重写version.js
为当前版本号的逻辑package.json
:
package.json
在您的中导入rollup.config.js
,并获取我们模块的路径version.js
:
rollup.config.js
:
import packageJson from './package.json';
const versionModulePath = require.resolve('./src/version.js');
现在,在plugins
我们的数组中rollup.config.js
,我们将添加一些逻辑来重写版本号:
{
name: 'rewrite-version-number',
load(id) {
// replace the version module with a live version from the package.json
if (id === versionModulePath) {
return `export default '${packageJson.version}'`;
}
},
},
每当 rollup 加载我们的version.js
文件时,我们只需将文件的内容重写为export default '${packageJson.version}'
。
步骤4:
接下来,我们将把我们的CHANGELOG.md
,转换成 JSON 文件,以便我们可以轻松地使用它并在前端比较变化。
为此,我们需要安装开发依赖项:
npm i -D md-2-json
并将其导入到我们的rollup.config.js
:
import md2json from 'md-2-json';
再次,在您rollup.config.js
的plugins
数组中添加以下代码:
{
name: 'generate-changelog-json',
writeBundle() {
const changelog = fs.readFileSync('./CHANGELOG.md', 'utf8');
fs.writeFileSync(
'./dist/CHANGELOG.json',
JSON.stringify(md2json.parse(changelog))
);
},
},
这将变成CHANGELOG.md
一个CHANGELOG.json
,看起来有点像:
{"Changelog":{"1.0.2":{"raw":"- Added the thing\n\n"},"1.0.1":{"raw":"- Fixed bug in keyboard control for dark mode toggle\n- Responsive styling fix\n\n"},"1.0.0":{"raw":"- Initial release\n\n\n"}}}
步骤5:
太好了,到目前为止,我们已经找到了一种方法来维护我们的 PWA 的当前版本号,并将其转变CHANGELOG.md
为我们可以在前端轻松使用的东西。
现在,我们需要对我们的workbox
配置进行一些更改rollup.config.js
。
我们要做的是:将skipWaiting
和clientsClaim
设置为false
,并将其添加CHANGELOG.json
到我们的globIgnores
。之所以将 添加到CHANGELOG.json
,globIgnores
是因为我们希望始终能够从网络获取最新的更改。我们还将 选项设置injectServiceWorker
为true
。这会自动将 Service Worker 注册代码添加到你的index.html
。
const baseConfig = createSpaConfig({
// ...
workbox: {
globIgnores: ['./CHANGELOG.json'],
skipWaiting: false,
clientsClaim: false,
},
injectServiceWorker: true,
// ...
});
步骤6:
太棒了!构建时的修改已经完成,接下来开始实现前端。我们将为此安装三个(非常小的)依赖项:
npm i -S pwa-helper-components @thepassle/generic-components es-semver
- pwa-helper-components:将为我们提供一个
addPwaUpdateListener
函数,每当有新的服务工作者可用时,该函数就会触发回调 - generic-components:将为我们提供一个可访问的对话框组件来显示我们的更新
- es-semver:将为我们提供一个 ES 模块版本,以便将当前应用程序版本号与变更日志中的条目进行比较
addPwaUpdateListener
让我们首先在您的文件夹中添加src/
,找到您的应用程序组件并添加以下代码:
import { LitElement, html, css } from 'lit-element';
import { openWcLogo } from './open-wc-logo.js';
import { addPwaUpdateListener } from 'pwa-helper-components';
export class ChangelogUpdatePattern extends LitElement {
static get properties() {
return {
updateAvailable: { type: Boolean },
};
}
constructor() {
super();
this.updateAvailable = false;
}
connectedCallback() {
super.connectedCallback();
addPwaUpdateListener((updateAvailable) => {
this.updateAvailable = updateAvailable;
});
}
static get styles() {/** omitted to brevity */}
render() {
return html`
<main>
<div class="logo">${openWcLogo}</div>
<h1>My app</h1>
<p>Welcome to my app!</p>
</main>
<p class="app-footer">
🚽 Made with love by
<a
target="_blank"
rel="noopener noreferrer"
href="https://github.com/open-wc"
>open-wc</a
>.
</p>
`;
}
}
正如您所见,我们导入了addPwaUpdateListener
,并在中注册了它connectedCallback
。
步骤7:
太好了,现在只要有新的服务人员可用,我们就会收到通知,但我们还没有真正对此做出反应;是时候实现对话了!
我们将从 导入对话框@thepassle/generic-components
,在方法中添加一些条件渲染render
,并向openDialog
类中添加一个方法。以下是此时的代码:
import { LitElement, html, css } from 'lit-element';
import { render } from 'lit-html';
import { addPwaUpdateListener } from 'pwa-helper-components';
import { dialog } from '@thepassle/generic-components/generic-dialog/dialog.js';
import { openWcLogo } from './open-wc-logo.js';
import './update-dialog.js';
export class ChangelogUpdatePattern extends LitElement {
static get properties() {
return {
updateAvailable: { type: Boolean },
};
}
constructor() {
super();
this.updateAvailable = false;
}
connectedCallback() {
super.connectedCallback();
addPwaUpdateListener((updateAvailable) => {
this.updateAvailable = updateAvailable;
});
}
static get styles() {/** */}
openDialog(e) {
dialog.open({
invokerNode: e.target,
content: dialogNode => {
dialogNode.id = 'dialog';
render(html`<update-dialog></update-dialog>`, dialogNode);
},
});
}
render() {
return html`
<main>
${this.updateAvailable
? html`
<button @click=${this.openDialog} class="update button">
Hey!
</button>`
: ''
}
<div class="logo">${openWcLogo}</div>
<h1>My app</h1>
<p>Welcome to my app!</p>
</main>
<p class="app-footer">
🚽 Made with love by
<a
target="_blank"
rel="noopener noreferrer"
href="https://github.com/open-wc"
>open-wc</a
>.
</p>
`;
}
}
步骤8:
还在吗?太棒了,你做得真棒。比在你的代码里加个 6 行代码片段稍微复杂一点index.html
,嗯?唉,都 2020 年了,服务人员还是个高深的学问。
现在是时候update-dialog.js
在你的文件夹中创建一个新文件了src/
。该update-dialog
组件将包含用于比较版本号差异的逻辑和 UI。
我们首先创建两个实用函数getChanged
:skipWaiting
getChanged
:将从CHANGELOG.json
网络获取最新信息,并将所有新更新编译到数组中skipWaiting
:将获得对新的等待服务人员的引用,并告诉它跳过等待。
async function getChanged(version) {
const { Changelog } = await (await fetch('./CHANGELOG.json')).json();
return Object.keys(Changelog)
.filter(item => satisfies(item, `>${version}`))
.map(
item => html`
<li>
<h2>${item}</h2>
<div class="changelog">${Changelog[item].raw}</div>
</li>
`
);
}
async function skipWaiting() {
const reg = await navigator.serviceWorker.getRegistration();
reg.waiting.postMessage({ type: 'SKIP_WAITING' });
}
最后,我们的update-dialog
组件如下所示:
import { html, LitElement } from 'lit-element';
import { dialog } from '@thepassle/generic-components/generic-dialog/dialog.js';
import version from './version.js';
class UpdateDialog extends LitElement {
static get properties() {
return {
changed: { type: Array },
};
}
constructor() {
super();
this.changed = [];
}
createRenderRoot() {
return this;
}
async connectedCallback() {
super.connectedCallback();
this.changed = await getChanged(version);
}
render() {
return html`
<button @click=${() => dialog.close()} class="close button">
x
</button>
<h1>There's an update available!</h1>
<p>Here's what's changed:</p>
<ul>
${this.changed}
</ul>
<div class="dialog-buttons">
<button class="button" @click=${skipWaiting}>Install update</button>
<button class="button" @click=${() => dialog.close()}>Close</button>
</div>
`;
}
}
customElements.define('update-dialog', UpdateDialog);
步骤 9(可选):
这种模式的缺点是,它不是很容易实现,而且对于你所做的每一个改变,你都需要相应地调整你CHANGELOG.md
的package.json
。
为了让大家更轻松一些,我创建了一个 Github Action,它会在每次拉取请求时检查你是否更改了你的CHANGELOG.md
and package.json
。你可以创建一个.github/workflows/
目录并添加以下工作流程来使用它:
package-json-changelog-enforcer.yml
:
name: package-json-changelog-enforcer
on: [pull_request]
jobs:
package-json-changelog-enforcer:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
fetch-depth: '0'
- uses: thepassle/package-json-enforcer@0.0.10
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
结论
就是这样。如果你一步一步地跟着做,那你就成功了。如果你没有,而是直接跳到 Github 仓库;再说一次,我不会责怪你。
我想用开头的方式结束这篇博文:2020年了。Service Worker 仍然是个火箭科学。人类仍然没有搞定 Service Worker 的更新。探索仍在继续。
鏂囩珷鏉ユ簮锛�https://dev.to/thepassle/on-pwa-update-patterns-4fgm