如何让博客支持离线访问

PWA 是个 buzzword, 说白了这个博客用到的 PWA 功能就两个: manifest.json 和 Service Worker. manifest.json 让浏览器知道"这是个应用", Service Worker 让它在离线时也能工作.

这篇重点说 Service Worker, 因为它比较复杂. manifest.json 很简单, 放在最后说.

Service Worker 是什么

简单说, Service Worker 是一个运行在浏览器背后的脚本. 它独立于网页, 在后台默默干活.

你可以把它理解为浏览器和网络之间的中间人. 正常情况下, 浏览器请求一个资源 (HTML, JS, 图片), 直接去服务器拿. 有了 Service Worker, 请求会先经过它:

浏览器 → Service Worker → 网络 (或缓存)

Service Worker 可以决定: 这个请求是去网络拿, 还是直接从本地缓存返回, 还是两者都做.

这个博客的 Service Worker 就是用 Workbox 生成的, 大约 5KB.

有什么用

离线访问

这是最核心的功能. 如果用户访问过一次博客, Service Worker 会把静态资源缓存下来. 下次即使没有网络, 也能打开之前看过的文章.

更快的加载速度

缓存的资源直接从本地读取, 不需要等待网络. 第二次访问时, 大部分资源都是秒开.

独立于页面

Service Worker 是独立运行的. 关闭页面后它仍然在后台, 可以用来做后台同步, 推送通知等. 不过这个博客目前没用到这些功能.

顺便吐槽一下: SW 的设计初衷是好的, 但调试起来真的很烦. 每次改了缓存策略, 部署上去, 自己在浏览器里怎么测都感觉没生效. 后来才发现 Chrome DevTools 的 Application 面板里可以手动 unregister, 这才解放了.

怎么生成的

没有手写 Service Worker 脚本. 用的是 Workbox 的 generateSW 模式, 在 Gulp 构建时自动生成.

gulpfile.js 里:

gulp.task('generate-service-worker', () => {
    return workbox.generateSW({
        globDirectory: './public',
        globPatterns: ['**/*.{woff2,woff,js,css,png.jpg}'],
        globIgnores: ['sw.js'],
        swDest: `./public/sw.js`,
        clientsClaim: true,
        skipWaiting: true,
        runtimeCaching: [
            { urlPattern: /.*\.js/,    handler: 'NetworkFirst' },
            { urlPattern: /.*\.css/,   handler: 'StaleWhileRevalidate' },
            { urlPattern: /.*\.(?:png|jpg|jpeg|svg|gif)/,
              handler: 'CacheFirst',
              options: { cacheName: 'images', expiration: { maxEntries: 50 } } },
            { urlPattern: /.*\.html/,  handler: 'NetworkFirst' }
        ]
    })
    .then(() => console.info('SW generated.'))
    .catch(error => console.warn('SW generation failed: ' + error));
});

globPatterns 告诉 Workbox 哪些文件需要预缓存. 构建完成后, Workbox 扫描 public/ 目录, 把匹配的文件列表写入 SW, 用户第一次访问时就全部缓存.

runtimeCaching 定义了运行时的缓存策略, 下面会说.

生成出来的 sw.js 文件大约 5KB, 放在 public/ 根目录.

缓存策略

四种资源, 四种处理方式:

JS 文件: NetworkFirst (优先走网络)

{ urlPattern: /.*\.js/, handler: 'NetworkFirst' }

JS 文件更新频繁. 发布新版本时, JS 内容会变. 所以优先从网络获取, 确保用户跑的是最新代码. 如果网络不可用, 再使用缓存的版本.

CSS 文件: StaleWhileRevalidate (缓存优先, 后台更新)

{ urlPattern: /.*\.css/, handler: 'StaleWhileRevalidate' }

CSS 变化不频繁. 直接从缓存返回给用户, 同时在后台悄悄请求最新版本更新缓存. 用户不需要等待网络, 体验快.

图片: CacheFirst (只走缓存)

