使用 Next.js 创建渐进式WEB应用

616 阅读12分钟

前言

什么是PWA

在开始之前先来了解一下什么是PWA?

PWA(Progressive Web Apps)是使用现代API构建并增强的Web应用,具有更高的性能、可靠性和可安装性,可在任何设备上运行,具有统一的代码库。

简单来说 我们可以像类似于使用的app体验,去使用web应用。

例如以下的功能

  1. 可安装 - 添加到手机主屏幕 快速启动
  2. 离线访问 - 即使没有网络也能正常使用
  3. 推送通知 - 像原生 App 一样发送通知

为什么要使用PWA

先来看下Web应用与本地应用之间的比较

特性Web应用本地应用
开发效率✅ 一次开发,多平台运行❌ 需要为不同平台单独开发
用户体验❌ 受浏览器限制✅ 原生体验,性能最佳
网络依赖❌ 必须联网✅ 可离线运行
功能完整性❌ 无法访问硬件✅ 可访问所有系统资源
分发更新✅ 链接分享,自动更新❌ 需要安装,手动更新
开发成本✅ 技术栈统一,成本低❌ 多平台开发,成本高

其中有个很大的问题是 如果用户当前网络差甚至处于无网状态,Web应用将无法被打开,这对用户体验性是极差的。

所以PWA的核心目标就是让 Web 应用通过渐进增强的方式更加贴近原生应用的体验

PWA核心概念

Service Worker

Service Worker是PWA的核心技术,它是一个在浏览器后台运行的脚本,可以:

  • 拦截网络请求 - 控制资源加载策略
  • 缓存资源 - 实现离线功能
  • 后台同步 - 在后台处理数据同步
  • 推送通知 - 接收服务器推送的消息

Web App Manifest

Web App Manifest是一个JSON文件,定义了PWA的基本信息:

  • 应用名称和图标
  • 启动URL和显示模式
  • 主题颜色和背景色
  • 屏幕方向和快捷方式

离线优先策略

PWA采用"离线优先"的策略:

  1. Cache First - 优先从缓存加载
  2. Network First - 优先从网络加载,失败时使用缓存
  3. Stale While Revalidate - 同时从缓存和网络加载,使用缓存结果,更新缓存

PWA是如何构建和工作的?

image.png

PWA的工作原理主要依赖于ServiceWorkerWebAppManifest

  • Service Worker拦截网络请求并缓存资源,使得应用可以在离线状态下继续工作。

  • WebAppManifest则定义了应用的基本信息

PWA必须在HTTPS环境下运行(localhost除外)

初始化Next项目

安装

  • 前置条件 Node.js 18.18或更高版本。
  • 执行命令
pnpm dlx shadcn@latest init

选择对应的安装配置

image.png

后续我们需要使用到shadcn-ui以及lucide-react图标库一并来安装下

pnpm dlx shadcn@latest init
pnpm install lucide-react

清除项目模板中默认的布局页面以及css样式,在app/page.tsx中添加一个基础的按钮展示

import { Button } from "@/components/ui/button"

export default function Home() {
  return (
    <div className='min-h-screen font-[family-name:var(--font-geist-sans)]'>
      <Button> Hello Nextjs </Button>
    </div>
  )
}

pnpm run dev启动项目并访问 http://localhost:3000/

image.png

Nextjs与PWA集成

社区中有很多快速与Nextjs集成的 PWA 解决方案像next-pwa,serwist 但考虑到 next-pwa 最后一次更新维护是三年之前而且它对于最版的Next.js支持并不好,所以这里我们使用serwist

  • 安装
pnpm add @serwist/next && pnpm add -D serwist
  • 实现
  1. next.config.mjs 中引入配置
import withSerwistInit from "@serwist/next"

const withSerwist = withSerwistInit({
 // Service Worker 源文件路径
 swSrc: "src/app/sw.ts",
 // 生成的 Service Worker 文件路径
 swDest: "public/sw.js",
 register: true
})

export default withSerwist({
 // Your Next.js config
})
  1. 更新 tsconfig.json
{
 //...
 "compilerOptions": {
   // Other options...
   "types": [
     // Other types...
     // Serwist提供类型定义
     "@serwist/next/typings"
   ],
   "lib": [
     // 将WebWorker和ServiceWorker相关的类型定义添加到全局
     "webworker"
   ],
 },
 "exclude": ["public/sw.js"]
}
  1. 更新 .gitignore
