Next.js 15 渐进式 Web 应用 (PWA):Workbox 离线缓存、Serwist 与 Service Worker 生命周期

随着 Web 平台底层能力的不断拓展,现代浏览器已经能够提供媲美原生操作系统(iOS / Android / macOS)的应用级体验。渐进式 Web 应用(Progressive Web App, PWA) 凭借桌面图标添加、全屏沉浸式运行、极速离线可访问性以及后台异步数据同步能力,正在成为现代全栈架构的核心拼图。

然而,在 Next.js 15 的 App Router 架构下落地 PWA 绝非简单地扔一个 manifest.json 即可完成。由于 React Server Components (RSC) 的 Flight Payload 流式传输、页面路由段切片以及不断迭代的静态哈希资源,传统旧时代的 next-pwa 插件早已年久失修并与 Next.js 15 严重脱节。现代工业级方案全面拥抱 Serwist(基于 Google Workbox 内核现代化改造的下一代 Service Worker 架构)。本文将深入 Service Worker 生命周期状态机、预缓存清册生成机制、多级运行时缓存策略与离线数据队列同步,全景揭秘 Next.js 15 的 PWA 生产级落地之道。


1. Service Worker 核心生命周期状态机剖析

Service Worker 是一种运行在浏览器后台的独立线程(Worker),它完全独立于当前网页的 DOM 上下文,充当浏览器与目标网络服务器之间的可编程网络代理(Network Proxy)。理解它的五大生命周期阶段,是解决“为什么发布了新版本用户却看不到更新”的根本钥匙。

+-------------------------------------------------------------------------+
|                  Service Worker 生命周期与事件流转                       |
+-------------------------------------------------------------------------+

  [1. 浏览器注册 (register)]
             |
             v
  [2. 下载与解析 (Installing)] ---> 触发 install 事件:
             |                      执行预缓存 (CacheStorage.addAll)
             v
  [3. 安装完成 (Installed / Waiting)]
             |
             +---> 存在旧 SW 在控制页面? ---> 挂起等待 (除非触发 skipWaiting)
             |
             v
  [4. 激活接管 (Activating)]   ---> 触发 activate 事件:
             |                      清理旧版本过期缓存 (CacheStorage.delete)
             v                      执行 clients.claim() 立即接管未受控页面
  [5. 运行受控 (Active / Running)]
             |
             +---> 拦截 fetch 请求: 依据 Workbox 策略返回缓存或发起网络
             +---> 监听 push 事件: 弹出系统桌面级通知
             +---> 监听 sync 事件: 后台离线队列网络恢复时自动重放

1.1 安装期(Installing)与预缓存清册(Precache Manifest)

在 install 事件触发时,Service Worker 会拉取构建期生成的资源清册,并将 HTML、核心 JS Chunks、CSS 资产预先填充至浏览器的 CacheStorage 中。只要清册中有任何一个资源拉取失败(如 404 错误),整个安装过程就会直接宣告失败,Service Worker 将被丢弃,旧版 SW 继续坚挺运行,保障网站绝对不至于陷入死锁。

1.2 激活期(Activating)与无感更新接管

默认情况下,即使新版 SW 安装成功,为了防止同一页面不同模块的代码版本产生跨协议撕裂,浏览器会强制新 SW 进入 waiting 状态,直到用户关闭所有已打开的标签页。 在现代化 Web 开发中,我们通过在配置中注入 skipWaiting() 与 clientsClaim(),让新版 SW 在激活瞬间强制接管所有当前活动的客户端页面,并向前端派发 SW_UPDATED 广播,提示用户“发现新版本,点击平滑刷新”。


2. Next.js 15 与 Serwist 构建流水线深度集成

Serwist 是目前支持 Next.js 15 App Router、TypeScript 与 Turbopack 最完美的 Workbox 继承者。

构建管线流转:
1. 执行 next build (或 next build + static export)
2. Serwist Webpack 插件扫描 out/ 或 .next/ 产物目录
3. 计算所有 CSS、JS chunk 与静态图像的 Revision Hash
4. 将 self.__SW_MANIFEST 占位符注入 service-worker.ts 并打包输出为 public/sw.js

2.1 Service Worker 核心配置与策略定义

在 src/app/sw.ts 中编写工业级的 Service Worker 源码:

