【Hexo博客系列】No.3 让博客断网也能访问:Service Worker + PWA 实战
【Hexo博客系列】No.3 让博客断网也能访问:Service Worker + PWA 实战
zhangchangsheng上一篇完成了安知鱼主题的安装与可维护配置。这一篇给博客加上一项更像“应用”的能力:网络突然断开时,读者仍能打开访问过的文章;从桌面或主屏幕启动时,也能获得更完整的应用体验。
这里会用到三个容易混淆的概念:
- **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. 先明确目标与边界
我们要实现的效果是:
- 首页、离线页和 PWA 图标在安装 Service Worker 时预缓存。
- 访问过的文章页在断网后仍可打开;从未访问过的页面显示离线页。
- 同源 CSS、JavaScript、图片和字体优先快速显示,同时在后台获取新版。
- API、评论、统计和跨域 CDN 请求始终交给网络,不进入我们的缓存。
- 缓存有版本号、数量上限和旧版本清理逻辑,更新或撤销时不会留下“一屋子旧家具”。
缓存不是越多越好。评论内容可能因用户而异,统计请求有副作用,第三方资源的响应和更新策略也不由博客控制。把它们一股脑装进 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/ |
当前安知鱼主题已经提供 192×192 和 512×512 的 maskable 图标。为避免以后升级或替换主题时图标消失,建议复制到站点自己的 source 目录。macOS、Linux 或 Git Bash 可以执行:
mkdir -p source/img/pwa |
Windows 也可以用资源管理器完成同样的复制和重命名。不要只把普通小图强行声明成 512×512;sizes 应与文件真实尺寸一致。
5. 创建完整的 Manifest
新建 source/manifest.webmanifest:
{ |
其中 start_url 是从系统图标启动时打开的地址,scope 限定安装体验涵盖的 URL,display: "standalone" 表示浏览器可以用接近独立应用的窗口展示站点。图标数组为不同设备提供合适尺寸;maskable 表示图标按安全区域设计,系统可以裁成圆形、圆角矩形等形状。字段含义可查阅 MDN Manifest 参考 与 应用图标指南。
6. 创建一个不依赖外部资源的离线页
新建 source/offline/index.html。离线页不要再引用 CDN 字体、远程背景图或评论脚本,否则它自己也会在断网时“失联”。
|
7. 单独注册 Service Worker
新建 source/js/register-sw.js:
if ("serviceWorker" in navigator) { |
这里使用绝对根路径,避免在 /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"; |
这段代码用到了两种运行时缓存策略:
- 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: |
路径相对于 source/,不要再新增第二个 skip_render 键。Hexo 官方的配置文档说明了 skip_render 的用途;source 中不可渲染的资源会被直接复制到输出目录,见 Hexo 项目结构。
构建后应得到:
public/manifest.webmanifest |
10. 通过安知鱼的 PWA 与 inject 配置接入
编辑根目录 _config.anzhiyu.yml,在现有 pwa 块中调整以下值:
pwa: |
然后在已有的 inject.bottom 列表中加入注册脚本:
inject: |
不要再在 inject.head 重复添加 Manifest 链接;pwa.enable: true 已经会由主题输出 <link rel="manifest">。同理,不要同时启用另一个 PWA 插件,否则两个 worker 或两套缓存规则可能互相争夺同一 scope。
安知鱼推荐把主题自定义配置放在根目录的 _config.anzhiyu.yml,而不是直接修改主题源码,这样升级主题时不容易丢失,详见官方仓库的覆盖配置说明。
11. 构建、部署与上线检查
先在本地执行:
hexo clean |
访问终端给出的 localhost 地址。第一次打开页面时还没有 worker 控制当前文档;注册、安装并激活后,刷新一次再测试离线最直观。Service Worker 生命周期中,install 用于准备缓存,activate 常用于清理旧缓存;skipWaiting() 与 clients.claim() 可以让新版本更快接管页面。
部署到 GitHub Pages、Cloudflare Pages 或其他静态托管后,再检查这些线上地址都返回 200:
https://blog.zhangchangsheng.icu/manifest.webmanifest |
生产站必须是 HTTPS。还应确认 sw.js 的响应类型是 JavaScript,而不是托管平台把 404 页面以 200 状态返回;后一种情况会让注册失败或运行错误。
12. Chrome 与 Firefox 离线验收
12.1 Chrome / Chromium
- 在线打开首页和一篇带日期永久链接的文章。
- 打开开发者工具的 Application → Manifest,确认名称、图标和
start_url正常。 - 在 Application → Service Workers 确认
/sw.js状态为 activated,scope 为站点根目录。 - 在 Application → Cache Storage 检查
hexo-pwa-precache-*、hexo-pwa-pages-*和hexo-pwa-static-*。 - 在 Network 面板选择 Offline,刷新刚才访问过的文章:它应从缓存打开。
- 离线访问一篇从未打开过的文章地址:它应显示
/offline/。 - 恢复网络,再刷新文章,确认能拿到线上最新内容。
12.2 Firefox
- 在线打开首页与一篇文章,刷新一次。
- 在开发者工具的 存储(Storage)→ Cache Storage 检查三类缓存。
- 打开
about:debugging#/runtime/this-firefox,在 Service Workers 中确认本站 worker 已注册。 - 使用 Firefox 的 Work Offline(脱机工作) 后刷新访问过的文章,再测试未访问页面是否进入离线页。
- 恢复在线并重新加载,确认页面与静态资源能够更新。
不要只看“可安装”图标就宣布成功。至少要分别验证“访问过的页面”“从未访问的页面”“恢复网络后的更新”三个场景。
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()。
如果线上内容一直不更新,按顺序检查:
sw.js的内容是否真的部署成功,版本字符串是否变化。- 托管平台或 CDN 是否给
sw.js设置了过长缓存;注册脚本的updateViaCache: "none"能减少这类干扰,但仍要检查实际响应。 - Chrome Service Workers 面板中是否有 waiting worker;可以点 Update,或关闭所有本站标签页后重新打开。
- 是否同时运行了旧插件生成的另一个 Service Worker。一个页面只会被最具体 scope 的一个注册控制,重叠 scope 会让排查非常痛苦。
- 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 () => { |
让这版在线保留一段时间,使回访用户有机会执行注销与清理。第二步,再移除 inject.bottom 中的脚本、关闭 pwa.enable,并删除 source/sw.js、Manifest、离线页和 PWA 图标。管理员自己也可以在浏览器开发者工具中执行 Unregister 和 Clear site data 验证撤销结果。
小结
PWA 的难点不在于写下一句 register(),而在于划清缓存边界。把 worker 放在正确的 scope,用 Network First 保证文章新鲜度,用 Stale While Revalidate 加速静态资源,只保存成功的同源 GET 响应,并给升级与撤销留出出口,离线能力才会是可靠的增强,而不是一只甩不掉的“缓存幽灵”。