# Serwist
public/sw*
public/swe-worker*
  1. 创建 service worker app/sw.ts
import { defaultCache } from "@serwist/next/worker"
import type { PrecacheEntry, SerwistGlobalConfig } from "serwist"
import { Serwist } from "serwist"

declare global {
  interface WorkerGlobalScope extends SerwistGlobalConfig {
    __SW_MANIFEST: (PrecacheEntry | string)[]
    skipWaiting(): void
    addEventListener(type: string, listener: (event: any) => void): void
  }
}

declare const self: WorkerGlobalScope

const serwist = new Serwist({
  precacheEntries: self.__SW_MANIFEST,// 预缓存条目
  skipWaiting: false, // 用户控制更新时机
  clientsClaim: true, // // 控制新Service Worker立即接管页面
  navigationPreload: true, // 启用导航预加载
  runtimeCaching: defaultCache // 使用默认运行时缓存策略
})

serwist.addEventListeners()

  1. 添加一个 Web 应用清单,Web 应用清单是一个 JSON 文件,包含基本信息(例如应用名称、图标和主题颜色);偏好设置(例如所需屏幕方向和应用快捷方式);以及目录元数据(例如屏幕截图)。 在public/manifest.json
{
  "name": "nextjs-pwa",
  "short_name": "nextjs-pwa",
  "description": "一个nextjs-pwa应用的实例项目",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#f97316",
  "orientation": "portrait",
  "icons": [
    {
      "src": "/icons/maskable-icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "maskable"
    },
    {
      "src": "/icons/maskable-icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

  • name: 完整应用名称,安装时显示
  • short_name: 简短名称,主屏幕显示
  • description: 应用描述,应用商店和安装提示显示
  • start_url: 应用启动 URL
  • display: standalone 模式提供原生应用体验
  • background_color: 启动画面背景色
  • theme_color: 状态栏和浏览器 UI 主题色
  • orientation: 限制为竖屏模式
  • icons: 应用图标

应用图标可以在 maskable.app/editor 编辑测试

  1. app/layout.tsx中配置 Meta 标签
export const metadata: Metadata = {
  title: "nextjs-pwa App",
  description: "一个nextjs-pwa应用的实例项目",
  manifest: "/manifest.json"
}

export const viewport: Viewport = {
  themeColor: [
      { media: "(prefers-color-scheme: light)", color: "white" },
      { media: "(prefers-color-scheme: dark)", color: "black" }
  ],
  width: "device-width",
  initialScale: 1,
  maximumScale: 1,
  userScalable: false
}

我们在项目中任意添加几个页面,以及对应的导航链接,然后打包并启动项目

pnpm run build && pnpm run start

访问 3000端口 如何测试呢 打开游览器控制台=> 应用=> Service Worker 已激活状态 我们可以勾选离线状态或者在终端关闭当前项目启动,会发现项目依旧可以正常访问,这就是PWA带给Web应用增强能力——离线访问

2025-07-09 20.07.17.gif

此时你观察地址栏右侧会发现有一个电脑图标,点击提示我们是否安装该应用(manifest.json中配置的应用清单) 2025-07-09 20.14.20.gif

点击安装,在启动台/桌面会新增该应用。像其他应用一样独立窗口使用 image.png

PWA更新与安装提示

PWA安装提示

对于不熟悉pwa安装流程的用户来讲,上述的安装操作让人费解,尤其是在移动端场景下。所以最好是在页面中有一个直观的提示框,能够清晰的告知用户,并可以一键快速安装。

当浏览器检测到应用可被安装时,就会触发 beforeinstallprompt 事件,

window.addEventListener("beforeinstallprompt", function(event) { ... });

event.prompt()可以帮助我们唤起安装提示框,执行完会返回一个promise对象其中outcome属性表示用户是否选择了安装应用。accepted:用户已安装该应用dismissed:用户未安装该应用。从而判断用户是否安装成功

我们可以先将event事件保存下来,方便后续的自定义处理,创建components/pwa-install-prompt.tsx文件

"use client"
import { Download, X } from "lucide-react"
import { useEffect, useState } from "react"
import { Button } from "./ui/button"

// 定义BeforeInstallPromptEvent接口以获得类型提示
interface BeforeInstallPromptEvent extends Event {
  prompt(): Promise<{ outcome: "accepted" | "dismissed"; platform: string }>
  userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>
  platforms: string[]
}

export function PwaInstallPrompt() {
  const [promptEvent, setPromptEvent] = useState<BeforeInstallPromptEvent | null>(null)

  useEffect(() => {
    const handleBeforeInstallPrompt = (e: BeforeInstallPromptEvent) => {
      // 阻止浏览器默认安装提示
      e.preventDefault()
      // 保存事件以便后续使用
      setPromptEvent(e)
    }
    // 添加事件监听器
    window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt as EventListener)

    // 清理函数
    return () => {
      window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt as EventListener)
    }
  }, [])

  const handleInstallClick = async () => {
    if (!promptEvent) return

    // 显示浏览器安装提示
    const result = await promptEvent.prompt()
     console.log(result)
    // 处理用户选择
    if (result.outcome === "accepted") {
      console.log("用户接受了安装提示")
    } else {
      console.log("用户取消了安装提示")
    }

    // 重置prompt事件
    setPromptEvent(null)
  }

  // 检查是否应该显示提示
  if (!promptEvent) return null

  return (
    <div className='fixed px-4 top-4 right-4'>
      <Button onClick={handleInstallClick}>
            <Download className='size-4 mr-2' />
            安装应用
          </Button>
    </div>
  )
}

效果如下 2025-07-10 21.30.21.gif 接下来我们在做些优化 例如

  1. 不同浏览器的 PWA 支持情况不同,例如Firefox/Safari虽然支持 Service Worker 但并不支持BeforeInstallPromptEvent 方法(安装提示)
  2. 如果是已经安装了,不需要再去提示,可以根据window.matchMedia("(display-mode: standalone)").matches 中尚 我们写一个方法对于影响到PWA安装支持的情况,在lib/pwa.ts 新增detectPWAEnvironment
interface PWAEnvironment {
  isSupportsServiceWorker: boolean
  isSupportsInstall: boolean
  isIOS: boolean
  isInstalled: boolean
  isDevelopment: boolean
}
/**
 * 检测 PWA 环境支持情况
 */

export const detectPWAEnvironment = (): PWAEnvironment => {
  const isDevelopment = process.env.NODE_ENV === "development"

  if (typeof window === "undefined") {
    return {
      isSupportsServiceWorker: false,
      isSupportsInstall: false,
      isIOS: false,
      isInstalled: false,
      isDevelopment
    }
  }

  // Service Worker 支持(PWA 基础功能)
  const isSupportsServiceWorker = "serviceWorker" in navigator

  // PWA 安装支持
  const isSupportsInstall = isSupportsServiceWorker && "BeforeInstallPromptEvent" in window

  // 是否iOS 设备
  const userAgent = window.navigator.userAgent.toLowerCase()
  const isIOS = /iphone|ipad|ipod/.test(userAgent)

  // 是否已安装
  const isInstalled =
    window.matchMedia("(display-mode: standalone)").matches ||
    (window.navigator as any).standalone === true

  return {
    isSupportsServiceWorker,
    isSupportsInstall,
    isIOS,
    isInstalled,
    isDevelopment
  }
}

创建pwa-install-prompt.tsx

"use client"

import { detectPWAEnvironment } from "@/lib/pwa"
import dayjs from "dayjs"
import { Download, Share, X } from "lucide-react"
import { useEffect, useState } from "react"
import { Button } from "./ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "./ui/card"
import { Checkbox } from "./ui/checkbox"
import { Label } from "./ui/label"

// 定义BeforeInstallPromptEvent接口
interface BeforeInstallPromptEvent extends Event {
  prompt(): Promise<{ outcome: "accepted" | "dismissed"; platform: string }>
  userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>
  platforms: string[]
}

enum STORAGE_KEYS {
  INSTALL_LATER_UNTIL = "installLaterUntil",
  INSTALL_DISMISSED_DATE = "installDismissedDate"
}
/**
 * 检查是否应该显示安装提示
 */
const shouldShowInstallPrompt = (): boolean => {
  try {
    const now = dayjs()

    // 是否在延迟1小时内(稍后展示)
    const laterUntil = localStorage.getItem(STORAGE_KEYS.INSTALL_LATER_UNTIL)
    if (laterUntil) {
      const laterTime = parseInt(laterUntil)
      if (now.valueOf() < laterTime) {
        return false
      }
    }

    // 是否已在今天被拒绝(不再提示)
    const dismissedDate = localStorage.getItem(STORAGE_KEYS.INSTALL_DISMISSED_DATE)
    if (dismissedDate) {
      const dismissedTime = parseInt(dismissedDate)
      const dismissedDay = dayjs(dismissedTime)

      if (now.isSame(dismissedDay, "day")) {
        console.log("今天已经拒绝,不显示")
        return false 
      }
    }

    return true
  } catch (error) {
    console.warn("检查安装提示状态失败:", error)
    return true
  }
}
export function PwaInstallPrompt() {
  const [promptEvent, setPromptEvent] = useState<BeforeInstallPromptEvent | null>(null)
  const [neverShowAgain, setNeverShowAgain] = useState(false)
  const [isInstallPrompt, setIsInstallPrompt] = useState(shouldShowInstallPrompt())
  const { isSupportsInstall, isInstalled, isIOS, isDevelopment } = detectPWAEnvironment()

  useEffect(() => {
    const handleBeforeInstallPrompt = (e: BeforeInstallPromptEvent) => {
      // 阻止浏览器默认安装提示
      e.preventDefault()
      // 保存事件以便后续使用
      setPromptEvent(e)
    }

    // 添加事件监听器
    window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt as EventListener)

    // 清理函数
    return () => {
      window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt as EventListener)
    }
  }, [])

  const handleInstallClick = async () => {
    if (!promptEvent) return

    // 显示浏览器安装提示
    const result = await promptEvent.prompt()

    // 处理用户选择
    if (result.outcome === "accepted") {
      localStorage.removeItem(STORAGE_KEYS.INSTALL_LATER_UNTIL)
      localStorage.removeItem(STORAGE_KEYS.INSTALL_DISMISSED_DATE)
    } else {
      console.log("用户取消了安装提示")
    }

    // 重置prompt事件
    setPromptEvent(null)
  }

  const setInstallLaterDelay = () => {
    try {
      const oneHourLater = dayjs()
        .add(1, "hour") 
        // 关闭10秒之后再展示
        // .add(1000 * 10, "ms")
        .valueOf()
      localStorage.setItem(STORAGE_KEYS.INSTALL_LATER_UNTIL, oneHourLater.toString())
      // 定时器
      setTimeout(() => {
        setIsInstallPrompt(true)
      }, oneHourLater - dayjs().valueOf())
    } catch (error) {
      console.warn("设置延迟时间失败:", error)
    }
  }

  /**
   * 设置今天已拒绝标记
   */
  const setInstallDismissedToday = () => {
    try {
      const oneDayLater = dayjs().add(1, "day").valueOf()
      localStorage.setItem(STORAGE_KEYS.INSTALL_DISMISSED_DATE, oneDayLater.toString())
    } catch (error) {
      console.warn("设置拒绝标记失败:", error)
    }
  }
  // 不再提醒选择
  const handleNeverShowChange = (checked: boolean) => {
    setNeverShowAgain(checked)
  }

  const handleClose = () => {
    if (neverShowAgain) {
      setInstallDismissedToday()
    } else {
      setInstallLaterDelay()
    }
    setIsInstallPrompt(false)
  }

  // 检查是否应该显示提示
  if (!isInstallPrompt || isInstalled || !isDevelopment) return null
  return (
    <div className='fixed w-full px-4 bottom-8'>
      <Card className='max-w-md mx-auto shadow-2xl  gap-4 bg-white/95 dark:bg-gray-900/95'>
        <CardHeader className='pb-2'>
          <div className='flex justify-between items-start'>
            <div className='flex items-center gap-2'>
              <div className='size-8 bg-primary text-primary-foreground rounded-lg flex items-center justify-center'>
                <Download className='size-4' />
              </div>
              <CardTitle className='text-lg'>安装</CardTitle>
            </div>
            <Button variant='ghost' size='sm' className='h-8 w-8 p-0' onClick={handleClose}>
              <X className='size-4' />
            </Button>
          </div>
          <CardDescription className='mt-4'>
            将应用安装到您的设备上,随时随地访问,即使在离线状态下也能使用部分功能。
          </CardDescription>
        </CardHeader>
        <CardContent className='pb-2'>
          {isIOS && (
            <p className='space-x-1 mb-4'>
              <span>在iOS,上点击底部工具栏的</span>
              <span className='inline-flex items-center mx-2'>
                <Share className='size-3 text-primary' />
              </span><span>然后选择添加到主屏幕即可安装应用</span>
            </p>
          )}
          {/* 不再提醒选择框 */}
          <div className='flex items-center space-x-2 pt-2 border-t border-border/50'>
            <Checkbox
              id='never-show-again'
              checked={neverShowAgain}
              onCheckedChange={checked => handleNeverShowChange(checked as boolean)}
            />
            <Label htmlFor='never-show-again' className='text-xs text-muted-foreground'>
              不再提醒安装
            </Label>
          </div>
        </CardContent>
        <CardFooter className='flex justify-end gap-2'>
          <Button variant='outline' onClick={handleClose}>
            稍后再说
          </Button>

          {!isSupportsInstall ||
            (!isIOS && (
              <Button onClick={handleInstallClick}>
                <Download className='size-4 mr-2' />
                安装应用
              </Button>
            ))}
        </CardFooter>
      </Card>
    </div>
  )
}

