Featured image of post 基于 Workbox 实现 Hugo 渐进式 Web 应用

基于 Workbox 实现 Hugo 渐进式 Web 应用

本文讲述了如何利用 Workbox 为 Hugo 静态网站添加 PWA 功能,通过 Service Worker 提升加载速度和用户体验。PWA 的优势包括快速加载、离线访问、推送通知和安装到主屏幕。文章详细介绍了注册 Service Worker、使用 Workbox 的缓存策略,并提供了详细的配置步骤和示例代码。

最近给基于 Hugo 搭建的博客添加了 PWA 功能,显著提升了加载速度和用户体验,甚至实现了离线访问。至于如何实现,那么你需要了解 Progressive Web Apps (PWA)

# 什么是 PWA

渐进式 Web 应用(Progressive Web Apps,简称 PWA)利用现代 Web API 和传统的渐进式增强策略,打造出跨平台的 Web 应用程序。这些应用无处不在,功能丰富,为用户带来媲美原生应用的体验。

PWA 的优势:

  • ⚡️ 更快的加载速度: PWA 可以缓存重要资源,即使网络状况不佳也能快速加载。
  • ✈️ 离线访问: PWA 可以缓存内容,让用户即使离线也能访问内容。
  • 🔔 推送通知: 像原生应用一样,PWA 可以向用户发送推送通知,提高用户参与度。
  • 📱 安装到主屏幕: 用户可以将你的应用添加到电脑或手机桌面,像原生应用一样浏览你的 Web 应用。

PWA 的实现原理是 Service WorkerService 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 文件中(其他主题可能需要根据文件结构进行调整):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<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 的注册状态。

Service Worker

Service Worker

# 导入 Workbox

在你的 Hugo 网站根目录下的 static 文件夹中创建 sw.js 文件。然后,在 sw.js 文件中添加以下代码,使用 CDN 导入 Workbox:

1
importScripts('https://cdn.bootcdn.net/ajax/libs/workbox-sw/7.1.0/workbox-sw.js');

# 缓存策略

Workbox 提供了一些常用的缓存策略,如 CacheFirstNetworkFirstStaleWhileRevalidate 等。这里先介绍几种常用的策略。

# CacheOnly 仅缓存

CacheOnly

CacheOnly

强制响应来自缓存。

1
2
3
4
workbox.routing.registerRoute(
    new RegExp(regex),
    new workbox.strategies.CacheOnly()
);

# NetworkOnly 仅网络

NetworkOnly

NetworkOnly

这种缓存策略强制要求所有请求都从网络获取最新数据,完全绕过缓存。

1
2
3
4
workbox.routing.registerRoute(
    new RegExp(regex),
    new workbox.strategies.NetworkOnly()
);

# CacheFirst 优先缓存

CacheFirst

CacheFirst

这种缓存策略以速度为优先,会首先尝试从缓存中获取响应,以尽快向用户显示内容。如果缓存中没有所需数据,它才会向网络发起请求获取数据。

1
2
3
4
workbox.routing.registerRoute(
    new RegExp(regex),
    new workbox.strategies.CacheFirst()
);

# NetworkFirst 优先网络

NetworkFirst

NetworkFirst

这种缓存策略优先使用最新数据,因此会首先尝试从网络获取响应。如果网络请求失败,例如用户离线或网络连接不稳定,它会回退使用缓存中的数据,确保用户仍然可以访问内容。

1
2
3
4
workbox.routing.registerRoute(
    new RegExp(regex),
    new workbox.strategies.NetworkFirst()
);

# StaleWhileRevalidate 读取缓存,同时发起网络请求

StaleWhileRevalidate

StaleWhileRevalidate

这种缓存策略优先返回缓存内容(如果有)。即使缓存内容有效,它也会在后台发起网络请求以获取最新数据,保证用户最终能看到最新内容。虽然这种策略能确保用户定期更新缓存,但也意味着每次请求都会产生网络流量,即使数据没有变化,也比较浪费带宽。

1
2
3
4
workbox.routing.registerRoute(
    new RegExp(regex),
    new workbox.strategies.StaleWhileRevalidate()
);

# 策略配置

Workbox 不仅提供上述策略,还允许通过 cacheName、plugins 和 expiration 等配置项进行自定义。你可以通过定义要使用的插件来自定义路由行为。例如,你可以配置缓存名称、缓存有效期以及可缓存的响应状态码,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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],
            }),
        ],
    })
);

# 本站配置

# 全局配置

以下是全局缓存配置:

1
2
3
4
// 缓存版本号
let cacheVersion = '-240619';
// 最大条目数
const maxEntries = 100;

# Twitto 配置

为了确保用户即使在离线状态下也能查看评论,Twitto 评论 API 采用了 NetworkFirst 缓存策略。这意味着浏览器会优先尝试从网络获取最新数据,如果网络不可用,则使用缓存中的数据。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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),不进行缓存。

1
2
3
4
workbox.routing.registerRoute(
    new RegExp('^https://cuterwrite\.top/(index|sitemap)\.xml'),
    new workbox.strategies.NetworkOnly()
);

# HTML 配置

为了在保证用户快速加载页面的同时,也能获取到最新内容,网站对 HTML 页面采用了 StaleWhileRevalidate 缓存策略。这意味着浏览器会优先使用缓存中的页面进行展示,同时在后台向服务器发起请求,获取最新版本,并在下次请求时使用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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 缓存策略,并设置了较长的缓存过期时间。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
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 缓存策略,并设置了较长的缓存过期时间。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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 插件来实现即使在网络离线的情况下也能保证数据最终发送成功。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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 缓存策略,并设置了较长的缓存过期时间。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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 缓存策略。

1
2
3
4
5
6
7
8
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 策略并设置了网络超时时间,以兼顾资源获取速度和离线可用性。

1
2
3
4
5
6
workbox.routing.setDefaultHandler(
    // 优先使用缓存,缓存没有则使用网络请求
    new workbox.strategies.NetworkFirst({
        networkTimeoutSeconds: 3,
    })
);

# 完整配置

sw.js
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
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

  1. 创建 manifest.json 文件

在你的 Hugo 博客的根目录 static 文件夹下创建 manifest.json 文件,该文件包含了关于你的博客的元数据,例如名称、图标和显示选项。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{
    "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 字段。

  1. 链接 manifest.json 文件

在你的 Hugo 博客的 layouts/partials/head/custom.html 文件中添加以下代码,将 manifest.json 文件链接到你的网站:

1
<link rel="manifest" href="/manifest.json">

完成以上步骤后,你的 Hugo 博客就具备了 PWA 功能,用户可以像使用原生应用程序一样访问你的网站。

# 参考资料

Licensed under CC BY-NC-SA 4.0
本博客已稳定运行
总访客数: Loading
总访问量: Loading
发表了 73 篇文章 · 总计 329.37k

使用 Hugo 构建
主题 StackJimmy 设计
基于 v3.30.0 分支版本修改