【Hexo博客系列】No.3 让博客断网也能访问:Service Worker + PWA 实战

上一篇完成了安知鱼主题的安装与可维护配置。这一篇给博客加上一项更像“应用”的能力:网络突然断开时,读者仍能打开访问过的文章;从桌面或主屏幕启动时,也能获得更完整的应用体验。

这里会用到三个容易混淆的概念:

  • **PWA(Progressive Web App,渐进式 Web 应用)**是一组让网站具备可安装、离线可用等能力的技术组合,不是某一个插件。
  • **Web App Manifest(Web 应用清单)**是一份 JSON 文件,负责名称、图标、启动地址和显示模式等“安装外观”。
  • **Service Worker(服务工作线程)**是浏览器在页面之外运行的脚本,可以拦截其控制范围内的请求,并决定访问网络还是读取缓存。

Manifest 本身不会让页面离线。真正的离线能力来自 Service Worker 和 Cache Storage(缓存存储)。MDN 也把 Manifest 定义为向浏览器提供 Web 应用安装信息的 JSON 文件,而 Service Worker 则负责请求代理与离线体验:Web App Manifest、Service Worker API。

本文采用手工方案,不引入额外 PWA 插件。这样代码多一点,但缓存了什么、什么时候更新、怎样彻底撤销都清清楚楚。

1. 先明确目标与边界

我们要实现的效果是:

  1. 首页、离线页和 PWA 图标在安装 Service Worker 时预缓存。
  2. 访问过的文章页在断网后仍可打开;从未访问过的页面显示离线页。
  3. 同源 CSS、JavaScript、图片和字体优先快速显示,同时在后台获取新版。
  4. API、评论、统计和跨域 CDN 请求始终交给网络,不进入我们的缓存。
  5. 缓存有版本号、数量上限和旧版本清理逻辑,更新或撤销时不会留下“一屋子旧家具”。

缓存不是越多越好。评论内容可能因用户而异,统计请求有副作用,第三方资源的响应和更新策略也不由博客控制。把它们一股脑装进 Cache Storage,轻则看到过期内容,重则造成隐私与功能问题。

2. HTTPS、localhost 与控制范围

Service Worker 只能运行在安全上下文中,也就是生产环境使用 HTTPS;浏览器为了方便本地开发,会把 http://localhost 视为安全上下文。普通局域网 HTTP 地址并不自动享受这个例外。详见 MDN:Service Worker API。

另一个关键概念是 scope(控制范围)。默认 scope 是 Service Worker 文件所在目录:把 sw.js 放在站点根目录并注册为 /sw.js,它就能控制根目录下的文章、分类和标签页。若把它放在 /js/sw.js,默认只能控制 /js/ 下的 URL,无法接管整站导航。register() 文档对此有明确说明。

当前项目的 _config.yml 使用独立域名 https://blog.zhangchangsheng.icu,文章永久链接为 :year/:month/:day/:title/。因此将 sw.js 输出到站点根目录、scope 设为 /,能够覆盖这些带日期的文章地址。

如果以后改为 https://username.github.io/repository/ 这样的子目录部署,不能照抄根路径:应把本文中的 /sw.js、/offline/、Manifest 的 start_url 与 scope 等路径统一改为 /repository/...,注册 scope 也改成 /repository/。不要试图用一个放在子目录里的 worker 越权控制站点根目录。

3. 看清当前安知鱼配置

这个项目当前有几个值得先说明的事实:

  • _config.anzhiyu.yml 中 pwa.enable 为 false。
  • 开启它以后,主题会输出 Manifest、图标和 Apple 启动画面标签,但主题模板中的 Service Worker 注册代码被注释了。可以直接查看当前版本的 AnZhiYu pwa.pug 源码。
  • 主题目录虽然有 sw-rules.js,根项目的 package.json 却没有安装 hexo-offline 或其他生成 Service Worker 的插件,所以仅设置 pwa.enable: true 不会自动获得离线缓存。
  • 当前 startup_image_enable: true 会引用一组 apple-splash-*.jpg,但项目和主题现有资源中并没有这些文件。要么补齐全部启动图,要么像本文一样先设为 false,避免浏览器产生一串 404。
  • 当前 CDN.internal_provider 为 local,主题内部脚本从同源加载;third_party_provider 为 cbd,第三方脚本来自跨域 CDN。本文只缓存前者,后者断网时可能不可用,这是有意为之。

