搜索工具

快速搜索工具

使用 Next.js 和 Serwist 构建离线应用完整指南
技术分享
07/23/2025
12 min

使用 Next.js 和 Serwist 构建离线应用完整指南

离线处理性能优化最佳实践

LocallyTools 上线有一段时间了。虽然产品是利用浏览器的能力在本地处理数据,但总是有用户质疑它的可信度——"真的不会上传我的文件吗?""怎么证明数据不会泄露?"

这些质疑促使我实现了完全的离线化。想法很简单:如果应用能在断网状态下正常工作,那就彻底证明了数据确实没有离开用户设备。

在实现过程中,我发现离线化带来的好处远超预期:

建立用户信任:产品能够离线运行,直接证明了数据的安全性,用户不再需要"相信"我的承诺,而是可以亲自验证。

减轻服务器压力:资源缓存到本地后,大部分请求都走本地缓存,大幅减少了服务器端的压力和带宽成本。

避开 SEO 竞争:现在工具箱市场竞争激烈,大家都在拼 SEO 排名。但 LocallyTools 做成 PWA 后,用户可以直接保存到桌面使用,不需要每次都去搜索引擎找工具。这样既避开了激烈的 SEO 竞争,也大大增加了用户粘性。

当然,实现起来比我最初想的要复杂一些。分享一下我在 Next.js 应用中实现离线功能的经验。

技术栈

我用的是:

  • Next.js 15 (app router)

  • Pnpm

  • TypeScript

  • TailwindCSS 4.0

  • @serwist/next

我先试了 Workbox,但发现 Serwist 更容易配置,特别是配合 Next.js,这也是 Next.js 官方文档中推荐的一种方法

配置 Serwist

安装很简单:

pnpm add @serwist/next serwist

next.config.ts 中配置 Serwist:

// next.config.ts
import withSerwistInit from '@serwist/next';
import { execSync } from 'child_process';

// 使用 git commit hash 当作缓存版本号
const revision = execSync('git rev-parse HEAD', { encoding: 'utf8' })
  .trim()
  .slice(0, 7);

const withSerwist = withSerwistInit({
  cacheOnNavigation: true,
  reloadOnOnline: flase,
  swSrc: 'app/sw.ts',
  swDest: 'public/sw.js',
  disable: process.env.NODE_ENV === 'development',
  additionalPrecacheEntries: [
    { url: '/', revision },
    // 可选
    { url: '/offline', revision },
  ],
});

export default withSerwist({
  // 其他配置...
});

Serwist 核心实现

这是离线应用的核心部分。选错缓存策略,要么用户看到过期内容,要么离线时什么都加载不出来。

缓存策略选择

四种主要缓存策略的对比:

策略工作原理适用场景优点缺点典型用例
CacheFirst优先读缓存,缓存没有才请求网络静态资源极快加载速度可能显示过期内容图片、字体、音视频
NetworkFirst优先请求网络,网络失败才读缓存动态内容数据始终新鲜依赖网络连接API 接口、用户数据
StaleWhileRevalidate立即返回缓存,同时后台更新平衡场景快速响应 + 后台更新实现复杂度较高CSS、JS 文件
NetworkOnly永远只请求网络,从不使用缓存敏感操作数据绝对新鲜离线时不可用登录、支付接口

Service Worker 配置

app/sw.ts 文件是定义缓存策略的地方

// app/sw.ts

import {
  Serwist,
  StaleWhileRevalidate,
  ExpirationPlugin,
  type RuntimeCaching,
  type PrecacheEntry,
  type SerwistGlobalConfig,
} from "serwist";
import { defaultCache } from "@serwist/next/worker";

declare global {
  interface WorkerGlobalScope extends SerwistGlobalConfig {
    // Change this attribute's name to your `injectionPoint`.
    // `injectionPoint` is an InjectManifest option.
    // See https://serwist.pages.dev/docs/build/configuring
    __SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
  }
}

declare const self: ServiceWorkerGlobalScope;

