渐进式 Web 应用(PWA)结合了 Web 应用的覆盖范围和可访问性,以及原生移动应用的特性和用户体验。借助 Next.js,您可以创建 PWA,在所有平台上提供无缝、类似应用的用户体验,而无需多个代码库或应用商店审批。
PWA 允许您:
Next.js 内置支持使用 App Router 创建 Web 应用清单。您可以创建静态或动态清单文件:
例如,创建 app/manifest.ts 或 app/manifest.json 文件:
import type { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Next.js PWA',
short_name: 'NextPWA',
description: 'A Progressive Web App built with Next.js',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#000000',
icons: [
{
src: '/icon-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
},
],
}
}export default function manifest() {
return {
name: 'Next.js PWA',
short_name: 'NextPWA',
description: 'A Progressive Web App built with Next.js',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#000000',
icons: [
{
src: '/icon-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
},
],
}
}此文件应包含有关名称、图标以及它应如何作为图标显示在用户设备上的信息。这将允许用户将您的 PWA 安装到他们的主屏幕上,提供类似原生应用的用户体验。
您可以使用 favicon 生成器 等工具创建不同的图标集,并将生成的文件放置在您的 public/ 文件夹中。
所有现代浏览器都支持 Web 推送通知,包括:
这使得 PWA 成为原生应用的一个可行替代方案。值得注意的是,您可以在不需要离线支持的情况下触发安装提示。
Web 推送通知允许您在用户不活跃使用您的应用时重新吸引他们。以下是如何在 Next.js 应用中实现它们:
首先,我们来创建 app/page.tsx 中的主页面组件。为了更好地理解,我们将其分解为更小的部分。首先,我们将添加一些所需的导入和工具。引用的 Server Actions 尚不存在也没关系:
'use client'
import { useState, useEffect } from 'react'
import { subscribeUser, unsubscribeUser, sendNotification } from './actions'
function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}'use client'
import { useState, useEffect } from 'react'
import { subscribeUser, unsubscribeUser, sendNotification } from './actions'
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding)
.replace(/\\-/g, '+')
.replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}现在,我们添加一个组件来管理订阅、取消订阅和发送推送通知。
function PushNotificationManager() {
const [isSupported, setIsSupported] = useState(false)
const [subscription, setSubscription] = useState<PushSubscription | null>(
null
)
const [message, setMessage] = useState('')
useEffect(() => {
if ('serviceWorker' in navigator && 'PushManager' in window) {
setIsSupported(true)
registerServiceWorker()
}
}, [])
async function registerServiceWorker() {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
updateViaCache: 'none',
})
const sub = await registration.pushManager.getSubscription()
setSubscription(sub)
}
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!
),
})
setSubscription(sub)
const serializedSub = JSON.parse(JSON.stringify(sub))
await subscribeUser(serializedSub)
}
async function unsubscribeFromPush() {
await subscription?.unsubscribe()
setSubscription(null)
await unsubscribeUser()
}
async function sendTestNotification() {
if (subscription) {
await sendNotification(message)
setMessage('')
}
}
if (!isSupported) {
return <p>Push notifications are not supported in this browser.</p>
}
return (
<div>
<h3>Push Notifications</h3>
{subscription ? (
<>
<p>You are subscribed to push notifications.</p>
<button onClick={unsubscribeFromPush}>Unsubscribe</button>
<input
type="text"
placeholder="Enter notification message"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<button onClick={sendTestNotification}>Send Test</button>
</>
) : (
<>
<p>You are not subscribed to push notifications.</p>
<button onClick={subscribeToPush}>Subscribe</button>
</>
)}
</div>
)
}function PushNotificationManager() {
const [isSupported, setIsSupported] = useState(false);
const [subscription, setSubscription] = useState(null);
const [message, setMessage] = useState('');
useEffect(() => {
if ('serviceWorker' in navigator && 'PushManager' in window) {
setIsSupported(true);
registerServiceWorker();
}
}, []);
async function registerServiceWorker() {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
updateViaCache: 'none',
});
const sub = await registration.pushManager.getSubscription();
setSubscription(sub);
}
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!
),
});
setSubscription(sub);
await subscribeUser(sub);
}
async function unsubscribeFromPush() {
await subscription?.unsubscribe();
setSubscription(null);
await unsubscribeUser();
}
async function sendTestNotification() {
if (subscription) {
await sendNotification(message);
setMessage('');
}
}
if (!isSupported) {
return <p>Push notifications are not supported in this browser.</p>;
}
return (
<div>
<h3>Push Notifications</h3>
{subscription ? (
<>
<p>You are subscribed to push notifications.</p>
<button onClick={unsubscribeFromPush}>Unsubscribe</button>
<input
type="text"
placeholder="Enter notification message"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<button onClick={sendTestNotification}>Send Test</button>
</>
) : (
<>
<p>You are not subscribed to push notifications.</p>
<button onClick={subscribeToPush}>Subscribe</button>
</>
)}
</div>
);
}最后,我们创建一个组件,用于向 iOS 设备显示一条消息,指导他们安装到主屏幕,并且仅在应用未安装时才显示。
function InstallPrompt() {
const [isIOS, setIsIOS] = useState(false)
const [isStandalone, setIsStandalone] = useState(false)
useEffect(() => {
setIsIOS(
/iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
)
setIsStandalone(window.matchMedia('(display-mode: standalone)').matches)
}, [])
if (isStandalone) {
return null // Don't show install button if already installed
}
return (
<div>
<h3>Install App</h3>
<button>Add to Home Screen</button>
{isIOS && (
<p>
To install this app on your iOS device, tap the share button
<span role="img" aria-label="share icon">
{' '}
⎋{' '}
</span>
and then "Add to Home Screen"
<span role="img" aria-label="plus icon">
{' '}
➕{' '}
</span>
.
</p>
)}
</div>
)
}
export default function Page() {
return (
<div>
<PushNotificationManager />
<InstallPrompt />
</div>
)
}function InstallPrompt() {
const [isIOS, setIsIOS] = useState(false);
const [isStandalone, setIsStandalone] = useState(false);
useEffect(() => {
setIsIOS(
/iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
);
setIsStandalone(window.matchMedia('(display-mode: standalone)').matches);
}, []);
if (isStandalone) {
return null; // Don't show install button if already installed
}
return (
<div>
<h3>Install App</h3>
<button>Add to Home Screen</button>
{isIOS && (
<p>
To install this app on your iOS device, tap the share button
<span role="img" aria-label="share icon">
{' '}
⎋{' '}
</span>
and then "Add to Home Screen"
<span role="img" aria-label="plus icon">
{' '}
➕{' '}
</span>
.
</p>
)}
</div>
);
}
export default function Page() {
return (
<div>
<PushNotificationManager />
<InstallPrompt />
</div>
);
}现在,我们来创建此文件调用的 Server Actions。
在 app/actions.ts 中创建一个新文件来包含您的 Actions。此文件将处理创建订阅、删除订阅和发送通知。
'use server'
import webpush from 'web-push'
webpush.setVapidDetails(
'<mailto:your-email@example.com>',
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
)
let subscription: PushSubscription | null = null
export async function subscribeUser(sub: PushSubscription) {
subscription = sub
// In a production environment, you would want to store the subscription in a database
// For example: await db.subscriptions.create({ data: sub })
return { success: true }
}
export async function unsubscribeUser() {
subscription = null
// In a production environment, you would want to remove the subscription from the database
// For example: await db.subscriptions.delete({ where: { ... } })
return { success: true }
}
export async function sendNotification(message: string) {
if (!subscription) {
throw new Error('No subscription available')
}
try {
await webpush.sendNotification(
subscription,
JSON.stringify({
title: 'Test Notification',
body: message,
icon: '/icon.png',
})
)
return { success: true }
} catch (error) {
console.error('Error sending push notification:', error)
return { success: false, error: 'Failed to send notification' }
}
}'use server';
import webpush from 'web-push';
webpush.setVapidDetails(
'<mailto:your-email@example.com>',
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
);
let subscription= null;
export async function subscribeUser(sub) {
subscription = sub;
// In a production environment, you would want to store the subscription in a database
// For example: await db.subscriptions.create({ data: sub })
return { success: true };
}
export async function unsubscribeUser() {
subscription = null;
// In a production environment, you would want to remove the subscription from the database
// For example: await db.subscriptions.delete({ where: { ... } })
return { success: true };
}
export async function sendNotification(message) {
if (!subscription) {
throw new Error('No subscription available');
}
try {
await webpush.sendNotification(
subscription,
JSON.stringify({
title: 'Test Notification',
body: message,
icon: '/icon.png',
})
);
return { success: true };
} catch (error) {
console.error('Error sending push notification:', error);
return { success: false, error: 'Failed to send notification' };
}
}发送通知将由我们的 Service Worker 处理,Service Worker 在步骤 5 中创建。
在生产环境中,您会将订阅存储在数据库中,以实现跨服务器重启的持久性并管理多个用户的订阅。
要使用 Web 推送 API,您需要生成 VAPID 密钥。最简单的方法是直接使用 web-push 命令行工具:
首先,全局安装 web-push:
npm install -g web-push通过运行以下命令生成 VAPID 密钥:
web-push generate-vapid-keys复制输出并将密钥粘贴到您的 .env 文件中:
NEXT_PUBLIC_VAPID_PUBLIC_KEY=your_public_key_here
VAPID_PRIVATE_KEY=your_private_key_here
为您的 Service Worker 创建一个 public/sw.js 文件:
self.addEventListener('push', function (event) {
if (event.data) {
const data = event.data.json()
const options = {
body: data.body,
icon: data.icon || '/icon.png',
badge: '/badge.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: '2',
},
}
event.waitUntil(self.registration.showNotification(data.title, options))
}
})
self.addEventListener('notificationclick', function (event) {
console.log('Notification click received.')
event.notification.close()
event.waitUntil(clients.openWindow('<https://your-website.com>'))
})此 Service Worker 支持自定义图片和通知。它处理传入的推送事件和通知点击。
icon 和 badge 属性为通知设置自定义图标。vibrate 模式以在支持的设备上创建自定义振动警报。data 属性将额外数据附加到通知。请记住彻底测试您的 Service Worker,以确保它在不同的设备和浏览器中按预期运行。此外,请确保将 notificationclick 事件监听器中的 'https://your-website.com' 链接更新为您应用的适当 URL。
步骤 2 中定义的 InstallPrompt 组件会向 iOS 设备显示一条消息,指导他们将其安装到主屏幕。
为确保您的应用可以安装到移动主屏幕,您必须具备:
满足这些条件时,现代浏览器将自动向用户显示安装提示。您可以使用 beforeinstallprompt 提供自定义安装按钮,但我们不推荐这样做,因为它不具备跨浏览器和平台兼容性(在 Safari iOS 上不起作用)。
为确保您可以在本地查看通知,请确保:
next dev --experimental-https 进行测试安全是任何 Web 应用(尤其是 PWA)的关键方面。Next.js 允许您使用 next.config.js 文件配置安全标头。例如:
module.exports = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
],
},
{
source: '/sw.js',
headers: [
{
key: 'Content-Type',
value: 'application/javascript; charset=utf-8',
},
{
key: 'Cache-Control',
value: 'no-cache, no-store, must-revalidate',
},
{
key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self'",
},
],
},
]
},
}让我们逐一了解这些选项:
X-Content-Type-Options: nosniff: 阻止 MIME 类型嗅探,降低恶意文件上传的风险。X-Frame-Options: DENY: 通过阻止您的站点嵌入到 iframe 中来防止点击劫持攻击。Referrer-Policy: strict-origin-when-cross-origin: 控制请求中包含的 referrer 信息量,平衡安全性和功能性。Content-Type: application/javascript; charset=utf-8: 确保 Service Worker 被正确解释为 JavaScript。Cache-Control: no-cache, no-store, must-revalidate: 防止 Service Worker 被缓存,确保用户始终获得最新版本。Content-Security-Policy: default-src 'self'; script-src 'self': 为 Service Worker 实施严格的内容安全策略,仅允许来自同源的脚本。了解更多关于使用 Next.js 定义 内容安全策略 的信息。