本篇依然来自于我们的 《前端周刊》 项目!
由团队成员 0bipinnata0 翻译,这位佬有技术追求、翻译风格精准细腻,还擅长挖掘原文背后的技术细节~
欢迎大家 进群 同该佬深度交流😁 以及持续追踪全球最新前端资讯!!
原文地址:Next.js PWA offline capability with Service Worker, no extra package

在本教程中,我们将使用 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 脚手架和样板开始
Shell
# 脚手架
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'
。
JavaScript
// next.config.js
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: 'export',
distDir: 'dist',
};
export default nextConfig;
在此操作之后,你应该有类似这样的结构。
PowerShell
.
├── 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
。
PowerShell
// version.ts, app-file-list.ts 将由脚本生成
version.ts |
service-worker.ts |-> service-worker.js
app-file-list.ts |
让我们开始创建脚手架。
PowerShell
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
:
Plain
mkdir scripts
touch scripts/generate.js
在那里我们有:
JavaScript
// 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
,你应该有:
TypeScript
// 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
。
TypeScript
// 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
。所以接下来是创建它们。
Plain
touch tsconfig.sw.json
touch webpack.config.js
在 tsconfig.sw.json
中我们有:
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
JSON
{
.
.
.
.
"exclude": [
"node_modules",
"src/sw"
]
}
这样,当我们构建我们的 Next.js 解决方案时,Service Worker 不会干扰。
在 webpack.config.js
中我们有:
JavaScript
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
中让我们添加几个新脚本:
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
。
在所有这些结束时,你应该有:
PowerShell
.
├── 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
│ └── 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
文件。
TypeScript
"use client";
import { useEffect } from "react";
export default function Template({ children }: Readonly<{ children: React.ReactNode; }>) {
useEffect(() => {
if (document.domain === "localhost") {
return;
}
if (!('serviceWorker' in navigator)) {
console.error("不支持 Service Worker");
return;
}
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 重新加载时发疯的注意事项。
然后我们实现了一个基本的 Next.js 解决方案,为 Service Worker 添加支持。为了做到这一点,我们使用了一个脚本来获取应用版本和文件列表,以便在 Service Worker 中使用。在 Service Worker 中,我们缓存了按版本和时间限定范围的文件列表,在获取时,如果资源在缓存中有匹配,我们使用它,否则我们从网络获取它。
最后,我们在 package.json
中用几个新脚本把所有东西整合在一起。
在此之后,你应该能够研究 Service Worker 和 Cache 文档并扩展当前示例。
如果你喜欢你读到的内容,我建议深入研究 skipWaiting
、postMessage
和 controllerchange
事件。