// 自定义缓存策略
const cacheStrategies: RuntimeCaching[] = [
  {
    matcher: ({ request, url: { pathname }, sameOrigin }) =>
      request.headers.get("RSC") === "1" &&
      request.headers.get("Next-Router-Prefetch") === "1" &&
      sameOrigin &&
      !pathname.startsWith("/api/"),
    handler: new StaleWhileRevalidate({
      cacheName: "pages-rsc-prefetch",
      plugins: [
        new ExpirationPlugin({
          maxEntries: 200,
          maxAgeSeconds: 24 * 60 * 60, // 24 hours
          maxAgeFrom: "last-used",
        }),
      ],
    }),
  },
  {
    matcher: ({ request, url: { pathname }, sameOrigin }) =>
      request.headers.get("RSC") === "1" &&
      sameOrigin &&
      !pathname.startsWith("/api/"),
    handler: new StaleWhileRevalidate({
      cacheName: "pages-rsc",
      plugins: [
        new ExpirationPlugin({
          maxEntries: 200,
          maxAgeSeconds: 24 * 60 * 60, // 24 hours
          maxAgeFrom: "last-used",
        }),
      ],
    }),
  },
  {
    matcher: ({ request, url: { pathname }, sameOrigin }) =>
      request.headers.get("Content-Type")?.includes("text/html") &&
      sameOrigin &&
      !pathname.startsWith("/api/"),
    handler: new StaleWhileRevalidate({
      cacheName: "pages",
      plugins: [
        new ExpirationPlugin({
          maxEntries: 200,
          maxAgeSeconds: 24 * 60 * 60, // 24 hours
          maxAgeFrom: "last-used",
        }),
      ],
    }),
  },

  // 其他资源的缓存策略
  // {
  //   matcher: /\.(?:mp4|webm)$/i,
  //   handler: new StaleWhileRevalidate({
  //     cacheName: 'static-video-assets',
  //     plugins: [
  //       new ExpirationPlugin({
  //         maxEntries: 32,
  //         maxAgeSeconds: 7 * 24 * 60 * 60,
  //         maxAgeFrom: 'last-used',
  //       }),
  //      new RangeRequestsPlugin(),
  //     ],
  //   }),
  // },
  ...
];

const serwist = new Serwist({
  precacheEntries: self.__SW_MANIFEST,
  skipWaiting: true,
  clientsClaim: true,
  navigationPreload: true,
  runtimeCaching: [...cacheStrategies, ...defaultCache],

  // 可选
  fallbacks: {
    entries: [
      {
        url: "/offline",
        matcher({ request }) {
          return request.destination === "document";
        },
      },
    ],
  },
});

serwist.addEventListeners();

在上面的配置中,特殊处理了与页面导航相关的特殊的请求:

  • RSC 预取 (Prefetch)RSC: 1 + Next-Router-Prefetch: 1,鼠标悬停在链接上时触发,Next.js 在后台提前加载。

  • RSC 导航 (Navigation)RSC: 1,用户实际点击链接进行页面跳转时触发。

  • HTML 文档 (Page Shell)Content-Type: text/html,首次访问或硬刷新页面触发。

使用 StaleWhileRevalidate 能更好的确保页面能快速加载,同时保持内容更新

插件说明

  • ExpirationPlugin:管理缓存过期,防止存储空间无限增长

  • RangeRequestsPlugin:支持大文件的范围请求(如视频、音频)

设置 manifest 和 metadata

设置 manifest

app/manifest.ts 中定义你的 Web 应用的信息,它相当于你的 Web 应用的身份证,只有提供了这个清单文件,浏览器才会认为你的网站是"可安装的"(Installable),并向用户显示"添加到主屏幕"的提示。

在本地你可以通过访问 http://localhost:3000/manifest.webmanifest 检查设置是否正确

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

export default async function manifest(): Promise<MetadataRoute.Manifest> {
  return {
    name: 'Your App Name',
    short_name: 'Your App Name',
    description: 'Your App Description',
    start_url: '/',
    display: 'standalone',
    background_color: '#ffffff',
    theme_color: '#ffffff',
    icons: [
      {
        src: '/android-chrome-192x192.png',
        sizes: '192x192',
        type: 'image/png',
        purpose: 'any',
      },
      {
        src: '/android-chrome-512x512.png',
        sizes: '512x512',
        type: 'image/png',
        purpose: 'any',
      },
      {
        src: '/android-chrome-512x512.png',
        sizes: '512x512',
        type: 'image/png',
        purpose: 'maskable',
      },
    ],
    orientation: 'portrait',
  };
}

设置 metadata

更新你的根布局文件 app/layout.tsx 中的 metadata 对象, 并添加 manifest 文件的位置信息

// app/layout.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'Your App Name',
  description: 'Your App Description',

  // 这行是关键
  manifest: '/manifest.webmanifest',
};

Service Worker 调试

调试 Service Worker 主要是通过 Chrome DevTools (或 Edge DevTools)

Application -> Service Workers

在该面板下有有一些选项和按钮,了解它们对于我们调试 Service Worker 很有帮助。

Application Service Workers

