给 Astro 静态博客开启 PWA

这次给博客补了一层 PWA 支持。

目标很简单:访问网站时,浏览器能识别它是一个可安装的 Web App;安装到桌面或手机桌面后,有独立窗口、站点图标和主题色;网络不稳定时,已经访问过的页面和静态资源也尽量能打开。

这个博客是一个很轻的 Astro 静态站点。为了不因为一个功能引入整套构建插件,我没有安装 @vite-pwa/astro 或 Workbox,而是用浏览器原生的三件东西完成:

  1. Web App Manifest。
  2. PWA 图标。
  3. service worker。

Manifest 负责告诉浏览器“这是一个应用”

PWA 的第一步是提供 manifest.webmanifest。它描述应用名称、启动地址、显示方式、主题色和图标。

我把 manifest 做成 Astro endpoint,而不是直接写死一个 public/manifest.webmanifest 文件。这样站点名、描述和语言都能继续复用现有的 site 配置,以后改站点信息时不用多改一份。

import { site } from '../theme/site';

export const GET = () => {
  const manifest = {
    name: site.title,
    short_name: site.shortTitle,
    description: site.description,
    lang: site.lang,
    id: '/',
    start_url: '/',
    scope: '/',
    display: 'standalone',
    orientation: 'portrait-primary',
    background_color: '#ffffff',
    theme_color: '#171717'
  };

  return new Response(JSON.stringify(manifest, null, 2), {
    headers: {
      'Content-Type': 'application/manifest+json; charset=utf-8'
    }
  });
};

这里最关键的是 display: standalone。它告诉浏览器:安装后尽量用独立应用窗口打开,而不是像普通网页一样显示完整浏览器 UI。

我还加了两个快捷入口:博客列表和搜索页。支持 shortcuts 的系统可以在应用图标的快捷菜单里直接进入这些页面。

图标要准备几种尺寸

PWA 不能只依赖 favicon.ico。浏览器安装应用时会读取 manifest 里的 icons,常见尺寸至少需要 192 和 512。

这个博客本来就有一张 512x512 的站点 logo,所以我直接基于它生成了几份图标:

public/images/theme/pwa-icon-192.png
public/images/theme/pwa-icon-512.png
public/images/theme/pwa-maskable-icon-512.png
public/images/theme/pwa-apple-touch-icon.png

其中 maskable 图标是给 Android 这类会裁切图标形状的平台准备的。它的重点不只是“正好 512x512”,而是图标主体要留出安全边距,否则圆角、圆形或水滴形裁切时可能会把主体切掉。

Manifest 里的配置大概是这样:

icons: [
  {
    src: '/images/theme/pwa-icon-192.png',
    sizes: '192x192',
    type: 'image/png',
    purpose: 'any'
  },
  {
    src: '/images/theme/pwa-icon-512.png',
    sizes: '512x512',
    type: 'image/png',
    purpose: 'any'
  },
  {
    src: '/images/theme/pwa-maskable-icon-512.png',
    sizes: '512x512',
    type: 'image/png',
    purpose: 'maskable'
  }
]

页面需要把 Manifest 挂出来

Manifest 不会自动生效,需要在每个页面的 head 里挂上链接。这里的 head 指页面的 HTML head 区域。

这个博客所有页面都经过 BaseLayout.astro,所以只要在基础布局里加一次即可。

<link rel="apple-touch-icon" href="/images/theme/pwa-apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />

同时补上移动端应用名和安装模式相关 meta:

<meta name="application-name" content={site.shortTitle} />
<meta name="apple-mobile-web-app-title" content={site.shortTitle} />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />

原来已经有浅色和深色主题色:

<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#171717" media="(prefers-color-scheme: dark)" />

这部分可以继续保留。安装后的状态栏、地址栏和启动背景会更贴近当前站点的视觉风格。

Service worker 负责缓存和离线兜底

有 manifest 只能让浏览器知道“这个网站像一个应用”。真正让它具备离线能力的是 service worker。

我把 sw.js 放在 public 目录下,这样构建后会直接出现在站点根路径:

/sw.js

service worker 的作用域和文件所在路径有关。放在根路径时,它可以控制整个站点;如果放到 /assets/sw.js,默认就只能控制 /assets/ 下面的请求。

这次的缓存策略分成两类。

页面导航走网络优先:

const networkFirst = async (request) => {
  try {
    const response = await fetch(request);
    await putInCache(RUNTIME_CACHE, request, response);
    return response;
  } catch {
    const cached = await caches.match(request);
    return cached ?? caches.match('/');
  }
};

原因是博客文章会更新,我不希望读者在线时还一直看到旧页面。网络正常就拿最新页面;网络失败时,再退回缓存。

静态资源走 stale-while-revalidate:

const staleWhileRevalidate = async (request, event) => {
  const cached = await caches.match(request);
  const fetched = fetch(request)
    .then((response) => putInCache(RUNTIME_CACHE, request, response))
    .catch(() => undefined);

  if (cached) {
    event.waitUntil(fetched);
    return cached;
  }

  return (await fetched) ?? Response.error();
};

这适合图片、CSS、脚本、字体和 manifest。读者可以先看到缓存里的资源,同时 service worker 在后台尝试更新缓存。

只处理同源 GET 请求

service worker 很容易写得过宽。这个博客里没有必要接管所有请求,所以我加了一个简单限制:

const isSameOriginGet = (request) => {
  const url = new URL(request.url);
  return request.method === 'GET' && url.origin === self.location.origin;
};

这样它不会缓存表单提交、第三方资源或跨域请求。对于一个静态博客来说,这个边界比较清楚,也更容易排查问题。

部署时要让 sw.js 及时更新

service worker 本身也会被浏览器缓存。如果服务器给 /sw.js 设置了很长的缓存时间,更新逻辑可能会延迟很久才生效。

所以我加了一个 _headers:

/sw.js
  Cache-Control: no-cache

/manifest.webmanifest
  Cache-Control: public, max-age=3600

sw.js 使用 no-cache,浏览器每次都会和服务器确认它有没有变化。Manifest 可以短缓存一会儿,因为它不需要像 service worker 一样严格及时更新。

怎么验证

配置完成后,先跑构建:

pnpm build

构建产物里应该能看到这些文件:

dist/manifest.webmanifest
dist/sw.js
dist/images/theme/pwa-icon-192.png
dist/images/theme/pwa-icon-512.png
dist/images/theme/pwa-maskable-icon-512.png
dist/images/theme/pwa-apple-touch-icon.png

再用浏览器打开生产预览,DevTools 里看两个地方:

  • Application / Manifest:确认名称、图标、display: standalone 正常。
  • Application / Service Workers:确认 /sw.js 注册成功。

如果要验证离线兜底,可以先访问首页和几篇文章,再切到 Offline 刷新。访问过的页面应该能从缓存打开;没访问过的页面至少会回退到首页。

这次没有解决什么

这次只是给静态博客补一个轻量 PWA 层,不是完整的离线内容系统。

它不会在构建时把所有文章和所有 hashed 资源自动列进预缓存清单。也就是说,一篇文章只有被访问过,才会进入运行时缓存。如果以后想做更完整的离线阅读,可以再引入 Workbox 或 @vite-pwa/astro,让构建过程自动生成 precache manifest。

但对这个博客当前的需求来说,手写配置已经够用:没有新增依赖,配置透明,缓存策略也容易理解。它让网站具备了基本的可安装体验,同时不把发布流程变复杂。