# 基于 Workbox 实现 Hugo PWA
最近给基于 Hugo 搭建的博客添加了 PWA 功能,显著提升了加载速度和用户体验,甚至实现了离线访问。至于如何实现,那么你需要了解 Progressive Web Apps (PWA)。
# 什么是 PWA
渐进式 Web 应用(Progressive Web Apps,简称 PWA)利用现代 Web API 和传统的渐进式增强策略,打造出跨平台的 Web 应用程序。这些应用无处不在,功能丰富,为用户带来媲美原生应用的体验。
PWA 的优势:
- ⚡️ 更快的加载速度: PWA 可以缓存重要资源,即使网络状况不佳也能快速加载。
- ✈️ 离线访问: PWA 可以缓存内容,让用户即使离线也能访问内容。
- 🔔 推送通知: 像原生应用一样,PWA 可以向用户发送推送通知,提高用户参与度。
- 📱 安装到主屏幕: 用户可以将你的应用添加到电脑或手机桌面,像原生应用一样浏览你的 Web 应用。
PWA 的实现原理是 Service Worker。Service Worker 是一种特殊的 JavaScript 资源,在浏览器后台独立运行,充当着网络浏览器和 Web 服务器之间的代理。它可以拦截和处理网络请求、缓存资源以及推送通知。
主流的前端框架 Vue、React、Angular 都提供了相应的 PWA 插件。而对于 Hugo 这样的静态网站生成器,我们可以通过手动添加 Workbox 来实现 PWA 功能。
# Workbox
Workbox 是由 Google Chrome 团队开发的一套模块,旨在简化常见的 Service Worker 路由和缓存操作。每个模块都针对 Service Worker 开发的特定方面进行了优化。Workbox 的目标是尽可能简化 Service Worker 的使用,同时在需要时灵活地满足复杂应用的需求。
如果没有 Workbox,我们需要手动编写 Service Worker 来监听 fetch 事件、缓存资源并实现离线访问等功能。而 Workbox 提供了一套工具,可以帮助我们自动生成 Service Worker,并且内置了一些常用的缓存策略,使我们能够更加专注于业务逻辑。
# 配置 PWA
在上一节中,我们了解了 PWA 的概念和优势,以及 Workbox 如何简化 Service Worker 的开发。接下来将一步步地给 Hugo 博客配置 PWA 功能。
# 注册 Service Worker
首先,我们需要在页面中注册 Service Worker。将以下代码段添加到你的 Hugo 主题的 layouts/partials/footer/custom.html
文件中(其他主题可能需要根据文件结构进行调整):
<script>
// Check that service workers are registered
if ('serviceWorker' in navigator) {
// Use the window load event to keep the page load performant
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').then(reg => {
console.log('Service worker registered with scope: ', reg.scope);
}, err => {
console.log('Service worker registration failed: ', err);
});
});
}
</script>
注释
注意: 在注册 Service Worker 之前,你需要先创建
sw.js
文件,我们将在下一小节中完成这一步骤。
完成注册后,你可以在浏览器的开发者工具 (F12) 中的 “Application” -> “Service Workers” 面板中查看 Service Worker 的注册状态。
# 导入 Workbox
在你的 Hugo 网站根目录下的 static
文件夹中创建 sw.js
文件。然后,在 sw.js
文件中添加以下代码,使用 CDN 导入 Workbox:
importScripts('https://cdn.bootcdn.net/ajax/libs/workbox-sw/7.1.0/workbox-sw.js');
# 缓存策略
Workbox 提供了一些常用的缓存策略,如 CacheFirst
、NetworkFirst
、StaleWhileRevalidate
等。这里先介绍几种常用的策略。
# CacheOnly 仅缓存
强制响应来自缓存。
workbox.routing.registerRoute(
new RegExp(regex),
new workbox.strategies.CacheOnly()
);
# NetworkOnly 仅网络
这种缓存策略强制要求所有请求都从网络获取最新数据,完全绕过缓存。
workbox.routing.registerRoute(
new RegExp(regex),
new workbox.strategies.NetworkOnly()
);
# CacheFirst 优先缓存
这种缓存策略以速度为优先,会首先尝试从缓存中获取响应,以尽快向用户显示内容。如果缓存中没有所需数据,它才会向网络发起请求获取数据。
workbox.routing.registerRoute(
new RegExp(regex),
new workbox.strategies.CacheFirst()
);
# NetworkFirst 优先网络
这种缓存策略优先使用最新数据,因此会首先尝试从网络获取响应。如果网络请求失败,例如用户离线或网络连接不稳定,它会回退使用缓存中的数据,确保用户仍然可以访问内容。
workbox.routing.registerRoute(
new RegExp(regex),
new workbox.strategies.NetworkFirst()
);
# StaleWhileRevalidate 读取缓存,同时发起网络请求
这种缓存策略优先返回缓存内容(如果有)。即使缓存内容有效,它也会在后台发起网络请求以获取最新数据,保证用户最终能看到最新内容。虽然这种策略能确保用户定期更新缓存,但也意味着每次请求都会产生网络流量,即使数据没有变化,也比较浪费带宽。
workbox.routing.registerRoute(
new RegExp(regex),
new workbox.strategies.StaleWhileRevalidate()
);
# 策略配置
Workbox 不仅提供上述策略,还允许通过 cacheName、plugins 和 expiration 等配置项进行自定义。你可以通过定义要使用的插件来自定义路由行为。例如,你可以配置缓存名称、缓存有效期以及可缓存的响应状态码,如下所示:
workbox.routing.registerRoute(
new RegExp(regex),
new workbox.strategies.CacheFirst({
cacheName: 'my-cache',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 Days
}),
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [0, 200],
}),
],
})
);
# 本站配置
# 全局配置
以下是全局缓存配置:
// 缓存版本号
let cacheVersion = '-240619';
// 最大条目数
const maxEntries = 100;
# Twitto 配置
为了确保用户即使在离线状态下也能查看评论,Twitto 评论 API 采用了 NetworkFirst
缓存策略。这意味着浏览器会优先尝试从网络获取最新数据,如果网络不可用,则使用缓存中的数据。
workbox.routing.registerRoute(
new RegExp('^https://comment\.cuterwrite\.top'),
new workbox.strategies.NetworkFirst({
plugins: [
new workbox.expiration.ExpirationPlugin({
maxEntries: maxEntries,
maxAgeSeconds: 30 * 24 * 60 * 60,
}),
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [0, 200],
}),
],
})
);
# RSS 与 Sitemap 配置
为了确保用户始终获取最新的 RSS 和 Sitemap 数据,这些页面配置为仅使用网络策略 (NetworkOnly
),不进行缓存。
workbox.routing.registerRoute(
new RegExp('^https://cuterwrite\.top/(index|sitemap)\.xml'),
new workbox.strategies.NetworkOnly()
);
# HTML 配置
为了在保证用户快速加载页面的同时,也能获取到最新内容,网站对 HTML 页面采用了 StaleWhileRevalidate
缓存策略。这意味着浏览器会优先使用缓存中的页面进行展示,同时在后台向服务器发起请求,获取最新版本,并在下次请求时使用。
workbox.routing.registerRoute(
new RegExp('.*\.html'),
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'html-cache' + cacheVersion,
plugins: [
new workbox.expiration.ExpirationPlugin({
maxEntries: maxEntries,
maxAgeSeconds: 30 * 24 * 60 * 60,
}),
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [0, 200],
}),
],
})
);
# Google Fonts 配置
为了在保证字体文件更新的同时,也能利用缓存加速页面加载速度,网站对 Google Fonts 资源采用了 CacheFirst
缓存策略,并设置了较长的缓存过期时间。
workbox.routing.registerRoute(
new RegExp('.*\.(?:woff|woff2|ttf|otf|eot)'),
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'google-fonts' + cacheVersion,
plugins: [
// 使用 expiration 插件实现缓存条目数目和时间控制
new workbox.expiration.ExpirationPlugin({
// 最大缓存条目数
maxEntries: maxEntries,
// 最长缓存时间 30 天
maxAgeSeconds: 30 * 24 * 60 * 60,
}),
// 使用 cacheableResponse 插件缓存状态码为 0 的请求
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [0, 200],
}),
],
})
);
# CDN 配置
为了最大程度地利用缓存加速页面加载速度,网站对来自常用 CDN 的资源采用了 CacheFirst
缓存策略,并设置了较长的缓存过期时间。
workbox.routing.registerRoute(
new RegExp('^https://(?:cdn\.bootcdn\.net|unpkg\.com|cdn\.jsdelivr\.net)'),
new workbox.strategies.CacheFirst({
cacheName: 'cdn' + cacheVersion,
fetchOptions: {
mode: 'cors',
credentials: 'omit',
},
plugins: [
new workbox.expiration.ExpirationPlugin({
maxEntries: maxEntries,
maxAgeSeconds: 30 * 24 * 60 * 60,
}),
],
})
);
# Umani 网站统计配置
为了确保网站统计数据的准确性,网站对 Umani 网站统计请求采用了 NetworkOnly
策略,并使用 BackgroundSyncPlugin
插件来实现即使在网络离线的情况下也能保证数据最终发送成功。
workbox.routing.registerRoute(
new RegExp('^https://analytics\.cuterwrite\.top/uma'),
new workbox.strategies.NetworkOnly({
plugins: [
// 使用 background sync 插件实现后台同步
new workbox.backgroundSync.BackgroundSyncPlugin('Optical_Collect', {
maxRetentionTime: 12 * 60,
}),
],
})
);
# 图片配置
为了加速图片加载速度,并减少网络请求次数,网站对图片资源采用了 CacheFirst
缓存策略,并设置了较长的缓存过期时间。
workbox.routing.registerRoute(
new RegExp('^(https://cuterwrite-1302252842\.file\.myqcloud\.com|https://s2\.loli\.net)'),
new workbox.strategies.CacheFirst({
cacheName: 'image-cache' + cacheVersion,
plugins: [
new workbox.expiration.ExpirationPlugin({
maxEntries: maxEntries,
maxAgeSeconds: 30 * 24 * 60 * 60,
}),
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [0, 200],
}),
],
})
);
# 后缀匹配配置
为了兼顾加载速度和内容更新,网站对未被域名匹配到的静态文件(例如图片、CSS 和 JavaScript 文件)采用了 StaleWhileRevalidate
缓存策略。
workbox.routing.registerRoute(
new RegExp('.*\.(?:png|jpg|jpeg|svg|gif|webp|ico)'),
new workbox.strategies.StaleWhileRevalidate()
);
workbox.routing.registerRoute(
new RegExp('.*\.(css|js)'),
new workbox.strategies.StaleWhileRevalidate()
);
# 默认行为配置
为了处理未被任何自定义路由规则匹配到的请求,网站配置了默认缓存行为,使用 NetworkFirst
策略并设置了网络超时时间,以兼顾资源获取速度和离线可用性。
workbox.routing.setDefaultHandler(
// 优先使用缓存,缓存没有则使用网络请求
new workbox.strategies.NetworkFirst({
networkTimeoutSeconds: 3,
})
);
# 完整配置
sw.js
importScripts('https://cdn.bootcdn.net/ajax/libs/workbox-sw/7.1.0/workbox-sw.js');
// 缓存版本号
let cacheVersion = '-240619';
// 最大条目数
const maxEntries = 100;
if (workbox) {
console.log(`Yay! Workbox is loaded 🎉`);
// 评论缓存
workbox.routing.registerRoute(
new RegExp('^https://comment\.cuterwrite\.top'),
new workbox.strategies.NetworkFirst({
plugins: [
new workbox.expiration.ExpirationPlugin({
maxEntries: maxEntries,
maxAgeSeconds: 30 * 24 * 60 * 60,
}),
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [0, 200],
}),
],
})
);
// rss 、sitemap 不缓存
workbox.routing.registerRoute(
new RegExp('^https://cuterwrite\.top/(index|sitemap)\.xml'),
new workbox.strategies.NetworkOnly()
);
// 缓存 HTML
workbox.routing.registerRoute(
new RegExp('.*\.html'),
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'html-cache' + cacheVersion,
plugins: [
new workbox.expiration.ExpirationPlugin({
maxEntries: maxEntries,
maxAgeSeconds: 30 * 24 * 60 * 60,
}),
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [0, 200],
}),
],
})
);
// 缓存 Google Fonts
workbox.routing.registerRoute(
new RegExp('.*\.(?:woff|woff2|ttf|otf|eot)'),
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'google-fonts' + cacheVersion,
plugins: [
// 使用 expiration 插件实现缓存条目数目和时间控制
new workbox.expiration.ExpirationPlugin({
// 最大缓存条目数
maxEntries: maxEntries,
// 最长缓存时间 30 天
maxAgeSeconds: 30 * 24 * 60 * 60,
}),
// 使用 cacheableResponse 插件缓存状态码为 0 的请求
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [0, 200],
}),
],
})
);
// 缓存 bootcdn、unpkg、jsdelivr 等公共库,用正则匹配
workbox.routing.registerRoute(
new RegExp('^https://(?:cdn\.bootcdn\.net|unpkg\.com|cdn\.jsdelivr\.net)'),
new workbox.strategies.CacheFirst({
cacheName: 'cdn' + cacheVersion,
fetchOptions: {
mode: 'cors',
credentials: 'omit',
},
plugins: [
new workbox.expiration.ExpirationPlugin({
maxEntries: maxEntries,
maxAgeSeconds: 30 * 24 * 60 * 60,
}),
],
})
);
// 自建 UMA 统计脚本: https://analytics.cuterwrite.top/uma
workbox.routing.registerRoute(
new RegExp('^https://analytics\.cuterwrite\.top/uma'),
new workbox.strategies.NetworkOnly({
plugins: [
// 使用 background sync 插件实现后台同步
new workbox.backgroundSync.BackgroundSyncPlugin('Optical_Collect', {
maxRetentionTime: 12 * 60,
}),
],
})
);
// 缓存存储桶图片 https://cloud.cuterwrite.fun/
workbox.routing.registerRoute(
new RegExp('^(https://cuterwrite-1302252842\.file\.myqcloud\.com|https://s2\.loli\.net)'),
new workbox.strategies.CacheFirst({
cacheName: 'image-cache' + cacheVersion,
plugins: [
new workbox.expiration.ExpirationPlugin({
maxEntries: maxEntries,
maxAgeSeconds: 30 * 24 * 60 * 60,
}),
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [0, 200],
}),
],
})
);
// 后缀匹配,针对其余没有被域名匹配到的静态文件
workbox.routing.registerRoute(
new RegExp('.*\.(?:png|jpg|jpeg|svg|gif|webp|ico)'),
new workbox.strategies.StaleWhileRevalidate()
);
workbox.routing.registerRoute(
new RegExp('.*\.(css|js)'),
new workbox.strategies.StaleWhileRevalidate()
);
// 默认匹配剩下的请求
workbox.routing.setDefaultHandler(
// 优先使用缓存,缓存没有则使用网络请求
new workbox.strategies.NetworkFirst({
networkTimeoutSeconds: 3,
})
);
} else {
console.log(`Boo! Workbox didn't load 😬`);
}
# manifest.json
- 创建 manifest.json 文件
在你的 Hugo 博客的根目录 static
文件夹下创建 manifest.json
文件,该文件包含了关于你的博客的元数据,例如名称、图标和显示选项。
{
"name": "你的博客名称",
"short_name": "博客简称",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000",
"icons": [{
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
注释
注意:将 icon-192x192.png 和 icon-512x512.png 替换为你自己的图标文件名。并确保将这两个图标文件放置在 Hugo 博客的
static
文件夹中。如果你想修改主题颜色和背景颜色,可以修改 theme_color 和 background_color 字段。
- 链接 manifest.json 文件
在你的 Hugo 博客的 layouts/partials/head/custom.html
文件中添加以下代码,将 manifest.json
文件链接到你的网站:
<link rel="manifest" href="/manifest.json">
完成以上步骤后,你的 Hugo 博客就具备了 PWA 功能,用户可以像使用原生应用程序一样访问你的网站。