1. 顶部复选框

  • Offline: 勾选后,Chrome 会模拟断网状态。所有网络请求都会立即失败,就像你拔掉了网线一样,对于测试 PWA 离线能力很有帮助。

  • Update on reload: 勾选后,每次刷新页面时,浏览器都会强制安装并激活最新的 SW,避免“代码不更新”的缓存问题。建议开发时勾选

  • Bypass for network,勾选后,会暂时完全绕过 SW,直接从网络加载所有资源,相当于快速“禁用”SW,这对排查问题很有帮助,常用来判断问题是出在 SW 缓存还是应用本身。

  • Push, Sync, Periodic sync: 如果你用到了推送通知或后台同步,可以在这里手动触发这些事件进行测试。

2. 操作按钮

  • Update: 触发浏览器去服务器检查 sw.js 文件是否有更新,如果有更新,浏览器就会开始下载并安装新版本。

  • Unregister: 注销当前的 Service Worker

  • start/stop: 启动/停止当前 Service Worker

  • skipWaiting: 当有一个新版本的 Service Worker 处于 waiting 状态时,会出现这个按钮,点击它会强制等待中的新 Service Worker 立即进入 activate 状态并接管页面。

Application -> Cache Storage

该面板用来检查和管理管理缓存

Application Cache Storage
  • 检查 runtimeCaching 是否生效: 在 app/sw.ts 中定义的 cacheName (e.g., apis, pages) 会在这里以独立条目的形式出现。

  • 查看缓存内容: 点击任何一个缓存条目,查看缓存的内容是否在这里正确出现。

  • 手动清理: 右键点击 “Delete” 按钮,删除任何一个缓存条目

Network

使用该面板用来确认资源的来源,主要从它的关键信息栏 Size 来确认

Network Status
  • Size: (ServiceWorker): 说明这个资源是从 Service Worker 缓存中提供的,没有走网络请求,这是确认你的缓存策略(特别是 CacheFirstStaleWhileRevalidate)是否按预期工作的直接证据。

  • Size: (from memory cache)(from disk cache) 表明该资源来自浏览器的 HTTP 缓存,而不是 Service Worker 缓存。

勾选了 Network 面板下的 Disable cache 选项,这只会禁用浏览器的 HTTP 缓存,不会禁用 Service Worker 缓存,要禁用 Service Worker,请使用 Application 面板的 Bypass for network

可能踩坑点

  • HTTPS 限制:Service Worker 只能在 HTTPS 环境下工作,但 localhost 是个例外,如果你通过本地 IP (e.g., 192.168.31.10) 访问,SW 将不会注册。

  • 代码不更新问题: 99% 的情况是旧的 SW 的问题,解决办法:

    1. 检查 DevTools 的 Update on reload 是否勾选。

    2. 去 Service Workers 面板手动 skipWaiting 或 Unregister 旧的 SW。

    3. 最终手段:在 Application -> Storage 面板,点击 Clear site data,这会清除所有缓存、SW、IndexedDB 等,让你的网站回到“出厂设置”。

  • 开发环境建议:当我们开发完 PWA 相关的功能后,强烈建议您禁用 serwist,只有在需要专门调试 PWA 功能时再启用它,以避免缓存地狱问题,毕竟没有比 “我明明改了代码,为什么页面没变?!”这种事情更让人抓狂的了。

// next.config.ts

const withSerwist = withSerwistInit({
  disable: process.env.NODE_ENV === "development",
   ...
});
  • 避免强制刷新:设置 reloadOnOnline: false,如果设置 reloadOnOnline: true,当用户从 offline 变成 online 之后,会触发了 location.reload(),页面会被强制刷新!假如用户此时正在填写表单,而你的网站又没有做实时保存功能,这时候用户辛辛苦苦填写的表单内容将瞬间消失了,因此建议您将 reloadOnOnline 设置为 false

// next.config.ts

const withSerwist = withSerwistInit({
  reloadOnOnline: false,
  ...
});

开始你的离线之旅

如果你也想让应用离线可用,建议从这些步骤开始:

  1. 评估现有应用:哪些功能可以本地化?

  2. 从静态资源开始:图片、CSS、JS 先用 CacheFirst

  3. 逐步添加 API 缓存:根据数据重要性选择策略

  4. 测试各种网络状况:断网测试

离线功能不是炫技,而是对用户时间的尊重。当用户在地铁里、飞机上、或者网络不稳定时,依然能流畅使用你的应用,这种体验是无价的。

想感受真正的离线应用是什么样的?可以试试 LocallyTools


相关阅读

如果你对离线优先开发感兴趣,推荐阅读:


你在实现离线功能时遇到过什么坑? 或者对哪个缓存策略有疑问?在评论区分享你的经验,让我们一起完善离线应用的最佳实践。

如果这篇文章对你有帮助,别忘了点赞和分享! 👍