这也是为什么我们要单独创建注册脚本,而不是把 pwa.enable 当成“离线开关”。

4. 准备文件布局与图标

完成后,新增文件应是下面的结构:

source/
├── manifest.webmanifest
├── sw.js
├── offline/
│ └── index.html
├── js/
│ └── register-sw.js
└── img/
└── pwa/
├── icon-192.png
└── icon-512.png

当前安知鱼主题已经提供 192×192 和 512×512 的 maskable 图标。为避免以后升级或替换主题时图标消失,建议复制到站点自己的 source 目录。macOS、Linux 或 Git Bash 可以执行:

mkdir -p source/img/pwa
cp themes/anzhiyu/source/img/siteicon/manifest-icon-192.maskable.png source/img/pwa/icon-192.png
cp themes/anzhiyu/source/img/siteicon/manifest-icon-512.maskable.png source/img/pwa/icon-512.png

Windows 也可以用资源管理器完成同样的复制和重命名。不要只把普通小图强行声明成 512×512;sizes 应与文件真实尺寸一致。

5. 创建完整的 Manifest

新建 source/manifest.webmanifest:

{
"id": "/",
"name": "边走边爱,四海为家",
"short_name": "四海为家",
"description": "记录技术实践与生活见闻的个人博客",
"lang": "zh-CN",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#f7f9fe",
"theme_color": "#49b1f5",
"icons": [
{
"src": "/img/pwa/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/img/pwa/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

其中 start_url 是从系统图标启动时打开的地址,scope 限定安装体验涵盖的 URL,display: "standalone" 表示浏览器可以用接近独立应用的窗口展示站点。图标数组为不同设备提供合适尺寸;maskable 表示图标按安全区域设计,系统可以裁成圆形、圆角矩形等形状。字段含义可查阅 MDN Manifest 参考 与 应用图标指南。

6. 创建一个不依赖外部资源的离线页

新建 source/offline/index.html。离线页不要再引用 CDN 字体、远程背景图或评论脚本,否则它自己也会在断网时“失联”。

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#49b1f5">
<title>暂时离线</title>
<style>
:root {
color-scheme: light dark;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
body {
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
background: #f7f9fe;
color: #1f2d3d;
}
main {
width: min(34rem, calc(100% - 3rem));
padding: 2rem;
border-radius: 1rem;
background: #ffffff;
box-shadow: 0 1rem 3rem rgb(31 45 61 / 12%);
text-align: center;
}
a { color: #1677ff; }
@media (prefers-color-scheme: dark) {
body { background: #17191c; color: #e8eaed; }
main { background: #24272b; }
}
</style>
</head>
<body>
<main>
<h1>网络开小差了</h1>
<p>这个页面还没有被缓存。恢复网络后重试,或返回已访问过的文章。</p>
<p><a href="/">返回首页</a></p>
</main>
</body>
</html>

7. 单独注册 Service Worker

新建 source/js/register-sw.js:

if ("serviceWorker" in navigator) {
window.addEventListener("load", async () => {
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
updateViaCache: "none",
});

console.info("Service Worker 已注册:", registration.scope);
} catch (error) {
console.error("Service Worker 注册失败:", error);
}
});
}

这里使用绝对根路径,避免在 /2026/09/17/文章标题/ 这样的 Hexo 永久链接中被解析成文章目录下的 sw.js。updateViaCache: "none" 表示检查 worker 脚本更新时不使用 HTTP 缓存;它只影响 worker 脚本及其导入,不会关闭页面资源的正常 HTTP 缓存,具体行为见 register() 的 updateViaCache。

8. 编写保守的 sw.js

新建 source/sw.js,完整内容如下:

const VERSION = "2026-09-17-1";
const CACHE_PREFIX = "hexo-pwa-";
const PRECACHE = `${CACHE_PREFIX}precache-${VERSION}`;
const PAGE_CACHE = `${CACHE_PREFIX}pages-${VERSION}`;
const STATIC_CACHE = `${CACHE_PREFIX}static-${VERSION}`;
const CURRENT_CACHES = new Set([PRECACHE, PAGE_CACHE, STATIC_CACHE]);

const OFFLINE_URL = "/offline/";
const PRECACHE_URLS = [
"/",
OFFLINE_URL,
"/manifest.webmanifest",
"/img/pwa/icon-192.png",
"/img/pwa/icon-512.png",
];

const BYPASS_PATH_PREFIXES = [
"/api/",
"/comment/",
"/comments/",
"/analytics/",
"/stats/",
];

const STATIC_DESTINATIONS = new Set(["style", "script", "image", "font"]);

function shouldBypass(url) {
return (
url.pathname === "/graphql" ||
BYPASS_PATH_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))
);
}

function isCacheable(request, response) {
if (request.method !== "GET" || !response || !response.ok) return false;

const requestUrl = new URL(request.url);
const responseUrl = new URL(response.url);
const cacheControl = response.headers.get("Cache-Control") || "";

return (
requestUrl.origin === self.location.origin &&
responseUrl.origin === self.location.origin &&
response.type === "basic" &&
!request.headers.has("Authorization") &&
!cacheControl.toLowerCase().includes("no-store") &&
response.headers.get("Vary") !== "*"
);
}

async function trimCache(cache, maxEntries) {
const keys = await cache.keys();
const overflow = keys.length - maxEntries;

if (overflow > 0) {
await Promise.all(keys.slice(0, overflow).map((key) => cache.delete(key)));
}
}

async function putInCache(cacheName, request, response, maxEntries) {
if (!isCacheable(request, response)) return;

const cache = await caches.open(cacheName);
await cache.put(request, response);
await trimCache(cache, maxEntries);
}

self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(PRECACHE);

for (const url of PRECACHE_URLS) {
const request = new Request(url, {
method: "GET",
credentials: "same-origin",
cache: "reload",
});
const response = await fetch(request);

if (!isCacheable(request, response)) {
throw new Error(`预缓存失败:${url}`);
}

await cache.put(request, response);
}

await self.skipWaiting();
})(),
);
});