{ urlPattern: /.*\.(?:png|jpg|jpeg|svg|gif)/,
  handler: 'CacheFirst',
  options: { cacheName: 'images', expiration: { maxEntries: 50 } } }

图片几乎不会变. 直接从缓存拿, 拿不到再去网络请求. 最多缓存 50 张, 超过后淘汰最旧的. 防止缓存无限膨胀.

HTML: NetworkFirst (优先走网络)

{ urlPattern: /.*\.html/, handler: 'NetworkFirst' }

文章内容是动态更新的. 用户应该看到最新版本. 所以优先走网络. 离线时用缓存兜底.

怎么注册的

Service Worker 需要在页面里注册才能激活. 在 layouts/partials/js.html 里:

if ('serviceWorker' in navigator) {
    if (location.host !== 'time-friend.com') return;
    window.addEventListener('load', () => {
        navigator.serviceWorker
            .register('/sw.js')
            .catch(registrationError => {
                console.log('SW registration failed: ', registrationError);
            });
    });
}

条件限制:

  1. 浏览器支持: serviceWorker in navigator — 只有支持 Service Worker 的浏览器才执行
  2. 生产环境: location.host !== 'time-friend.com' — 只在正式域名下注册, 开发环境不注册, 避免本地调试时缓存干扰
  3. 页面加载后: window.addEventListener('load') — 等页面主要内容都加载完了再注册 SW, 不影响首屏渲染

注册后, 浏览器下载 sw.js, 安装, 激活. 首次访问不会缓存任何东西, 第二次访问开始生效.

这里有个很隐蔽的问题: 你更新了文章内容重新部署, 用户如果之前访问过, SW 还在用旧缓存. 虽然有 NetworkFirst 策略保证 HTML 走网络, 但如果网络慢, 还是会先展示缓存的老版本, 等新的回来了再替换. 用户可能看到一闪而过的旧内容. 这个问题到现在也没什么完美的解法.

manifest.json

PWA 的另一半是 manifest.json. 放在 static/manifest.json, 构建时直接复制到 public/ 根目录:

{
    "name": "WEB BANG! BANG!! BANG!!!",
    "short_name": "前端大爆炸",
    "theme_color": "#1d1f21",
    "background_color": "#1d1f21",
    "display": "standalone",
    "Scope": "/",
    "start_url": "/",
    "icons": [
        { "src": "https://time-friend.com/images/50x50.png", "sizes": "144x144", "type": "image/png" },
        { "src": "https://time-friend.com/images/50x50.png", "sizes": "128x128", "type": "image/png" },
        { "src": "https://time-friend.com/images/50x50.png", "sizes": "96x96", "type": "image/png" }
    ]
}

head.html 里引用:

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

作用就是: 用户在支持 PWA 的浏览器上访问时, 浏览器会提示"添加到桌面". 添加后看起来像一个原生应用, 有图标, 有启动画面.

图标只有一张 50x50 的图缩放了三个尺寸, 比较敷衍. 不过反正也没什么人真的会把这个博客添加到桌面.

性能影响

正面:

  • 第二次访问开始, 大部分资源从缓存加载, 速度明显提升
  • 离线可用, 网络差的时候也能正常阅读
  • SW 文件只有 5KB, 几乎不增加带宽消耗

负面:

  • 首次访问后多下载一次 SW 和相关缓存 (但总量很小, 照片级别的博客才需要注意这个问题)
  • 更新 SW 后, 需要用户关闭再打开页面才能完全切换到新版本 (虽然有 skipWaiting, 但只能跳过等待阶段, 不能强制刷新页面)
  • NetworkFirst 策略下, 网络慢的时候用户可能先看到缓存的旧版本, 等新版本回来了再闪一下. 内容更新后, 没法保证所有用户立刻看到最新版

对于这个博客来说, 正面效果远大于负面影响.

文章链接:

/zh/archive/blog-architecture-service-worker/

# 相关文章推荐