前言
什么是PWA
在开始之前先来了解一下什么是PWA?
PWA(Progressive Web Apps)是使用现代API构建并增强的Web应用,具有更高的性能、可靠性和可安装性,可在任何设备上运行,具有统一的代码库。
简单来说 我们可以像类似于使用的app体验,去使用web应用。
例如以下的功能
- 可安装 - 添加到手机主屏幕 快速启动
- 离线访问 - 即使没有网络也能正常使用
- 推送通知 - 像原生 App 一样发送通知
为什么要使用PWA
先来看下Web应用与本地应用之间的比较
| 特性 | Web应用 | 本地应用 |
|---|---|---|
| 开发效率 | ✅ 一次开发,多平台运行 | ❌ 需要为不同平台单独开发 |
| 用户体验 | ❌ 受浏览器限制 | ✅ 原生体验,性能最佳 |
| 网络依赖 | ❌ 必须联网 | ✅ 可离线运行 |
| 功能完整性 | ❌ 无法访问硬件 | ✅ 可访问所有系统资源 |
| 分发更新 | ✅ 链接分享,自动更新 | ❌ 需要安装,手动更新 |
| 开发成本 | ✅ 技术栈统一,成本低 | ❌ 多平台开发,成本高 |
其中有个很大的问题是 如果用户当前网络差甚至处于无网状态,Web应用将无法被打开,这对用户体验性是极差的。
所以PWA的核心目标就是让 Web 应用通过渐进增强的方式更加贴近原生应用的体验
PWA核心概念
Service Worker
Service Worker是PWA的核心技术,它是一个在浏览器后台运行的脚本,可以:
- 拦截网络请求 - 控制资源加载策略
- 缓存资源 - 实现离线功能
- 后台同步 - 在后台处理数据同步
- 推送通知 - 接收服务器推送的消息
Web App Manifest
Web App Manifest是一个JSON文件,定义了PWA的基本信息:
- 应用名称和图标
- 启动URL和显示模式
- 主题颜色和背景色
- 屏幕方向和快捷方式
离线优先策略
PWA采用"离线优先"的策略:
- Cache First - 优先从缓存加载
- Network First - 优先从网络加载,失败时使用缓存
- Stale While Revalidate - 同时从缓存和网络加载,使用缓存结果,更新缓存
PWA是如何构建和工作的?
PWA的工作原理主要依赖于ServiceWorker和WebAppManifest。
-
Service Worker拦截网络请求并缓存资源,使得应用可以在离线状态下继续工作。 -
WebAppManifest则定义了应用的基本信息
PWA必须在HTTPS环境下运行(localhost除外)
初始化Next项目
安装
- 前置条件 Node.js 18.18或更高版本。
- 执行命令
pnpm dlx shadcn@latest init
选择对应的安装配置
后续我们需要使用到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/
Nextjs与PWA集成
社区中有很多快速与Nextjs集成的 PWA 解决方案像next-pwa,serwist
但考虑到 next-pwa 最后一次更新维护是三年之前而且它对于最版的Next.js支持并不好,所以这里我们使用serwist
- 安装
pnpm add @serwist/next && pnpm add -D serwist
- 实现
- 在
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
})
更新 tsconfig.json
{
//...
"compilerOptions": {
// Other options...
"types": [
// Other types...
// Serwist提供类型定义
"@serwist/next/typings"
],
"lib": [
// 将WebWorker和ServiceWorker相关的类型定义添加到全局
"webworker"
],
},
"exclude": ["public/sw.js"]
}
- 更新
.gitignore
# Serwist
public/sw*
public/swe-worker*
- 创建 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()
- 添加一个 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 编辑测试
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应用增强能力——离线访问
此时你观察地址栏右侧会发现有一个电脑图标,点击提示我们是否安装该应用(manifest.json中配置的应用清单)
点击安装,在启动台/桌面会新增该应用。像其他应用一样独立窗口使用
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>
)
}
效果如下
接下来我们在做些优化
例如
- 不同浏览器的 PWA 支持情况不同,例如Firefox/Safari虽然支持 Service Worker 但并不支持BeforeInstallPromptEvent 方法(安装提示)
- 如果是已经安装了,不需要再去提示,可以根据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 中,运行效果如下
PWA 更新提示
在查看serwist相关文档时,在issues中发现了github.com/serwist/ser… 针对于提示相关问题解答
接下来我们基于这个示例实现PWA更新检测功能
pnpm add -D @serwist/window
- 确保
app/sw.ts中 skipWaiting 设置为falseconst serwist = new Serwist({ skipWaiting: false, // 用户控制更新时机 })
- 在
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 在页面在做任意修改 再次执行如下
接下来我们优化下更新提示,与安装提示类似展示,那么什么时机触发更新检查呢可以交由两个事件
focus 以及visibilitychange
- focus 窗口获得焦点时
- visibilitychange 当用户从其他标签页、应用或桌面切换回来时触发
并执行更新检查
serwist.current?.update()
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
我们来完善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>
)
}
效果如下所示
到这里就我们就完成了nextjs与pwa的基础功能集成。但pwa应用远不止于此,像推送通知,硬件交互使其更能贴近原生的体验。感兴趣的同学可以自行做了解