self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const cacheNames = await caches.keys();
await Promise.all(
cacheNames
.filter(
(name) => name.startsWith(CACHE_PREFIX) && !CURRENT_CACHES.has(name),
)
.map((name) => caches.delete(name)),
);

await self.clients.claim();
})(),
);
});

async function networkFirst(event) {
const { request } = event;
const url = new URL(request.url);
const cache = await caches.open(PAGE_CACHE);
const cacheKey = new Request(`${url.origin}${url.pathname}`, {
method: "GET",
credentials: "same-origin",
});

let response;

try {
response = await fetch(request);
} catch (error) {
return (
(await cache.match(cacheKey)) ||
(await caches.match(OFFLINE_URL)) ||
new Response("当前离线,且离线页尚未缓存。", {
status: 503,
headers: { "Content-Type": "text/plain; charset=utf-8" },
})
);
}

// 带查询参数的导航可以读取缓存,但不写入,避免追踪参数制造重复条目。
if (!url.search && isCacheable(request, response)) {
try {
await putInCache(PAGE_CACHE, cacheKey, response.clone(), 30);
} catch (error) {
console.warn("页面缓存写入失败:", error);
}
}

return response;
}

async function staleWhileRevalidate(event) {
const { request } = event;
const cache = await caches.open(STATIC_CACHE);
const cachedResponse = await cache.match(request);

const networkPromise = fetch(request).then(async (response) => {
if (isCacheable(request, response)) {
try {
await putInCache(STATIC_CACHE, request, response.clone(), 80);
} catch (error) {
console.warn("静态资源缓存写入失败:", error);
}
}

return response;
});

if (cachedResponse) {
event.waitUntil(networkPromise.catch(() => undefined));
return cachedResponse;
}

return networkPromise;
}

