2401 words
12 minutes
使用 Service Worker 为 Next.js PWA 添加离线支持

在本教程中,我们将使用 Service Worker 和缓存为 Next.js PWA 添加离线支持,无需额外的包,不使用 next-pwanext-offlineSerwist,只使用纯 TypeScript。

为什么你可能想要这样做#

首先,因为这不是一个超级复杂的任务,所以是一个完美的机会来玩转 Service Worker 和缓存,希望能消除一些在谈论这个主题时通常出现的压倒性感觉。

其次,你将拥有它。这意味着一旦你理解了几个关键概念,你就能够完全自定义你想要的所有细节。

但最重要的是,你可以减少应用程序的包依赖面,提高稳定性并减轻 Fire And Motion 的影响。

关于 Service Worker 你需要知道的一切#

Service Worker 让很多人感到害怕,所以让我们尽量简化它。

一个 Service Worker 可以处于 4 种可能的状态:downloadinstallwaitingactivate

这就像你邀请朋友来吃晚餐,你需要做一些菜:你会去买一些食材(下载),你会做饭(安装),然后你会把菜端给你的朋友,让他们享用(激活)。等待,就是做完饭和朋友拿到菜之间的所有时间。

现在,在 waitingactivate 之间有一个叫做 skipWaiting 的函数。

为了理解 skipWaiting,让我们回到你邀请朋友吃晚餐的场景。

通常,你会等朋友吃完一道菜再上下一道菜。而 skipWaiting 就是当你很粗鲁地强迫你的朋友吃下一道菜,因为你要赶电影。

避免 Service Worker 创伤:为什么我的 Service Worker 没有激活#

在你部署一个新的 Service Worker 后,你可能会看到它已安装并想:“我重新加载页面就能看到它工作了”,但然后它不工作,你开始哭泣。

现在,记住这一点:

  1. 你需要关闭 所有 带有你应用的标签页,更好的是,关闭浏览器
  2. 仅仅刷新(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.tsapp-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.tsapp-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.tsversion.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.tsversion.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 状态downloadinstallwaitingactivate
  • 缓存策略:使用缓存优先策略提供离线支持
  • 版本管理:基于 package.json 版本号管理缓存作用域

🛠️ 实现步骤#

  1. 脚本生成:创建自动生成 version.tsapp-file-list.ts 的脚本
  2. Service Worker 开发:实现安装、激活和请求拦截逻辑
  3. 构建配置:配置 TypeScript 和 Webpack 构建流程
  4. Next.js 集成:在应用中注册和使用 Service Worker

🚀 下一步#

完成本教程后,你可以:

  • 深入研究 Service Worker APICache API 文档
  • 探索 skipWaitingpostMessagecontrollerchange 事件的高级用法
  • 根据具体需求自定义缓存策略

📚 参考资源#

官方文档#

缓存策略#

相关工具#

延伸阅读#

banner

加入我们,共译前端好文

我们不靠算法推荐,而是靠前端开发者对技术趋势的敏锐、对知识分享的热爱,手动为你筛选每周全球社区中最新、最热、最值得关注的前端文章。@海外前端动态情报源清单(持续更新)

qrcode
使用 Service Worker 为 Next.js PWA 添加离线支持
https://0bipinnata0.my/posts/weekly-translate/nextjs-offline-service-worker/
Author
0bipinnata0
Published at
2025-08-18 23:55:21