这次给博客补了一层 PWA 支持。
目标很简单:访问网站时,浏览器能识别它是一个可安装的 Web App;安装到桌面或手机桌面后,有独立窗口、站点图标和主题色;网络不稳定时,已经访问过的页面和静态资源也尽量能打开。
这个博客是一个很轻的 Astro 静态站点。为了不因为一个功能引入整套构建插件,我没有安装 @vite-pwa/astro 或 Workbox,而是用浏览器原生的三件东西完成:
- Web App Manifest。
- PWA 图标。
- 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。
但对这个博客当前的需求来说,手写配置已经够用:没有新增依赖,配置透明,缓存策略也容易理解。它让网站具备了基本的可安装体验,同时不把发布流程变复杂。