构建 COVID-19 PWA 的经验教训
介绍
几周前,一位同事问我是否有兴趣参与一个开源新冠疫情项目的设计和开发工作。当时我们国家刚刚开始实施“智能封锁”,我突然有了不少空闲时间,觉得这是个贡献力量的好机会。虽然项目尚未完成,但我非常乐意与大家分享一些我们处理某些问题的方法。
封锁计划
该项目名为“封锁项目”(Project Lockdown)。其基本理念是制作一张世界地图,展示世界各地处于封锁状态的国家/地区。虽然封锁通常有助于各国控制疫情蔓延,防止新冠病毒扩散,但一些具有独裁性质的国家可能会将此视为加强对国内控制的机会。政府可能会为了政治利益而无故延长封锁期限,或不遵守此前宣布的结束日期。
⚠️ 截图或 GIF 中显示的任何数据均为模拟数据
数据来源
任何与新冠疫情相关的项目都面临着一个重要问题:数据来源如何?数据是否可靠?我们的数据由来自世界各地的“编辑”团队录入,用户也可以提交数据,提交的数据随后会由编辑进行审核。我们只接受来自官方政府声明的数据。
数据会被录入到 Google Sheets 中,然后我们有一个 Node 脚本,它会抓取 Google Sheets 中的数据,并输出一个worldmap.json渲染世界地图的图像,地图上用不同颜色标示处于封锁状态的国家/地区,同时还会输出各个国家/地区的 JSON 文件(例如 `<country_name>.json`),这些文件可以在前端使用。该 Node 脚本作为GitHub ActionNL.json中的 cron 任务,每 5 分钟运行一次。
我们还使用Coronatracker API获取一些统计数据,例如各国的感染人数、死亡人数和康复人数。Coronatracker 已获得世界卫生组织的认可。
前端技术使用
通常,我和我的同事 Lars 都非常热衷于 Web 组件,我们参与一个名为open-wc 的项目,致力于开发用于现代 Web 开发的工具和库。Web 组件领域目前非常活跃,但事实上,与 React 或 Preact 等社区相比,Web 组件社区的规模相对较小。我们认为,如果使用更多开发者熟悉的库,就能更容易地获得更多贡献,也能更快地引导新开发者上手。毕竟,这个项目的目的并非推广 Web 组件,而是为了造福整个 Web 开发领域。幸运的是,我们仍然能够使用许多我们自己开发的工具,例如非常适合使用 ES 模块开发的es-dev-server,以及我们开发的rollup-plugin-html。
由于 bundlesize 是本项目的一个重要考虑因素,我们决定采用Preact,并结合一些额外的辅助工具作为 web 组件。
不过,我们仍然非常推崇无构建开发,并希望与浏览器保持紧密联系,因此我们决定同时使用htm。htm是一个非常小的库,它允许你使用带标签的模板字面量直接在浏览器中编写类似 JSX 的语法。
由于习惯了 Web 组件,我们已经被 Shadow DOM 提供的出色封装性惯坏了,所以重新编写全局 CSS 着实让人头疼。事实证明,市面上有很多CSS 作用域解决方案,但其中许多都需要构建步骤,增加了复杂性。因此,我们仍然秉持着无需构建的开发理念,并希望与浏览器保持紧密联系,最终选择了Luke Jackson开发的csz,它允许你使用……带标签的模板字面量来编写 CSS!
我们花了很多时间讨论地图库的选择,发现Mapbox 的体验非常接近原生应用,功能也很强大,但可惜的是它的包体积非常大。考虑到我们希望尽量减小包体积,最终选择了Leaflet。
建筑
为了构建用于生产环境的应用程序,我们使用Rollup。Rollup是一款极其用户友好的构建工具,而且配置起来也相对容易。
我们的构建过程需要采取以下几个步骤:
- 复制资产
- 生成 Service Worker 并将 Service Worker 注册应用到 index.html 文件。
- 优化和压缩 JS
- 解析裸模块说明符,例如
import { Component } from 'preact' - 转换不支持的语法,例如可选链和 import.meta
以下是我们大致的配置:
export default [
/* sw build */
{
/* Normally, HTML is not a valid entrypoint for Rollup, but we use @open-wc/rollup-plugin-html to take care of this for us */
input: 'index.html',
output: {
entryFileNames: '[hash].js',
chunkFileNames: '[hash].js',
format: 'es',
dir: 'build'
},
plugins: [
{
name: 'version',
load(id) {
/* replace the version module with a live version from the package.json */
if (id === versionModulePath) {
return `export default '${packageJson.version}'`;
}
}
},
/* Handle bare module specifiers */
resolve(),
html(),
/**
- Transform non-widely supported features like optional chaining and nullish coalescing
- support import.meta
- Optimise HTM by using babel-plugin-htm
*/
babel({
babelHelpers: 'bundled',
presets: [require.resolve('@babel/preset-modules')],
plugins: [
[require.resolve('babel-plugin-htm'), { import: 'preact' }],
[require.resolve('babel-plugin-bundled-import-meta'), { importStyle: 'baseURI' }],
require.resolve('@babel/plugin-proposal-optional-chaining'),
require.resolve('@babel/plugin-proposal-nullish-coalescing-operator')
]
}),
/* Compress our JavaScript */
terser({ output: { comments: false } }),
/* Copy some assets, we do this in the buildStart hook of rollup to ensure all assets are there when we run rollup-plugin-workbox */
copy({
hook: 'buildStart',
targets: [{ src: 'data/**/*', dest: 'build/data' }],
flatten: false
}),
copy({
hook: 'buildStart',
targets: [
{ src: 'manifest.json', dest: 'build/' },
],
flatten: false
}),
/* Create our service worker */
injectManifest({
swSrc: 'build/sw.js',
swDest: 'build/sw.js',
globDirectory: 'build/',
mode: 'production'
}),
/* Apply the service worker registration to our index.html */
applySwRegistration({
htmlFileName: 'index.html'
}),
]
}
];
国家管理
状态管理是前端领域一个被广泛讨论的话题,市面上有很多解决方案。我们决定采用一个非常简洁的单例模式EventTarget,它利用了……,具体如下:
class TotalsService extends EventTarget {
async getTotals(forceRefresh) {
if (forceRefresh || !this.__totals) {
this.__totals = fetch(new URL('../../data/totals.json', import.meta.url)).then((r) => r.json());
await this.__totals;
this.dispatchEvent(new Event('change'));
}
return this.__totals;
}
}
export const totalsService = new TotalsService();
然后我们可以像这样应对变化:
totalsService.addEventListener('change', () => {/* update state when data has changed */})
如果你对这种模式感兴趣,想了解更多,Lars正在撰写一篇更详细的博文,敬请关注open-wc 的论坛或推特账号😉
PWA
自从我发现 PWA 以来,我就一直对它很感兴趣,而且 PWA 领域有很多令人兴奋的努力和发展,例如Project Fugu和PWAbuilder。
我们希望“Project Lockdown”是一个 PWA(渐进式 Web 应用),原因有以下几点:
- 表现
- 离线支持
- 类似原生应用的体验
- 开发速度/维护
采用 PWA 的另一个好处是,我们不再受限于应用商店。在项目进行过程中,我们曾听说苹果和谷歌开始移除与新冠疫情相关的应用程序的搜索结果。虽然保护用户免受虚假新闻和利用重大悲剧事件牟利的应用程序的侵害是一项崇高的事业,但许多直接依赖世卫组织数据的合法应用程序也受到了这些禁令的影响。
我很高兴地告诉大家,将 Project Lockdown 改造为 PWA 的过程非常顺利,就像即插即用一样。以下是我们使用的一些工具:
- Workbox v5(通过rollup-plugin-workbox) ——用于构建我们的服务工作线程
- pwa-asset-generator - 用于为多个设备生成所有特定的图标/资源
- pwa-helpers - 用于实用程序
- pwa-helper-components - 用于更新 PWA、安装 PWA、启用深色模式以及监听 Service Worker 更新的 Web 组件
这些工具帮我们减轻了很多繁重的工作,使项目的 PWA 化过程非常顺利。
工作箱
使用Workbox v5和rollup-plugin-workbox构建我们的服务工作线程非常轻松。
injectManifest({
swSrc: 'build/sw.js',
swDest: 'build/sw.js',
globDirectory: 'build/',
mode: 'production',
}),
由于我们需要对 Service Worker 进行更精细的控制,所以我们选择了 WorkboxinjectManifest而不是其他generateSW方案。构建 Service Worker 的过程就像这样:选择一个资源或请求,然后确定策略。这个资源或请求是否至关重要?我们需要为此获取最新数据吗?还是可以先从缓存加载?基于这些决策,我们可以非常轻松地在 Service Worker 中添加相应的路由。由于 Workbox 提供了通用和高级策略,设置诸如 Google Fonts 策略和基于 SPA 的路由等功能变得非常简单。即插即用!
我们遇到的一个问题是,世界上有很多国家。点击某个国家并打开“国家详情对话框”会获取一些数据并在对话框中显示。如果用户对很多国家感兴趣并点击了很多,他们的存储空间可能会很快被占满。这就是为什么我们使用插件和类似 `--limits` 和 `--limits`这样Expiration的设置的原因。注意用户设备的存储空间非常重要!maxEntriespurgeOnQuotaError
在这种设置下,当然也可能出现用户没有网络连接,点击某个国家/地区,但缓存中没有该国家/地区的数据的情况。我们不会崩溃或显示错误信息,而是向用户显示一条友好的消息:
使用 Workbox 实现 Service Worker 的整体体验很棒,但我也有一些批评意见:
我们没有依赖 CDN 导入 Workbox,而是选择将 Service Worker 集成到我们现有的构建流程中。这样一来,我们就可以编写 ES 模块导入语句,并将所有内容整合到我们的流程中。但这种方式的缺点是,有时很难弄清楚哪个 Workbox 模块导出了哪些内容,而且文档有时也显得比较分散。
我们遇到的另一个问题是 Workbox 使用了process.env.NODE_ENV变量。作为一个喜欢无构建开发的人,看到前端库(说的就是你,Redux!)使用这类变量,我有点失望。幸运的是,这个问题很容易解决@rollup/plugin-replace。
不过公平地说,为了维护 Workbox 的声誉,这是我决定自己打包 service worker 所要付出的代价,而 Workbox 确实是一个很棒的工具。
培养服务人员
使用 Rollup 构建 Service Worker 的设置也相对简单,以下是 Service Worker 的配置:
export default [
{
input: 'sw.js',
output: {
format: 'es',
dir: 'build'
},
plugins: [
replace({ 'process.env.NODE_ENV': '"production"' }),
resolve(),
terser({ output: { comments: false } }),
]
},
// ...
]
除此之外,我们还编写了一个简单的内联 Rollup 插件,仅在index.html构建时将 Service Worker 注册代码附加到项目中,这样我们在常规开发过程中就不必担心这个问题了:
{
name: 'rollup-plugin-apply-service-worker-registration',
generateBundle(_, bundle) {
let htmlSource = bundle['index.html'].source;
htmlSource = applyServiceWorkerRegistration(htmlSource);
},
}
该applyServiceWorkerRegistration函数接收一个 HTML 文件作为字符串,使用 AST 将其解析为抽象语法树 (AST) parse5,然后从 AST 中查询文档主体,创建一个<script>包含 Service Worker 注册码的新标签,将其添加到文档主体的末尾,最后将新的 HTML 文件作为字符串返回。以下是代码:
const { parse, serialize } = require('parse5');
const Terser = require('terser');
const { createScript } = require('@open-wc/building-utils');
const { append, predicates, query } = require('@open-wc/building-utils/dom5-fork');
function applyServiceWorkerRegistration(htmlString) {
const documentAst = parse(htmlString);
const body = query(documentAst, predicates.hasTagName('body'));
const swRegistration = createScript(
{},
Terser.minify(`
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker
.register('./sw.js')
.then(function() {
console.log('ServiceWorker registered.');
})
.catch(function(err) {
console.log('ServiceWorker registration failed: ', err);
});
});
}
`).code,
);
append(body, swRegistration);
return serialize(documentAst);
};
为了方便我自己(也希望方便您),我已经将此Rollup 插件发布到 NPM 上。已发布的版本增加了一些配置选项,例如自定义 HTML 文件名、自定义 Service Worker 作用域和自定义 Service Worker 文件名。我们在open-wc的构建配置中也使用了它。
此外,我们维护一个版本号来跟踪 PWA 的当前版本,主要用于调试。但将来我们可能需要使用该版本号来获取更新信息CHANGELOG,当 PWA 的新版本可用时,我们可以显示一个“新增功能”页面,列出新版本包含的更新内容。此版本变量的更新也通过一个非常简洁的内联汇总插件自动完成:
在我们的 Rollup 配置中,我们导入了该模块,package.json以便能够读取最新版本号,以及导出版本号的 JS 文件的路径:
./rollup.config.js:
import packageJson from './package.json';
const versionModulePath = require.resolve('./src/version.js');
./src/version.js:
export default 'dev';
然后,在 Rollup 配置的插件数组中,我们执行以下操作:
{
name: 'version',
load(id) {
// replace the version module with a live version from the package.json
if (id === versionModulePath) {
return `export default '${packageJson.version}'`;
}
}
},
然后我们就可以像这样使用它App.js:
import version from './version';
// During development will output 'dev', in production will always show the latest version of the package.json:
console.log(`🌐 Project Lockdown, version: ${version}`);
Pwa资产生成器
我们使用pwa-asset-generator来生成 PWA 资源。pwa-asset-generator 是Önder Ceylan开发的一款出色工具,它能让你非常灵活地生成资源,并自动将它们添加到 index.html 和 manifest.json 文件中。更棒的是,它甚至还能处理暗黑模式的资源,以及苹果特有的图标/启动画面。
以下是我们的脚本:
"prepare-pwa-assets": "./node_modules/.bin/pwa-asset-generator Assets/logo.png src/assets/pwa --manifest manifest.json --index index.html && ./node_modules/.bin/pwa-asset-generator Assets/logo-dark.png src/assets/pwa --dark-mode --background '#303136' --splash-only --index index.html && npm run format:index",
要重新生成我们的资产,我们只需运行:npm run prepare-pwa-assets。
我们使用 pwa-asset-generator 遇到的唯一问题是它会在 index.html 文件中添加很多奇怪的新行。虽然可以通过在运行 `pwa-asset-generator`prettier后再运行 `pwa-asset-generator` 来轻松解决这个pwa-asset-generator问题,但需要额外执行这一步还是有点麻烦。总而言之,我强烈推荐使用 pwa-asset-generator pwa-asset-generator,它的设置非常简单,而且文档也很完善。
懒加载和路由
为了提升性能和首屏加载速度,我们对 Leaflet 和 Dialog 组件都采用了延迟加载。这样一来,加载 Leaflet 就不会阻塞首屏渲染,用户也能更快地看到界面元素。我们原本也可以选择延迟加载菜单中的部分内容,但菜单中的大部分内容只有几行 HTML 代码。
此外,我们还会检查 SPA 路由导航中是否有新的 Service Worker 可用。这样,即使用户长时间保持页面打开且从未刷新,他们在使用应用时仍然能够收到更新。我们将在更新部分详细介绍这一点。
安装
安装过程中,我们使用了我之前在pwa-helper-components项目中创建的 Web 组件。我们只需要安装这个组件即可:
npm i -S pwa-helper-components
导入组件:
import 'pwa-helper-components/pwa-install-button.js';
然后像这样把它添加到我的 Markdown 文件中:
<pwa-install-button>
<button class="my-button-styles">Install app</button>
</pwa-install-button>
只有当 PWA 可安装时,该按钮才会显示,并且添加安装流程非常简单,即插即用!(注意:这需要注册有效的 Service Worker,并且manifest.json必须有人在场。)
PWABuilder是一个很棒的项目,值得一看,它还提供了一个pwa-install Web 组件。我们没有选择它的原因是它使用了LitElement构建。虽然 LitElement 本身就是一个很棒的库(压缩后只有 7kb!),我强烈建议你试用一下,但我们觉得仅仅为了一个pwa-install 组件就增加 LitElement 的打包大小不太合适。<pwa-install-button>我们使用的组件是用原生 Web 组件编写的,代码大约 32 行(未压缩)。
正在更新
如果您的 PWA 有新版本,您需要一种方法让用户更新到最新版本。为了实现这一点,我们使用了另一个 Web 组件;该<pwa-update-available>组件也来自pwa-helper-components 库。同样,我只需要安装该库,然后像这样将该组件添加到我的 Markdown 代码中:
<pwa-update-available>
<button class="my-button-styles">Update app</button>
</pwa-update-available>
当有更新可用时,该组件就会变为可见。
但是,问题就出在这里。该<pwa-update-available>组件会在菜单的“设置”部分有条件地渲染。这意味着用户可能要等到点击“设置”菜单按钮才会知道有更新可用。如果能显示一些用户友好的指示器来告知用户有更新可用就更好了。我们特意避免使用类似“toast”的提示模式,因为很多用户(包括我自己)都觉得这种模式很烦人。最终,我们采用了以下模式来解决这个问题,灵感来自Jad Joubran的演讲“原生 PWA 的秘密”(时间戳:25:49)。
为了实现这一点,我们从<pwa-update-available>组件中提取了一些逻辑,并将其转换为一个简单的辅助函数,该函数会在检测到新的服务工作线程时执行回调:
function addPwaUpdateListener(callback) {
let newWorker;
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistration().then(reg => {
if (reg) {
reg.addEventListener('updatefound', () => {
newWorker = reg.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
callback(true);
}
});
});
if (reg.waiting && navigator.serviceWorker.controller) {
callback(true);
newWorker = reg.waiting;
}
}
});
}
}
然后,我们可以像这样在应用级组件中使用它:
addPwaUpdateListener(updateAvailable => {
this.setState({
updateAvailable
});
});
此外,还会显示一个细微的指示器,提示有新版本可用:
我们考虑的另一个问题是,如果用户一直将我们的应用保持在标签页中打开,他们可能永远不会收到应用的任何更新通知。通常情况下,浏览器会在导航和功能事件(例如页面跳转和更新sync)后自动检查更新push,但由于我们的应用本质上是一个单页应用(SPA),我们通过在客户端 SPA 路由中添加一个检查来解决这个问题:
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistration().then(registration => {
if (registration) {
registration.update();
}
});
}
现在,即使用户一直开着我们的应用程序,他们仍然可以通过使用该应用程序来接收应用程序的新更新。
深色模式
如今几乎所有应用/PWA都具备的一个功能是深色模式。说实话,我原本以为这只是个锦上添花的功能,实现起来应该很容易,不用花太多功夫。但我不得不承认,我在这上面花费的时间比我愿意承认的要长得多😅
我想要达到的目标是:
- 页面首次加载时,如果尚未设置任何手动首选项,则遵循用户的系统首选项。
- 然后,如果用户决定切换到深色(/浅色)模式,则存储该偏好,并从现在开始使用该偏好,即使在后续访问中也是如此,因为用户已手动选择加入。
我们通过在页面加载时注册一个媒体查询匹配器来启用或禁用深色模式。我们之前已经在使用 ` installMediaQueryMatcherfrom` pwa-helpers,但自己实现这个监听器只需要三行代码:
export const installMediaQueryMatcher = (mediaQuery, callback) => {
const mediaMatcher = window.matchMedia(mediaQuery);
mediaMatcher.addListener((e) => callback(e.matches));
callback(mediaMatcher.matches);
};
以下这段逻辑比我想象的要花时间写出来,但总之,它就是这样:
installMediaQueryWatcher(`(prefers-color-scheme: dark)`, preference => {
const localStorageDarkmode = localStorage.getItem('darkmode');
const darkmodePreferenceExists = localStorageDarkmode !== null;
const darkMode = localStorageDarkmode === 'true';
const html = document.getElementsByTagName('html')[0].classList;
/* on initial pageload and no manual user preference, decide darkmode on users system preference */
if (!darkmodePreferenceExists) {
if (preference) {
localStorage.setItem('darkmode', 'true');
html.add('dark');
} else {
localStorage.setItem('darkmode', 'false');
html.remove('dark');
}
} else {
/* if the user has manually chosen a preference, prioritise that instead */
if (darkMode) {
html.add('dark');
}
}
});
然后在设置菜单中,我们提供了一个按钮,用户可以手动切换深色/浅色模式:
function toggleDarkmode() {
const html = document.getElementsByTagName('html')[0].classList;
if (html.contains('dark')) {
html.remove('dark');
localStorage.setItem('darkmode', 'false');
} else {
html.add('dark');
localStorage.setItem('darkmode', 'true');
}
}
我已经将此逻辑提取到一个简单的<pwa-dark-mode>Web 组件中,用于切换类dark并将其持久化localStorage,以及一个用于注册媒体查询监听器及其逻辑的辅助函数,并将其发布到pwa-helper-components中。
这意味着用户只需编写一些 CSS 代码,使用 Web 组件包装一个原生<button>元素,并注册辅助函数即可启用暗黑模式:
<html>
<head>
<style>
:root {
--my-text-col: black;
--my-bg-col: white;
}
.dark {
--my-text-col: white;
--my-bg-col: black;
}
body {
background-color: var(--my-bg-col);
color: var(--my-text-col);
}
</style>
</head>
<body>
<p>Hello world!</p>
<pwa-dark-mode></pwa-dark-mode>
<script type="module">
import { installDarkModeHandler } from 'pwa-helper-components';
import 'pwa-helper-components/pwa-dark-mode.js';
installDarkModeHandler();
</script>
</body>
</html>
此外,我们还会根据用户喜好更新网站图标:
地理位置和权限
我们最后一个渐进式功能是另一个虽小但锦上添花的功能,它确实有助于提升应用的原生体验。我们希望用户能够允许应用访问地理位置信息,以便地图能够“吸附”到他们的位置。在开发此功能时,浏览器会在页面加载时弹出权限请求窗口:
即使在开发这项功能的过程中,我们也很快发现这种方式非常烦人。因此,我们决定仅在用户表现出对某项功能感兴趣时才请求权限,例如点击“允许地理位置”按钮。只有在用户点击该按钮后,我们才会显示浏览器的权限弹出窗口。
部署
我们使用Netlify进行部署。我非常喜欢Netlify,将 GitHub 项目与 Netlify 集成体验极其流畅,而且功能强大:自动部署、在拉取请求中预览部署,以及非常轻松地为项目添加自定义域名。
此外,Netlify慷慨地为与新冠疫情相关的项目提供了支持,我们也申请并获得了这项支持。这为我们提供了无限的构建时间以及免费的分析插件等。
回顾
参与新冠肺炎项目总体来说是一次很棒的经历,但也常常像坐过山车一样。我们的团队成员遍布世界各地,使用不同的沟通平台,这有时会导致我们在设计决策、功能特性等方面难以达成共识。但我对自己为项目做出的贡献感到非常满意,并期待着最终项目的发布。
现在回想起来,我确实有点后悔当初没选择 LitElement,因为我觉得它本来可以很好地展示如何使用 Web Components 开发应用,而且我们最终也没有像最初预期的那样收到那么多贡献。不过话说回来,使用不同的库也让人耳目一新。使用 Preact 和 HTM 绝对是一种享受,我强烈建议你们也试试,用它们开发一些(轻量级的!)很棒的应用。我自己肯定也会在以后的项目中继续使用它们。
亲爱的读者,我希望您能看到这篇博客,或许它能给您带来一些启发,或者让您学到一些新知识。
注意安全,保持健康🙏
文章来源:https://dev.to/thepassle/lessons-learned-building-a-covid-19-pwa-57fi












