Next.js 原生实现 PWA 离线能力

本篇依然来自于我们的 《前端周刊》 项目!

由团队成员 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 种可能的状态: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 脚手架和样板开始

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.tsapp-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.tsversion.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.tsversion.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 WorkerCache 文档并扩展当前示例。

如果你喜欢你读到的内容,我建议深入研究 skipWaitingpostMessagecontrollerchange 事件。

链接

相关推荐
喜欢你,还有大家21 分钟前
FTP文件传输服务
linux·运维·服务器·前端
该用户已不存在24 分钟前
你没有听说过的7个Windows开发必备工具
前端·windows·后端
Bi36 分钟前
Dokploy安装和部署项目流程
运维·前端
普通网友37 分钟前
前端安全攻防:XSS, CSRF 等防范与检测
前端·安全·xss
携欢40 分钟前
PortSwigger靶场之Reflected XSS into attribute with angle brackets HTML-encoded通关秘籍
前端·xss
小爱同学_43 分钟前
React知识:useState和useRef的使用
前端·react.js
再学一点就睡1 小时前
双 Token 认证机制:从原理到实践的完整实现
前端·javascript·后端
wallflower20201 小时前
滑动窗口算法在前端开发中的探索与应用
前端·算法
蚂蚁绊大象1 小时前
flutter第二话题-布局约束
前端
龙在天1 小时前
我是前端,scss颜色函数你用过吗?
前端