在本教程中,我们将使用 Service Worker 和缓存为 Next.js PWA 添加离线支持,无需额外的包,不使用 next-pwa
、next-offline
或 Serwist
,只使用纯 TypeScript。
为什么你可能想要这样做
首先,因为这不是一个超级复杂的任务,所以是一个完美的机会来玩转 Service Worker 和缓存,希望能消除一些在谈论这个主题时通常出现的压倒性感觉。
其次,你将拥有它。这意味着一旦你理解了几个关键概念,你就能够完全自定义你想要的所有细节。
但最重要的是,你可以减少应用程序的包依赖面,提高稳定性并减轻 Fire And Motion 的影响。
关于 Service Worker 你需要知道的一切
Service Worker 让很多人感到害怕,所以让我们尽量简化它。
一个 Service Worker 可以处于 4 种可能的状态:download
、install
、waiting
和 activate
。
这就像你邀请朋友来吃晚餐,你需要做一些菜:你会去买一些食材(下载),你会做饭(安装),然后你会把菜端给你的朋友,让他们享用(激活)。等待,就是做完饭和朋友拿到菜之间的所有时间。
现在,在 waiting
和 activate
之间有一个叫做 skipWaiting
的函数。
为了理解 skipWaiting
,让我们回到你邀请朋友吃晚餐的场景。
通常,你会等朋友吃完一道菜再上下一道菜。而 skipWaiting
就是当你很粗鲁地强迫你的朋友吃下一道菜,因为你要赶电影。
避免 Service Worker 创伤:为什么我的 Service Worker 没有激活
在你部署一个新的 Service Worker 后,你可能会看到它已安装并想:“我重新加载页面就能看到它工作了”,但然后它不工作,你开始哭泣。
现在,记住这一点:
- 你需要关闭 所有 带有你应用的标签页,更好的是,关闭浏览器
- 仅仅刷新(
Ctrl + R
)是不够的,使用硬刷新Ctrl + Shift + R
假设和计划
我们将使用带有 TypeScript 和 output: "export"
的 Next.js 设置,这意味着我们不会使用服务器端或类似的东西。只使用传统的静态文件。
然后我们将使用 缓存优先 策略缓存静态文件,这意味着在安装时我们会保存文件,在激活时我们会使用缓存的文件而不是从服务器获取。
此外,我们想要为数据使用某种 API,这意味着在缓存静态文件的同时,我们希望能够为其他所有内容访问网络。
最后一点是,我们将使用 package.json
中的版本号来限定缓存范围,这样当 Service Worker 激活时,我们会删除旧的缓存。
为了获取包版本和获取我们应用的文件列表,我们将创建几个自定义脚本。
让我们从 Next.js 脚手架和样板开始
# 脚手架 npx create-next-app@latest pwa-offline cd pwa-offline echo "v22.14.0" > .nvmrc
我们使用 nvm
锁定 Node.js,这样没有人会对我们使用的 Node.js 版本感到困惑,然后我们 create-next-app
。
为了创建仅静态文件,我们将在 next.config.js
中添加 output: 'export'
。
// next.config.js import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: 'export', distDir: 'dist', }; export default nextConfig;
在此操作之后,你应该有类似这样的结构。
. ├── README.md ├── eslint.config.mjs ├── next-env.d.ts ├── next.config.ts ├── package-lock.json ├── package.json ├── public │ ├── file.svg │ ├── globe.svg │ ├── next.svg │ ├── vercel.svg │ └── window.svg ├── src │ └── app │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ ├── page.module.css │ └── page.tsx └── tsconfig.json
Service Worker
所以,对于 Service Worker,如前所述,计划很简单:我们将有一个 Service Worker 缓存应用中的文件列表,按应用版本限定范围。为了把所有东西放在一起,我们将使用几个自定义脚本、tsc
和 Webpack 来输出 service-worker.js
。
graph LR A[version.ts] --> D[service-worker.js] B[service-worker.ts] --> D C[app-file-list.ts] --> D style A fill:#e1f5fe style C fill:#e1f5fe style B fill:#f3e5f5 style D fill:#e8f5e8
注意:
version.ts
和app-file-list.ts
将由脚本自动生成
让我们开始创建脚手架。
mkdir src/sw touch src/sw/service-worker.ts touch src/sw/app-file-list.ts touch src/sw/version.ts
现在让我们处理生成 version.ts
、app-file-list.ts
的脚本,我们称之为 generate.js
:
mkdir scripts touch scripts/generate.js
在那里我们有:
// generate.js const fs = require('fs'); const path = require('path'); /** * VERSION * * 1 - 获取 package 信息 * 2 - 导出一个常量 VERSION */ const pkg = require('../package.json'); fs.writeFileSync( './src/sw/version.ts', `export const VERSION = '${pkg.version}';\n` ); /** * APP FILE LIST * * 1 - 获取文件列表 * 2 - 导出一个常量 APP_FILE_LIST */ const folderPath = './dist'; function getAllFilesInDir(dir) { const entries = fs.readdirSync(dir, { withFileTypes: true }); return entries .flatMap((entry) => { const fullPath = path.join(dir, entry.name); return entry.isDirectory() ? getAllFilesInDir(fullPath) : [fullPath]; }); } fs.writeFileSync( './src/sw/app-file-list.ts', `export const APP_FILE_LIST = [ "/", ${getAllFilesInDir(folderPath).map(i => "'" + i.slice(4) + "'").join(", \n")} ];` );
如果你现在运行 node scripts/generate.js
,你应该有:
version.ts
export const VERSION = '0.3.0';
app-file-list.ts
export const APP_FILE_LIST = [ "/", '/404.html', '/_next/static/OQvQ0DovXF5ZWbrYv4Ncy/_buildManifest.js', '/_next/static/OQvQ0DovXF5ZWbrYv4Ncy/_ssgManifest.js', '/_next/static/chunks/4bd1b696-daa26928ff622cec.js', '/_next/static/chunks/684-703ae9b085b41bfc.js', '/_next/static/chunks/app/_not-found/page-88c6d7d182d9074a.js', '/_next/static/chunks/app/layout-ca036fe7ce1c23fd.js', '/_next/static/chunks/app/page-c3804bb37ebec7f8.js', '/_next/static/chunks/app/template-eca6ee34e977c582.js', '/_next/static/chunks/framework-f593a28cde54158e.js', '/_next/static/chunks/main-app-5054e05586ea1599.js', '/_next/static/chunks/main-c09f9dcdf4e52331.js', '/_next/static/chunks/pages/_app-da15c11dea942c36.js', '/_next/static/chunks/pages/_error-cc3f077a18ea1793.js', '/_next/static/chunks/polyfills-42372ed130431b0a.js', '/_next/static/chunks/webpack-8e1805b62d936603.js', '/_next/static/css/34fc136d66718394.css', '/_next/static/css/76338d74addccb7a.css', '/_next/static/media/569ce4b8f30dc480-s.p.woff2', '/_next/static/media/747892c23ea88013-s.woff2', '/_next/static/media/8d697b304b401681-s.woff2', '/_next/static/media/93f479601ee12b01-s.p.woff2', '/_next/static/media/9610d9e46709d722-s.woff2', '/_next/static/media/ba015fad6dcf6784-s.woff2', '/favicon.ico', '/file.svg', '/globe.svg', '/index.html', '/index.txt', '/next.svg', '/vercel.svg', '/window.svg' ];
现在我们有了 app-file-list.ts
和 version.ts
,我们可以专注于 service-worker.ts
。
// service-worker.ts import { VERSION } from "./version"; import { APP_FILE_LIST } from "./app-file-list"; const sw: ServiceWorkerGlobalScope = self as unknown as ServiceWorkerGlobalScope; /** * SW: INSTALL * * 1. 打开按版本号限定范围的缓存 * 2. 保存列表中的文件 */ async function onInstall() { console.info("SW: Install: " + VERSION); const cache = await caches.open(VERSION); return cache.addAll(APP_FILE_LIST); } /** * SW: ACTIVATE * * 1. 删除不是当前版本的缓存 */ async function onActivate() { console.info("SW: Activate: " + VERSION); const cacheNames = await caches.keys(); return Promise.all( cacheNames .filter(function (cacheName) { return cacheName !== VERSION; }) .map(function (cacheName) { return caches.delete(cacheName); }) ); } /** * SW: FETCH * * 这是当 Service Worker 拦截 HTTP 请求时 * * 如果路径在缓存中,我们使用它 * 否则我们继续请求 */ async function onFetch(event: FetchEvent) { const cache = await caches.open(VERSION); const url = new URL(event.request.url); const cacheResource = url.pathname; const response = await cache.match(cacheResource); return response || fetch(event.request); } sw.addEventListener('install', event => event.waitUntil(onInstall())); sw.addEventListener('activate', event => event.waitUntil(onActivate())); sw.addEventListener('fetch', event => event.respondWith(onFetch(event)));
要实际构建这个,我们仍然需要一个 tsconfig
和一个 webpack.config
。所以接下来是创建它们。
touch tsconfig.sw.json touch webpack.config.js
在 tsconfig.sw.json
中我们有:
{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "lib": ["DOM", "webworker", "ES2020"], "outDir": "./dist", "strict": true, "esModuleInterop": true, "moduleResolution": "node", "skipLibCheck": true }, "include": ["./src/sw/service-worker.ts"] }
这基本上是说,获取 src/sw/service-worker.ts
并将结果放在 dist
中。由于它是一个 Web Worker,在 lib
中我们也会添加 "webworker"
。
而在 tsconfig.json
中我们将添加 exclude: src/sw
:
{ // ... 其他配置 "exclude": [ "node_modules", "src/sw" ] }
这样,当我们构建我们的 Next.js 解决方案时,Service Worker 不会干扰。
在 webpack.config.js
中我们有:
const path = require('path'); module.exports = { entry: './src/sw/service-worker.ts', module: { rules: [ { test: /\.ts$/, use: { loader: 'ts-loader', options: { configFile: 'tsconfig.sw.json' } }, exclude: /node_modules/ } ] }, resolve: { extensions: ['.ts', '.js'] }, output: { filename: 'service-worker.js', path: path.resolve(__dirname, 'dist') } };
最后一步,在 package.json
中让我们添加几个新脚本:
{ "scripts": { "x--------------------NEXT------------------------x": "Next 命令", "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint", "x--------------------SW------------------------x": "Service Worker", "build:sw:generate": "node scripts/generate.js", "build:sw": "webpack", "x--------------------VERSION------------------------x": "版本升级", "ver:patch": "npm version patch", "ver:minor": "npm version minor", "ver:major": "npm version major", "x--------------------RELEASE------------------------x": "发布", "release:patch": "npm run ver:patch && npm run build && npm run build:sw:generate && npm run build:sw", "release:minor": "npm run ver:minor && npm run build && npm run build:sw:generate && npm run build:sw", "release:major": "npm run ver:major && npm run build && npm run build:sw:generate && npm run build:sw" } }
有了这个,你应该能够进行发布,例如 npm run release:minor
,它将升级 package.json
中的 package 版本,然后构建 Next.js,然后生成 app-file-list.ts
和 version.ts
,最后创建 service-worker.js
。
在所有这些结束时,你应该有:
. ├── README.md ├── eslint.config.mjs ├── next-env.d.ts ├── next.config.ts ├── package-lock.json ├── package.json ├── public/ │ ├── file.svg │ ├── globe.svg │ ├── next.svg │ ├── vercel.svg │ └── window.svg ├── scripts/ │ └── generate.js ├── src/ │ ├── app/ │ │ ├── favicon.ico │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── page.module.css │ │ ├── page.tsx │ │ └── template.tsx │ └── sw/ │ ├── app-file-list.ts │ ├── service-worker.ts │ └── version.ts ├── tsconfig.json ├── tsconfig.sw.json └── webpack.config.js
让我们在 Next.js 中导入 Service Worker
此时,我们应该能够在我们的 dist
文件夹中有我们的 Next.js 应用和我们的 service-worker.js
,但我们仍然需要注册 Service Worker 以便使用它。
为了做到这一点,我们将在 app
文件夹中添加一个 template.tsx
文件。
"use client"; import { useEffect } from "react"; export default function Template({ children }: Readonly<{ children: React.ReactNode; }>) { useEffect(() => { // 在本地开发环境中跳过 Service Worker 注册 if (document.domain === "localhost") { return; } // 检查浏览器是否支持 Service Worker if (!('serviceWorker' in navigator)) { console.error("不支持 Service Worker"); return; } // 注册 Service Worker navigator.serviceWorker .register("/service-worker.js") .then((registration) => { console.log("Service Worker 注册成功:", registration); if (registration.installing) { console.log("SW 状态:安装中"); return; } if (registration.waiting) { console.log("SW 状态:等待中"); return; } if (registration.active) { console.log("SW 状态:激活"); return; } }) .catch((error) => { console.error(`Service Worker 注册失败:${error}`); }); }, []); return <>{children}</>; }
总结
在本教程中,我们深入了解了 Service Worker 的核心概念和实现方式:
🔑 关键概念
- Service Worker 状态:
download
→install
→waiting
→activate
- 缓存策略:使用缓存优先策略提供离线支持
- 版本管理:基于
package.json
版本号管理缓存作用域
🛠️ 实现步骤
- 脚本生成:创建自动生成
version.ts
和app-file-list.ts
的脚本 - Service Worker 开发:实现安装、激活和请求拦截逻辑
- 构建配置:配置 TypeScript 和 Webpack 构建流程
- Next.js 集成:在应用中注册和使用 Service Worker
🚀 下一步
完成本教程后,你可以:
- 深入研究 Service Worker API 和 Cache API 文档
- 探索
skipWaiting
、postMessage
和controllerchange
事件的高级用法 - 根据具体需求自定义缓存策略
📚 参考资源
官方文档
缓存策略
- 缓存优先,回退到网络 - Chrome 开发者文档
相关工具
- next-pwa - Next.js PWA 插件
- next-offline - Next.js 离线支持
- Serwist - Service Worker 工具库
延伸阅读
- Fire And Motion – Joel on Software - 软件开发哲学