self.addEventListener("fetch", (event) => {
const { request } = event;
const url = new URL(request.url);

// 非 GET、跨域、接口、评论和统计请求全部交给浏览器正常处理。
if (
request.method !== "GET" ||
url.origin !== self.location.origin ||
shouldBypass(url)
) {
return;
}

if (request.mode === "navigate") {
event.respondWith(networkFirst(event));
return;
}

if (STATIC_DESTINATIONS.has(request.destination)) {
event.respondWith(staleWhileRevalidate(event));
}
});

这段代码用到了两种运行时缓存策略:

  • Network First(网络优先):文章和其他导航请求先访问网络,成功时更新缓存;断网或连接失败才读取曾访问页面,仍没有就返回离线页。它适合希望优先看到新内容的博客页面。
  • Stale While Revalidate(先用旧缓存、后台更新):CSS、脚本、图片和字体有缓存就立即返回,同时在后台请求新版,下一次访问会用新内容。它适合允许短暂过期的静态资源。web.dev 的缓存策略说明也将“网络失败时回退缓存”和“先返回缓存再更新”用于不同的新鲜度需求。

**预缓存(precache)**发生在 install 阶段,用于提前保存离线体验必需文件;**运行时缓存(runtime cache)**则在读者实际访问页面和资源时逐步形成。response.ok、同源、GET、no-store、Authorization 与 Vary: * 检查共同守住写入边界。Cache API 的 put() 会覆盖同键响应,而且响应体写入后会被消费,因此代码写入的是 response.clone();可参考 MDN:Cache.put()。

页面缓存最多保留 30 条,静态资源最多保留 80 条。这里采用简单的先进先出清理,避免长期浏览把 Cache Storage 撑得越来越大。大型站点可以进一步使用带过期时间的缓存管理方案,但不要在没有上限和失效规则时缓存全站所有图片。

9. 让 Hexo 原样复制这些文件

Hexo 会渲染可渲染文件,并把不可渲染文件复制到 public。为了防止 HTML、JavaScript 或 Manifest 被渲染器和模板误处理,在根目录 _config.yml 中找到现有的 skip_render,改成一个列表:

skip_render:
- "manifest.webmanifest"
- "sw.js"
- "offline/**"
- "js/register-sw.js"

路径相对于 source/,不要再新增第二个 skip_render 键。Hexo 官方的配置文档说明了 skip_render 的用途;source 中不可渲染的资源会被直接复制到输出目录,见 Hexo 项目结构。

构建后应得到:

public/manifest.webmanifest
public/sw.js
public/offline/index.html
public/js/register-sw.js
public/img/pwa/icon-192.png
public/img/pwa/icon-512.png

10. 通过安知鱼的 PWA 与 inject 配置接入

编辑根目录 _config.anzhiyu.yml,在现有 pwa 块中调整以下值:

pwa:
enable: true
startup_image_enable: false
manifest: /manifest.webmanifest
theme_color: "#49b1f5"
mask_icon: /img/siteicon/apple-icon-180.png
apple_touch_icon: /img/siteicon/apple-icon-180.png
bookmark_icon: /img/siteicon/apple-icon-180.png
favicon_32_32: /img/siteicon/32.png
favicon_16_16: /img/siteicon/16.png

然后在已有的 inject.bottom 列表中加入注册脚本:

inject:
head:
# 保留原有 head 注入项
bottom:
- <script src="/js/register-sw.js" defer></script>

不要再在 inject.head 重复添加 Manifest 链接;pwa.enable: true 已经会由主题输出 <link rel="manifest">。同理,不要同时启用另一个 PWA 插件,否则两个 worker 或两套缓存规则可能互相争夺同一 scope。

