Next.js 14+ 完整实现:Service Worker 缓存非HTML静态资源(含更新/CDN适配)

整体流程

  1. 创建 Service Worker 核心文件(public/sw.js)
  2. 配置 next.config.js:打包时注入版本号、复制SW文件
  3. 客户端注册 Service Worker(App Router/Pages Router 兼容)
  4. 打包部署 & 验证缓存效果

前置说明

  • 适配 Next.js 14+(App Router/Pages Router 通用)
  • 只缓存:JS/CSS/图片/字体/CDN资源(排除HTML/API)
  • 自动处理:版本号更新、缓存过期、CDN资源变化

步骤1:创建 Service Worker 文件(public/sw.js)

在项目根目录的 public 文件夹下新建 sw.js(Next.js 会将public目录文件原样复制到打包后的根目录):

// public/sw.js
// 打包时会自动替换 __BUILD_VERSION__ 为 Git Commit + 构建时间
const CACHE_VERSION = '__BUILD_VERSION__';
const CACHE_NAME = `next-static-cache-${CACHE_VERSION}`;
// 缓存过期时间:7天(单位:秒)
const CACHE_EXPIRE_SECONDS = 7 * 24 * 60 * 60;
// 只缓存非HTML静态资源(含CDN)
const STATIC_ASSET_REG = /\.(js|css|png|webp|jpg|jpeg|woff2|gif|svg)$/;
// 排除HTML(Next.js的路由如/、/dashboard等)
const HTML_REG = /(\/$)|(\.html$)/;
// 你的CDN域名(替换为实际值)
const CDN_DOMAINS = ['cdn.your-domain.com', 'static.your-domain.com'];

// 1. SW安装:跳过等待,直接激活
self.addEventListener('install', (e) => {
  console.log(`[SW] 安装中,版本:${CACHE_VERSION}`);
  e.waitUntil(self.skipWaiting());
});

// 2. SW激活:清理旧缓存 + 接管所有页面
self.addEventListener('activate', async (e) => {
  e.waitUntil(
    Promise.all([
      self.clients.claim(), // 立即接管所有页面
      // 清理逻辑:删除所有非当前版本的缓存
      (async () => {
        const allCacheKeys = await caches.keys();
        const oldCaches = allCacheKeys.filter(key => !key.includes(CACHE_VERSION));
        await Promise.all(oldCaches.map(key => {
          console.log(`[SW] 清理旧缓存:${key}`);
          return caches.delete(key);
        }));
      })()
    ])
  );
  console.log(`[SW] 激活成功,当前版本:${CACHE_VERSION}`);
});

// 3. 核心:拦截请求,只缓存非HTML静态资源
self.addEventListener('fetch', (e) => {
  const req = e.request;
  const url = new URL(req.url);

  // 过滤规则:
  // - 只处理GET请求
  // - 排除HTML/API(Next.js的API路由以/api/开头)
  // - 只匹配静态资源(本地+CDN)
  const isGet = req.method === 'GET';
  const isHTML = HTML_REG.test(url.pathname);
  const isApi = url.pathname.startsWith('/api/');
  const isLocalStatic = STATIC_ASSET_REG.test(url.pathname) && url.hostname === location.hostname;
  const isCdnStatic = STATIC_ASSET_REG.test(url.pathname) && CDN_DOMAINS.includes(url.hostname);
  const isNeedCache = isGet && !isHTML && !isApi && (isLocalStatic || isCdnStatic);

  // 非目标资源 → 直接走网络
  if (!isNeedCache) {
    e.respondWith(fetch(req));
    return;
  }

  // 静态资源:缓存优先 + 过期校验
  e.respondWith(
    (async () => {
      const cache = await caches.open(CACHE_NAME);
      const cachedRes = await cache.match(req);

      // 有缓存 → 校验过期时间
      if (cachedRes) {
        const cacheTime = new Date(cachedRes.headers.get('x-cache-time')).getTime();
        // 未过期:直接返回SW缓存(第二次请求命中)
        if (Date.now() - cacheTime < CACHE_EXPIRE_SECONDS * 1000) {
          console.log(`[SW] ✅ 缓存命中:${req.url}`);
          return cachedRes;
        }
        // 已过期:删除旧缓存
        await cache.delete(req);
        console.log(`[SW] ⚠️ 缓存过期:${req.url}`);
      }

      // 无缓存/过期 → 走网络 + 存新缓存
      try {
        const networkRes = await fetch(req.clone());
        if (networkRes.ok) {
          // 克隆响应并添加缓存时间戳
          const resToCache = new Response(networkRes.clone().body, {
            status: networkRes.status,
            statusText: networkRes.statusText,
            headers: {
              ...Object.fromEntries(networkRes.headers.entries()),
              'x-cache-time': new Date().toUTCString()
            }
          });
          await cache.put(req, resToCache);
          console.log(`[SW] 📥 缓存新资源:${req.url}`);
        }
        return networkRes;
      } catch (err) {
        console.error(`[SW] 网络失败,兜底返回缓存:${req.url}`, err);
        // 网络失败时,即使缓存过期也返回(降级策略)
        if (cachedRes) return cachedRes;
        throw err;
      }
    })()
  );
});

// 4. 监听更新(新版本部署后通知)
self.addEventListener('message', (e) => {
  if (e.data?.type === 'SW_UPDATED') {
    self.skipWaiting(); // 立即激活新版本
  }
});