// src/app/sw.ts
import { defaultCache } from '@serwist/next/worker';
import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist';
import { Serwist, NetworkFirst, StaleWhileRevalidate, CacheFirst, ExpirationPlugin } from 'serwist';

// 声明全局 Service Worker 环境上下文
declare global {
  interface WorkerGlobalScope extends SerwistGlobalConfig {
    __SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
  }
}

declare const self: ServiceWorkerGlobalScope;

const serwist = new Serwist({
  // 1. 构建期注入的静态资产预缓存清册
  precacheEntries: self.__SW_MANIFEST,
  // 2. 新版本立即跳过等待接管控制权
  skipWaiting: true,
  clientsClaim: true,
  navigationPreload: true,
  // 3. 多级分层运行时缓存策略 (Runtime Caching)
  runtimeCaching: [
    // 策略 A: 针对静态哈希资产 (_next/static/*) -> 采用强缓存 CacheFirst
    {
      matcher: ({ url }) => url.pathname.startsWith('/_next/static/'),
      handler: new CacheFirst({
        cacheName: 'next-static-assets',
        plugins: [
          new ExpirationPlugin({
            maxEntries: 64,
            maxAgeSeconds: 30 * 24 * 60 * 60, // 30 天缓存
          }),
        ],
      }),
    },
    // 策略 B: 针对文章动态路由及 RSC Flight Payload -> 采用 Stale-While-Revalidate
    {
      matcher: ({ url, request }) =>
        url.pathname.startsWith('/posts/') || request.headers.get('RSC') === '1',
      handler: new StaleWhileRevalidate({
        cacheName: 'articles-rsc-cache',
        plugins: [
          new ExpirationPlugin({
            maxEntries: 100,
            maxAgeSeconds: 7 * 24 * 60 * 60, // 7 天
          }),
        ],
      }),
    },
    // 策略 C: 针对外部 CDN 字体与矢量图标 -> 采用 CacheFirst
    {
      matcher: ({ url }) =>
        url.origin.includes('fonts.googleapis.com') ||
        url.origin.includes('fonts.gstatic.com'),
      handler: new CacheFirst({
        cacheName: 'google-fonts',
        plugins: [
          new ExpirationPlugin({
            maxEntries: 20,
            maxAgeSeconds: 365 * 24 * 60 * 60, // 1 年
          }),
        ],
      }),
    },
    // 策略 D: 针对后台 API 接口与遥测遥控 -> 采用 NetworkFirst 并设置 3 秒超时回退
    {
      matcher: ({ url }) => url.pathname.startsWith('/api/'),
      handler: new NetworkFirst({
        cacheName: 'api-data-cache',
        networkTimeoutSeconds: 3,
        plugins: [
          new ExpirationPlugin({
            maxEntries: 50,
            maxAgeSeconds: 24 * 60 * 60,
          }),
        ],
      }),
    },
    ...defaultCache,
  ],
  // 4. 全局网络彻底中断时的离线后备兜底页
  fallbacks: {
    entries: [
      {
        url: '/offline',
        matcher({ request }) {
          return request.destination === 'document';
        },
      },
    ],
  },
});

serwist.addEventListeners();

3. Web App Manifest 规范与现代原生安装体验

为了让移动端 Safari、Chrome 以及桌面端 Edge/Chrome 弹出优雅的“安装为应用程序”引导弹窗,必须在 src/app/manifest.ts 中配置合法的标准化元数据。

// src/app/manifest.ts
import { MetadataRoute } from 'next';

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: 'MonsterAI 极客技术中枢',
    short_name: 'MonsterAI',
    description: '深入 Rust、Flutter 与 Next.js 底层核心的现代化技术博客与运维管理大屏',
    start_url: '/',
    display: 'standalone', // 独立原生窗口显示,移除浏览器地址栏与导航条
    background_color: '#090d16',
    theme_color: '#06b6d4',
    orientation: 'portrait-primary',
    icons: [
      {
        src: '/icons/icon-192x192.png',
        sizes: '192x192',
        type: 'image/png',
        purpose: 'maskable',
      },
      {
        src: '/icons/icon-512x512.png',
        sizes: '512x512',
        type: 'image/png',
        purpose: 'any',
      },
    ],
  };
}

3.1 客户端自定义安装引导弹窗(Custom Install Prompt)

原生浏览器的地址栏安装图标非常隐蔽。我们可以在前端捕获 beforeinstallprompt 事件,并在合适时机向用户展示高转化的安装横幅:

// src/components/PwaInstallBanner.tsx
'use client';

import React, { useEffect, useState } from 'react';

export function PwaInstallBanner() {
  const [deferredPrompt, setDeferredPrompt] = useState<any>(null);
  const [showBanner, setShowBanner] = useState(false);

  useEffect(() => {
    const handler = (e: Event) => {
      e.preventDefault(); // 阻止浏览器默认的沉底提示条
      setDeferredPrompt(e);
      setShowBanner(true);
    };

    window.addEventListener('beforeinstallprompt', handler);
    return () => window.removeEventListener('beforeinstallprompt', handler);
  }, []);

  const handleInstallClick = async () => {
    if (!deferredPrompt) return;
    deferredPrompt.prompt();
    const { outcome } = await deferredPrompt.userChoice;
    if (outcome === 'accepted') {
      setShowBanner(false);
    }
    setDeferredPrompt(null);
  };

  if (!showBanner) return null;

  return (
    <div className="fixed bottom-6 right-6 p-4 rounded-xl bg-slate-900/95 border border-cyan-500/30 shadow-2xl flex items-center gap-4 z-50">
      <div>
        <h4 className="text-white font-bold text-sm">安装 MonsterAI 桌面版</h4>
        <p className="text-xs text-slate-400">支持极速秒开、离线无网阅读与原生独立窗口。</p>
      </div>
      <button
        onClick={handleInstallClick}
        className="px-3 py-1.5 bg-cyan-500 hover:bg-cyan-400 text-slate-950 font-bold rounded-lg text-xs"
      >
        立即安装
      </button>
    </div>
  );
}

4. 离线数据突变排队:Background Sync 与 IndexedDB

在没有网络连接时,用户依然可能发表评论或修改个人偏好设置。传统的 Web 页面在断网时直接抛出 Failed to fetch 红色错误,体验极其糟糕。 先进的 PWA 离线架构:利用 IndexedDB 暂存突变,并在 Service Worker 触发 Background Sync 自动回放。

[用户点击提交表单 (无网络连接)]
              |
              v
[检测到 navigator.onLine === false]
              |
              v
[写入浏览器 IndexedDB 离线任务表 (offline_mutations)]
              |
              v
[向 Service Worker 注册 sync 标签: registration.sync.register('replay-comments')]
              |
         (用户关闭网页,离开电脑)
              |
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    ~  等待网络重新恢复连接 (Online)  ~
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
              |
              v
[浏览器后台唤醒 Service Worker]
              |
[触发 sync 事件: 读取 IndexedDB 队列并重新逐条发起 HTTP POST 提交]
              |
[写入成功 -> 弹出系统通知: "您的离线评论已成功同步发布!"]

5. 生产级基准性能与可靠性验收矩阵

评估维度传统无 PWA 站点简单 Manifest 站点完整 Serwist 现代 PWA 架构
断网/离线访问表现浏览器默认恐龙小游戏 🦖 (无法打开)依然白屏崩溃 (无缓存)🟢 页面正常浏览,离线后备就绪
二次访问加载速度 (TTI)850ms ~ 1,400ms600ms ~ 900ms🟢 45ms ~ 120ms (直接命中本地缓存)
弱网 (Slow 3G) 体验请求超时报错,整页挂死严重卡顿🟢 SWR 毫秒级展示旧数据,后台静默重校验
移动端首屏占用流量每次导航全量重复拉取 JS/CSS重复消耗流量🟢 零重复流量消耗 (单月流量节省 85%)

PWA 工业级交付自检表

[✓] 1. HTTPS 安全基线: Service Worker 必须且仅在 HTTPS 或 localhost 运行。
[✓] 2. 清册尺寸控制: 预缓存清单总量严禁超过 5MB,大图与视频必须走运行时缓存。
[✓] 3. 缓存版本淘汰: 每次构建必须生成独立的 Revision,防止用户端缓存雪崩不更新。
[✓] 4. 离线后备页面: 必须注册 /offline 兜底页,杜绝断网时出现浏览器原生崩溃画面。
[✓] 5. Lighthouse 评分: Chrome DevTools PWA 审计项必须全部亮起绿色对勾 (100 分)。

通过将 Next.js 15 现代 App Router 与基于 Serwist 的 Service Worker 深度融合,我们让传统的 Web 站点一跃具备了原生客户端级的确定性响应速度与永不离线的稳健可用性。