安知鱼推荐把主题自定义配置放在根目录的 _config.anzhiyu.yml,而不是直接修改主题源码,这样升级主题时不容易丢失,详见官方仓库的覆盖配置说明。

11. 构建、部署与上线检查

先在本地执行:

hexo clean
hexo generate
hexo server

访问终端给出的 localhost 地址。第一次打开页面时还没有 worker 控制当前文档;注册、安装并激活后,刷新一次再测试离线最直观。Service Worker 生命周期中,install 用于准备缓存,activate 常用于清理旧缓存;skipWaiting() 与 clients.claim() 可以让新版本更快接管页面。

部署到 GitHub Pages、Cloudflare Pages 或其他静态托管后,再检查这些线上地址都返回 200:

https://blog.zhangchangsheng.icu/manifest.webmanifest
https://blog.zhangchangsheng.icu/sw.js
https://blog.zhangchangsheng.icu/offline/
https://blog.zhangchangsheng.icu/js/register-sw.js

生产站必须是 HTTPS。还应确认 sw.js 的响应类型是 JavaScript,而不是托管平台把 404 页面以 200 状态返回;后一种情况会让注册失败或运行错误。

12. Chrome 与 Firefox 离线验收

12.1 Chrome / Chromium

  1. 在线打开首页和一篇带日期永久链接的文章。
  2. 打开开发者工具的 Application → Manifest,确认名称、图标和 start_url 正常。
  3. 在 Application → Service Workers 确认 /sw.js 状态为 activated,scope 为站点根目录。
  4. 在 Application → Cache Storage 检查 hexo-pwa-precache-*、hexo-pwa-pages-* 和 hexo-pwa-static-*。
  5. 在 Network 面板选择 Offline,刷新刚才访问过的文章:它应从缓存打开。
  6. 离线访问一篇从未打开过的文章地址:它应显示 /offline/。
  7. 恢复网络,再刷新文章,确认能拿到线上最新内容。

12.2 Firefox

  1. 在线打开首页与一篇文章,刷新一次。
  2. 在开发者工具的 存储(Storage)→ Cache Storage 检查三类缓存。
  3. 打开 about:debugging#/runtime/this-firefox,在 Service Workers 中确认本站 worker 已注册。
  4. 使用 Firefox 的 Work Offline(脱机工作) 后刷新访问过的文章,再测试未访问页面是否进入离线页。
  5. 恢复在线并重新加载,确认页面与静态资源能够更新。

不要只看“可安装”图标就宣布成功。至少要分别验证“访问过的页面”“从未访问的页面”“恢复网络后的更新”三个场景。

13. 更新版本与避免缓存污染

发布文章通常不需要每次改 VERSION:页面采用 Network First,读者在线访问时会更新对应缓存。下面这些变化则应同步修改 sw.js 中的版本号,例如从 2026-09-17-1 改为 2026-09-20-1:

  • 修改了预缓存文件或离线页。
  • 更换了图标、核心样式或缓存规则。
  • 需要一次性抛弃旧运行时缓存。

浏览器发现 sw.js 文件内容变化后会安装新版本;activate 阶段只删除以 hexo-pwa- 开头且不在当前列表中的缓存,不会误删同一域名下其他应用的缓存。MDN 推荐在 activate 中配合 caches.keys() 与 caches.delete() 清理旧版本,参见 CacheStorage.delete()。

如果线上内容一直不更新,按顺序检查:

  1. sw.js 的内容是否真的部署成功,版本字符串是否变化。
  2. 托管平台或 CDN 是否给 sw.js 设置了过长缓存;注册脚本的 updateViaCache: "none" 能减少这类干扰,但仍要检查实际响应。
  3. Chrome Service Workers 面板中是否有 waiting worker;可以点 Update,或关闭所有本站标签页后重新打开。
  4. 是否同时运行了旧插件生成的另一个 Service Worker。一个页面只会被最具体 scope 的一个注册控制,重叠 scope 会让排查非常痛苦。
  5. Network 面板中的响应究竟来自 Service Worker、HTTP 缓存还是网络。三种缓存层不要混为一谈。