步骤2:配置 next.config.js(注入版本号)

修改项目根目录的 next.config.js,实现打包时自动替换SW中的版本号:

步骤2.1:安装依赖

npm install git-rev-sync copy-webpack-plugin --save-dev
# 或 yarn add git-rev-sync copy-webpack-plugin -D

步骤2.2:编写 next.config.js

// next.config.js
const nextConfig = {
  reactStrictMode: true,
  webpack: (config, { buildId, dev, isServer }) => {
    // 开发环境不处理SW(避免缓存干扰)
    if (dev || isServer) return config;

    const gitRev = require('git-rev-sync');
    const CopyPlugin = require('copy-webpack-plugin');
    // 生成唯一版本号:Git Commit短哈希 + 构建ID
    const gitCommit = gitRev.short() || 'dev';
    const buildVersion = `${gitCommit}-${buildId}`;

    // 1. 替换SW中的__BUILD_VERSION__占位符
    config.plugins.push(
      new CopyPlugin({
        patterns: [
          {
            from: 'public/sw.js',
            to: 'sw.js',
            transform: (content) => {
              return content.toString().replace('__BUILD_VERSION__', buildVersion);
            }
          }
        ]
      })
    );

    return config;
  }
};

module.exports = nextConfig;

步骤3:注册 Service Worker(客户端组件)

Next.js 14+ App Router 中,需在客户端组件中注册SW(Pages Router 同理):

步骤3.1:创建客户端组件(app/sw-register.js)

// app/sw-register.js
'use client';

import { useEffect } from 'react';

export default function SWRegister() {
  useEffect(() => {
    // 仅生产环境注册SW(开发环境禁用)
    if (process.env.NODE_ENV !== 'production' || !('serviceWorker' in navigator)) {
      return;
    }

    // 注册SW
    const registerSW = async () => {
      try {
        const registration = await navigator.serviceWorker.register('/sw.js', {
          scope: '/' // 全局拦截
        });
        console.log('SW注册成功:', registration.scope);

        // 监听SW更新(新版本部署后)
        registration.addEventListener('updatefound', () => {
          const newSW = registration.installing;
          newSW.addEventListener('statechange', () => {
            if (newSW.state === 'installed' && navigator.serviceWorker.controller) {
              // 新版本已安装,提示用户刷新
              console.log('SW有新版本,刷新页面生效');
              // 可选:手动触发SW激活
              // registration.waiting.postMessage({ type: 'SW_UPDATED' });
              // window.location.reload();
            }
          });
        });
      } catch (err) {
        console.error('SW注册失败:', err);
      }
    };

    // 页面加载完成后注册
    window.addEventListener('load', registerSW);

    // 卸载时取消监听
    return () => {
      window.removeEventListener('load', registerSW);
    };
  }, []);

  return null; // 无UI渲染
}

步骤3.2:在根布局中引入(app/layout.js)

// app/layout.js
import SWRegister from './sw-register';

export default function RootLayout({ children }) {
  return (
    <html lang="zh-CN">
      <body>
        <SWRegister /> {/* 注册SW */}
        {children}
      </body>
    </html>
  );
}

步骤4:打包部署 & 验证

步骤4.1:打包构建

npm run build
# 或 yarn build

打包后查看 .next/static/sw.js,确认 __BUILD_VERSION__ 已被替换为实际版本号(如 a8b7c9d-123456789)。

步骤4.2:本地验证(启动生产环境)

npm run start
# 或 yarn start

步骤4.3:验证缓存效果

  1. 打开 Chrome → F12 → ApplicationService Workers
    • 状态显示 Activated + Running
    • 版本号为打包时生成的唯一值
  2. 刷新页面(首次访问):
    • Console 打印 [SW] 📥 缓存新资源
    • Cache Storage 中出现 next-static-cache-xxx 缓存库,里面是JS/CSS/图片/CDN资源(无HTML)
  3. 再次刷新(第二次访问):
    • Console 打印 [SW] ✅ 缓存命中
    • Network 面板中,静态资源的 Size 列显示 (from ServiceWorker)
  4. 验证更新:
    • 修改代码/CDN图片 → 重新打包(版本号变化)→ 部署
    • 刷新页面 → Console 打印 [SW] 清理旧缓存 → 新资源重新缓存

核心关键点总结

1. 版本号自动注入

  • Next.js 打包时,通过 git-rev-sync 获取Git Commit,结合 buildId 生成唯一版本号;
  • 自动替换SW中的占位符,确保每次打包版本号唯一。

2. 只缓存非HTML静态资源

  • 通过正则排除HTML/API,只匹配JS/CSS/图片/CDN资源;
  • Next.js的路由(如/dashboard)、API路由(/api/*)直接走网络,不缓存。

3. 缓存更新机制

  • 版本号变化 → SW激活时清理旧缓存 → 新资源存入新版本缓存库;
  • CDN资源带contenthash,更新后URL变化 → 自动缓存新资源;
  • 7天过期时间兜底,避免缓存永久有效。

4. 第二次请求命中逻辑

  • 静态资源首次请求:网络请求 → 存入SW缓存;
  • 第二次请求:直接从SW缓存返回,优先级高于浏览器强缓存,无需走网络。

这套方案是Next.js生产环境专用,无需额外配置,复制即可用,完美解决「静态资源缓存+更新+CDN适配」问题。