Firefox/Safari浏览器安装亦有所差异,可自行补充尝试

引入到 app/layout.tsx 中,运行效果如下

2025-07-16 01.41.12.gif

PWA 更新提示

在查看serwist相关文档时,在issues中发现了github.com/serwist/ser… 针对于提示相关问题解答 接下来我们基于这个示例实现PWA更新检测功能

安装 @serwist/window

pnpm add -D @serwist/window
  1. 确保 app/sw.ts 中 skipWaiting 设置为false
const serwist = new Serwist({
 skipWaiting: false, // 用户控制更新时机
})
  1. next.config.mjs
const withSerwist = withSerwistInit({
 //...
 register: false,//不去自动注册service worker
 disable: process.env.NODE_ENV === "development" // 开发环境下禁用
})```

创建usePWAUpdate.ts用来管理注册以及更新时机


import type { Serwist } from "@serwist/window"
import { Serwist as SerwistWindow } from "@serwist/window"
import { useCallback, useEffect, useRef, useState } from "react"
import { detectPWAEnvironment } from "../lib/pwa"

export function usePWAUpdate() {
  // Serwist 实例
  const serwist = useRef<Serwist | undefined>(undefined)

  const { isSupportsServiceWorker, isDevelopment } = detectPWAEnvironment()

  /**
   * 执行应用更新向 Service Worker 发送跳过等待消息
   */
  const update = useCallback(() => {
    // 发送 SKIP_WAITING 消息给等待中的 Service Worker
    // 让新的 Service Worker 立即激活
    serwist.current?.messageSkipWaiting()
  }, [])

  useEffect(() => {
    const loadSerwist = async () => {
      // 开发环境下跳过 Service Worker 注册
      if (isDevelopment) {
        console.log("开发环境:跳过 Service Worker 注册")
        return
      }

      if (isSupportsServiceWorker) {
        try {
          // 创建 Serwist 实例
          // "/sw.js": Service Worker 文件的路径
          // scope: "/": Service Worker 的作用域,"/" 表示整个网站
          const sw = new SerwistWindow("/sw.js", {
            scope: "/"
          })
          serwist.current = sw

          if (!serwist.current) return

          /**
           * 处理 Service Worker 更新的核心函数
           * 当检测到新版本的 Service Worker 时调用
           */
          const showSkipWaitingPrompt = () => {
            if (!serwist.current) return

            // 设置监听器:当新的 Service Worker 获得控制权时触发
            serwist.current.addEventListener("controlling", () => {
              // 新的 Service Worker 已经接管了页面控制权
              // 此时刷新页面以确保用户看到最新版本的内容
              // 注意:在刷新前可以添加数据保存逻辑,避免用户数据丢失
              window.location.reload()
            })

          }

          // 注册 "waiting" 事件监听器
          serwist.current.addEventListener("waiting", showSkipWaitingPrompt)

          // 注册 Service Worker
          void serwist.current.register()

          console.log("PWA Service Worker 已注册,离线功能已启用")
        } catch (error) {
          // Service Worker 注册失败时的错误处理
          // 使用 console.warn 而不是 console.error,因为这不是致命错误
          // 应用仍然可以正常运行,只是没有 PWA 功能
          console.warn("Failed to load service worker:", error)
        }
      }
    }

    // 执行 Service Worker 初始化
    loadSerwist()
  }, [])


  return {
    update // 更新执行函数,供更新按钮调用
  }
}

创建pwa-update-prompt.tsx 并引入到app/layout.ts

"use client"

import { usePWAUpdate } from "../hooks/usePWAUpdate"
import { Button } from "./ui/button"

export function PwaUpdatePrompt() {
  const { update } = usePWAUpdate()

  return (
    <>
      <div>
        <Button onClick={update}>立即更新</Button>
      </div>
    </>
  )
}

执行pnpm run build && pnpm run start 在页面在做任意修改 再次执行如下

2025-07-16 13.21.40.gif 接下来我们优化下更新提示,与安装提示类似展示,那么什么时机触发更新检查呢可以交由两个事件 focus 以及visibilitychange

  • focus 窗口获得焦点时
  • visibilitychange 当用户从其他标签页、应用或桌面切换回来时触发 并执行更新检查serwist.current?.update()

image.png

usePWAUpdate.ts文件补充

  ...
  useEffect(() => {
    // 开发环境下跳过更新检查
    if (isDevelopment) {
      return
    }

    /**
     * 窗口获得焦点时的处理函数
     * 当用户从其他标签页、应用或桌面切换回来时触发
     */
    const handleFocus = () => {
      console.log("handleFocus  更新提示")
      // 主动检查是否有新的 Service Worker 可用
      serwist.current?.update()
    }

    const handleVisibilityChange = () => {
      if (document.visibilityState === "visible") {
        // 可见时检查更新
        serwist.current?.update()
      }
    }

    // 注册事件监听器
    window.addEventListener("focus", handleFocus)
    document.addEventListener("visibilitychange", handleVisibilityChange)

    return () => {
      window.removeEventListener("focus", handleFocus)
      document.removeEventListener("visibilitychange", handleVisibilityChange)
    }
  }, [])

再次执行构建命令pnpm run build && pnpm run start

从开发者工具/应用窗口 切换焦点,观察控制台以及应用中的service worker当新版本的 Service Worker 安装后会处于 waiting 状态并执行了事件方法showSkipWaitingPrompt

2025-07-16 13.47.25.gif

我们来完善pwa-update-prompt.tsx部分代码

"use client"

import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle
} from "@/components/ui/card"
import { Download, RefreshCw, X } from "lucide-react"
import { usePWAUpdate } from "../hooks/usePWAUpdate"
import { Button } from "./ui/button"

export function PwaUpdatePrompt() {
  const { isPromptingUpdate, update, handleClose } = usePWAUpdate()
  if (!isPromptingUpdate) return null
  return (
    <div className='fixed w-full px-4 bottom-8'>
      <Card className='max-w-md mx-auto shadow-2xl  gap-4 bg-white/95 dark:bg-gray-900/95'>
        <CardHeader className='pb-2'>
          <div className='flex justify-between items-start'>
            <div className='flex items-center gap-2'>
              <div className='size-8 bg-primary text-primary-foreground rounded-lg flex items-center justify-center'>
                <RefreshCw className='size-4' />
              </div>
              <CardTitle className='text-lg'>新版本可用</CardTitle>
            </div>
            <Button variant='ghost' size='sm' className='h-8 w-8 p-0' onClick={handleClose}>
              <X className='size-4' />
            </Button>
          </div>
          <CardDescription>发现应用更新</CardDescription>
        </CardHeader>
        <CardContent className='pb-2'>
          <p className='text-sm'>
            应用有新版本可用,包含功能改进和错误修复。更新后将获得更好的使用体验。
          </p>
        </CardContent>
        <CardFooter className='flex justify-end gap-2'>
          <Button variant='outline' onClick={handleClose}>
            稍后更新
          </Button>
          <Button onClick={update}>
            <Download className='size-4 mr-2' />
            立即更新
          </Button>
        </CardFooter>
      </Card>
    </div>
  )
}

效果如下所示 2025-07-16 23.47.50.gif

到这里就我们就完成了nextjs与pwa的基础功能集成。但pwa应用远不止于此,像推送通知,硬件交互使其更能贴近原生的体验。感兴趣的同学可以自行做了解

参考文档

  1. Serwist 官网
  2. web.dev PWA 指南
  3. PWA Book 中文版
  4. MDN PWA 文档
  5. Chrome PWA 开发指南