
使用 Next.js 和 Serwist 构建离线应用完整指南
LocallyTools 上线有一段时间了。虽然产品是利用浏览器的能力在本地处理数据,但总是有用户质疑它的可信度——"真的不会上传我的文件吗?""怎么证明数据不会泄露?"
这些质疑促使我实现了完全的离线化。想法很简单:如果应用能在断网状态下正常工作,那就彻底证明了数据确实没有离开用户设备。
在实现过程中,我发现离线化带来的好处远超预期:
建立用户信任:产品能够离线运行,直接证明了数据的安全性,用户不再需要"相信"我的承诺,而是可以亲自验证。
减轻服务器压力:资源缓存到本地后,大部分请求都走本地缓存,大幅减少了服务器端的压力和带宽成本。
避开 SEO 竞争:现在工具箱市场竞争激烈,大家都在拼 SEO 排名。但 LocallyTools 做成 PWA 后,用户可以直接保存到桌面使用,不需要每次都去搜索引擎找工具。这样既避开了激烈的 SEO 竞争,也大大增加了用户粘性。
当然,实现起来比我最初想的要复杂一些。分享一下我在 Next.js 应用中实现离线功能的经验。
技术栈
我用的是:
Next.js 15 (app router)
Pnpm
TypeScript
TailwindCSS 4.0
@serwist/next
我先试了 Workbox,但发现 Serwist 更容易配置,特别是配合 Next.js,这也是 Next.js 官方文档中推荐的一种方法
配置 Serwist
安装很简单:
pnpm add @serwist/next serwist
在 next.config.ts
中配置 Serwist:
// next.config.ts
import withSerwistInit from '@serwist/next';
import { execSync } from 'child_process';
// 使用 git commit hash 当作缓存版本号
const revision = execSync('git rev-parse HEAD', { encoding: 'utf8' })
.trim()
.slice(0, 7);
const withSerwist = withSerwistInit({
cacheOnNavigation: true,
reloadOnOnline: flase,
swSrc: 'app/sw.ts',
swDest: 'public/sw.js',
disable: process.env.NODE_ENV === 'development',
additionalPrecacheEntries: [
{ url: '/', revision },
// 可选
{ url: '/offline', revision },
],
});
export default withSerwist({
// 其他配置...
});
Serwist 核心实现
这是离线应用的核心部分。选错缓存策略,要么用户看到过期内容,要么离线时什么都加载不出来。
缓存策略选择
四种主要缓存策略的对比:
策略 | 工作原理 | 适用场景 | 优点 | 缺点 | 典型用例 |
CacheFirst | 优先读缓存,缓存没有才请求网络 | 静态资源 | 极快加载速度 | 可能显示过期内容 | 图片、字体、音视频 |
NetworkFirst | 优先请求网络,网络失败才读缓存 | 动态内容 | 数据始终新鲜 | 依赖网络连接 | API 接口、用户数据 |
StaleWhileRevalidate | 立即返回缓存,同时后台更新 | 平衡场景 | 快速响应 + 后台更新 | 实现复杂度较高 | CSS、JS 文件 |
NetworkOnly | 永远只请求网络,从不使用缓存 | 敏感操作 | 数据绝对新鲜 | 离线时不可用 | 登录、支付接口 |
Service Worker 配置
app/sw.ts
文件是定义缓存策略的地方
// app/sw.ts
import {
Serwist,
StaleWhileRevalidate,
ExpirationPlugin,
type RuntimeCaching,
type PrecacheEntry,
type SerwistGlobalConfig,
} from "serwist";
import { defaultCache } from "@serwist/next/worker";
declare global {
interface WorkerGlobalScope extends SerwistGlobalConfig {
// Change this attribute's name to your `injectionPoint`.
// `injectionPoint` is an InjectManifest option.
// See https://serwist.pages.dev/docs/build/configuring
__SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
}
}
declare const self: ServiceWorkerGlobalScope;
// 自定义缓存策略
const cacheStrategies: RuntimeCaching[] = [
{
matcher: ({ request, url: { pathname }, sameOrigin }) =>
request.headers.get("RSC") === "1" &&
request.headers.get("Next-Router-Prefetch") === "1" &&
sameOrigin &&
!pathname.startsWith("/api/"),
handler: new StaleWhileRevalidate({
cacheName: "pages-rsc-prefetch",
plugins: [
new ExpirationPlugin({
maxEntries: 200,
maxAgeSeconds: 24 * 60 * 60, // 24 hours
maxAgeFrom: "last-used",
}),
],
}),
},
{
matcher: ({ request, url: { pathname }, sameOrigin }) =>
request.headers.get("RSC") === "1" &&
sameOrigin &&
!pathname.startsWith("/api/"),
handler: new StaleWhileRevalidate({
cacheName: "pages-rsc",
plugins: [
new ExpirationPlugin({
maxEntries: 200,
maxAgeSeconds: 24 * 60 * 60, // 24 hours
maxAgeFrom: "last-used",
}),
],
}),
},
{
matcher: ({ request, url: { pathname }, sameOrigin }) =>
request.headers.get("Content-Type")?.includes("text/html") &&
sameOrigin &&
!pathname.startsWith("/api/"),
handler: new StaleWhileRevalidate({
cacheName: "pages",
plugins: [
new ExpirationPlugin({
maxEntries: 200,
maxAgeSeconds: 24 * 60 * 60, // 24 hours
maxAgeFrom: "last-used",
}),
],
}),
},
// 其他资源的缓存策略
// {
// matcher: /\.(?:mp4|webm)$/i,
// handler: new StaleWhileRevalidate({
// cacheName: 'static-video-assets',
// plugins: [
// new ExpirationPlugin({
// maxEntries: 32,
// maxAgeSeconds: 7 * 24 * 60 * 60,
// maxAgeFrom: 'last-used',
// }),
// new RangeRequestsPlugin(),
// ],
// }),
// },
...
];
const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST,
skipWaiting: true,
clientsClaim: true,
navigationPreload: true,
runtimeCaching: [...cacheStrategies, ...defaultCache],
// 可选
fallbacks: {
entries: [
{
url: "/offline",
matcher({ request }) {
return request.destination === "document";
},
},
],
},
});
serwist.addEventListeners();
在上面的配置中,特殊处理了与页面导航相关的特殊的请求:
RSC 预取 (Prefetch):
RSC: 1
+Next-Router-Prefetch: 1
,鼠标悬停在链接上时触发,Next.js 在后台提前加载。RSC 导航 (Navigation):
RSC: 1
,用户实际点击链接进行页面跳转时触发。HTML 文档 (Page Shell):
Content-Type: text/html
,首次访问或硬刷新页面触发。
使用 StaleWhileRevalidate
能更好的确保页面能快速加载,同时保持内容更新
插件说明:
ExpirationPlugin:管理缓存过期,防止存储空间无限增长
RangeRequestsPlugin:支持大文件的范围请求(如视频、音频)
设置 manifest 和 metadata
设置 manifest
在 app/manifest.ts
中定义你的 Web 应用的信息,它相当于你的 Web 应用的身份证,只有提供了这个清单文件,浏览器才会认为你的网站是"可安装的"(Installable),并向用户显示"添加到主屏幕"的提示。
在本地你可以通过访问 http://localhost:3000/manifest.webmanifest
检查设置是否正确
// app/manifest.ts
import type { MetadataRoute } from 'next';
export default async function manifest(): Promise<MetadataRoute.Manifest> {
return {
name: 'Your App Name',
short_name: 'Your App Name',
description: 'Your App Description',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#ffffff',
icons: [
{
src: '/android-chrome-192x192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'any',
},
{
src: '/android-chrome-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any',
},
{
src: '/android-chrome-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
orientation: 'portrait',
};
}
设置 metadata
更新你的根布局文件 app/layout.tsx
中的 metadata
对象, 并添加 manifest
文件的位置信息
// app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Your App Name',
description: 'Your App Description',
// 这行是关键
manifest: '/manifest.webmanifest',
};
Service Worker 调试
调试 Service Worker 主要是通过 Chrome DevTools (或 Edge DevTools)
Application
-> Service Workers
在该面板下有有一些选项和按钮,了解它们对于我们调试 Service Worker 很有帮助。

1. 顶部复选框
Offline
: 勾选后,Chrome 会模拟断网状态。所有网络请求都会立即失败,就像你拔掉了网线一样,对于测试 PWA 离线能力很有帮助。Update on reload
: 勾选后,每次刷新页面时,浏览器都会强制安装并激活最新的 SW,避免“代码不更新”的缓存问题。建议开发时勾选Bypass for network
,勾选后,会暂时完全绕过 SW,直接从网络加载所有资源,相当于快速“禁用”SW,这对排查问题很有帮助,常用来判断问题是出在 SW 缓存还是应用本身。Push
,Sync
,Periodic sync
: 如果你用到了推送通知或后台同步,可以在这里手动触发这些事件进行测试。
2. 操作按钮
Update
: 触发浏览器去服务器检查sw.js
文件是否有更新,如果有更新,浏览器就会开始下载并安装新版本。Unregister
: 注销当前的 Service Workerstart/stop
: 启动/停止当前 Service WorkerskipWaiting
: 当有一个新版本的 Service Worker 处于 waiting 状态时,会出现这个按钮,点击它会强制等待中的新 Service Worker 立即进入 activate 状态并接管页面。
Application
-> Cache Storage
该面板用来检查和管理管理缓存

检查
runtimeCaching
是否生效: 在app/sw.ts
中定义的cacheName
(e.g.,apis
,pages
) 会在这里以独立条目的形式出现。查看缓存内容
: 点击任何一个缓存条目,查看缓存的内容是否在这里正确出现。手动清理
: 右键点击 “Delete” 按钮,删除任何一个缓存条目
Network
使用该面板用来确认资源的来源,主要从它的关键信息栏 Size
来确认

Size: (ServiceWorker)
: 说明这个资源是从 Service Worker 缓存中提供的,没有走网络请求,这是确认你的缓存策略(特别是CacheFirst
和StaleWhileRevalidate
)是否按预期工作的直接证据。Size: (from memory cache)
或(from disk cache)
表明该资源来自浏览器的 HTTP 缓存,而不是 Service Worker 缓存。
勾选了 Network
面板下的 Disable cache
选项,这只会禁用浏览器的 HTTP 缓存,不会禁用 Service Worker 缓存,要禁用 Service Worker,请使用 Application 面板的 Bypass for network
可能踩坑点
HTTPS 限制:Service Worker 只能在 HTTPS 环境下工作,但
localhost
是个例外,如果你通过本地 IP (e.g., 192.168.31.10) 访问,SW 将不会注册。代码不更新问题: 99% 的情况是旧的 SW 的问题,解决办法:
检查 DevTools 的
Update on reload
是否勾选。去 Service Workers 面板手动 skipWaiting 或 Unregister 旧的 SW。
最终手段:在 Application -> Storage 面板,点击
Clear site data
,这会清除所有缓存、SW、IndexedDB 等,让你的网站回到“出厂设置”。
开发环境建议:当我们开发完 PWA 相关的功能后,强烈建议您禁用
serwist
,只有在需要专门调试 PWA 功能时再启用它,以避免缓存地狱问题,毕竟没有比 “我明明改了代码,为什么页面没变?!”这种事情更让人抓狂的了。
// next.config.ts
const withSerwist = withSerwistInit({
disable: process.env.NODE_ENV === "development",
...
});
避免强制刷新:设置
reloadOnOnline: false
,如果设置reloadOnOnline: true
,当用户从 offline 变成 online 之后,会触发了location.reload()
,页面会被强制刷新!假如用户此时正在填写表单,而你的网站又没有做实时保存功能,这时候用户辛辛苦苦填写的表单内容将瞬间消失了,因此建议您将reloadOnOnline
设置为false
。
// next.config.ts
const withSerwist = withSerwistInit({
reloadOnOnline: false,
...
});
开始你的离线之旅
如果你也想让应用离线可用,建议从这些步骤开始:
评估现有应用:哪些功能可以本地化?
从静态资源开始:图片、CSS、JS 先用 CacheFirst
逐步添加 API 缓存:根据数据重要性选择策略
测试各种网络状况:断网测试
离线功能不是炫技,而是对用户时间的尊重。当用户在地铁里、飞机上、或者网络不稳定时,依然能流畅使用你的应用,这种体验是无价的。
想感受真正的离线应用是什么样的?可以试试 LocallyTools
相关阅读
如果你对离线优先开发感兴趣,推荐阅读:
你在实现离线功能时遇到过什么坑? 或者对哪个缓存策略有疑问?在评论区分享你的经验,让我们一起完善离线应用的最佳实践。
如果这篇文章对你有帮助,别忘了点赞和分享! 👍