为避免缓存污染,本文的 worker 明确拒绝以下内容:

  • POST、PUT 等非 GET 请求;
  • 所有跨域资源,包括当前安知鱼配置使用的第三方 CDN;
  • /api/、评论、统计、GraphQL 等路径;
  • 带 Authorization 的请求;
  • Cache-Control: no-store 或 Vary: * 的响应;
  • 网络错误、重定向到跨域后的响应和非成功响应。

如果你的评论或统计服务使用了其他同源路径,把对应前缀补进 BYPASS_PATH_PREFIXES。宁可少缓存一点,也不要猜测动态接口可以离线复用。

14. 常见问题

14.1 注册时报 SecurityError

先检查是否通过 HTTPS 或 localhost 访问,再检查 sw.js 是否与页面同源。直接双击 HTML 文件形成的 file:// 地址不能注册 Service Worker。

14.2 scope 不对或文章页不受控制

确认线上文件是 /sw.js,注册地址也是 /sw.js,scope 是 /。如果站点部署在子目录,所有相关路径必须使用同一个子目录前缀。不要只改注册 scope 而忘记 Manifest 和预缓存列表。

14.3 worker 一直无法安装

本例要求所有预缓存资源都成功;任意一个图标或 /offline/ 返回 404,install 就会失败,旧 worker 继续工作。打开 Network 面板逐个访问 PRECACHE_URLS,比反复清空浏览器更有效。

14.4 页面能打开但样式或评论缺失

访问过的同源静态资源可以离线使用,跨域 CDN、评论和统计被刻意排除。离线阅读正文是“降级可用”,不是把整个互联网塞进缓存。如果核心样式来自第三方 CDN,应先改成同源自托管,再决定是否缓存。

14.5 更新后出现新 HTML 搭配旧样式

这通常是 worker、HTTP 缓存和 CDN 版本不同步。先提升 VERSION,确保静态文件 URL 或内容已更新,再检查托管平台响应头。skipWaiting() 会让新 worker 立即激活,简单博客体验更直接;若站点资源之间必须严格同版本,可以去掉它,让新 worker 等旧页面全部关闭后再接管。

14.6 本地正常,部署后 manifest.webmanifest 变成 HTML

检查 skip_render 的缩进和文件名,再查看构建产物。某些托管平台会把未知路径重写到 404 或首页,所以不能只看状态码,还要看响应内容与 Content-Type。

15. 如何安全撤销

只删除 register-sw.js 并不够:已经注册的 worker 会留在读者浏览器里继续控制页面。需要分两步撤销。

第一步,暂时保留 inject.bottom,把 source/js/register-sw.js 改成清理脚本并部署:

window.addEventListener("load", async () => {
if ("serviceWorker" in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();

await Promise.all(
registrations
.filter((registration) => registration.scope === `${location.origin}/`)
.map((registration) => registration.unregister()),
);
}

if ("caches" in window) {
const cacheNames = await caches.keys();

await Promise.all(
cacheNames
.filter((name) => name.startsWith("hexo-pwa-"))
.map((name) => caches.delete(name)),
);
}
});

让这版在线保留一段时间,使回访用户有机会执行注销与清理。第二步,再移除 inject.bottom 中的脚本、关闭 pwa.enable,并删除 source/sw.js、Manifest、离线页和 PWA 图标。管理员自己也可以在浏览器开发者工具中执行 Unregister 和 Clear site data 验证撤销结果。

小结

PWA 的难点不在于写下一句 register(),而在于划清缓存边界。把 worker 放在正确的 scope,用 Network First 保证文章新鲜度,用 Stale While Revalidate 加速静态资源,只保存成功的同源 GET 响应,并给升级与撤销留出出口,离线能力才会是可靠的增强,而不是一只甩不掉的“缓存幽灵”。

参考资料