渐进式 Web 应用 (PWA)
渐进式 Web 应用 (PWA) 提供 Web 应用的覆盖范围和可访问性,并结合原生移动应用的功能和用户体验。使用 Next.js,你可以创建 PWA,在所有平台上提供无缝的类似应用的体验,而无需多个代码库或应用商店批准。
¥Progressive Web Applications (PWAs) offer the reach and accessibility of web applications combined with the features and user experience of native mobile apps. With Next.js, you can create PWAs that provide a seamless, app-like experience across all platforms without the need for multiple codebases or app store approvals.
PWAs 允许你:
¥PWAs allow you to:
-
无需等待应用商店批准即可立即部署更新
¥Deploy updates instantly without waiting for app store approval
-
使用单一代码库创建跨平台应用
¥Create cross-platform applications with a single codebase
-
提供类似原生的功能,例如主屏幕安装和推送通知
¥Provide native-like features such as home screen installation and push notifications
使用 Next.js 创建 PWA
¥Creating a PWA with Next.js
1. 创建 Web 应用清单
¥ Creating the Web App Manifest
Next.js 提供使用 App Router 创建 web 应用清单 的内置支持。你可以创建静态或动态清单文件:
¥Next.js provides built-in support for creating a web app manifest using the App Router. You can create either a static or dynamic manifest file:
例如,创建一个 app/manifest.ts
或 app/manifest.json
文件:
¥For example, create a app/manifest.ts
or app/manifest.json
file:
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,提供类似原生应用的体验。
¥This file should contain information about the name, icons, and how it should be displayed as an icon on the user's device. This will allow users to install your PWA on their home screen, providing a native app-like experience.
你可以使用 图标生成器 之类的工具来创建不同的图标集,并将生成的文件放在你的 public/
文件夹中。
¥You can use tools like favicon generators to create the different icon sets and place the generated files in your public/
folder.
2. 实现 Web 推送通知
¥ Implementing Web Push Notifications
所有现代浏览器都支持 Web 推送通知,包括:
¥Web Push Notifications are supported with all modern browsers, including:
-
适用于安装到主屏幕的应用的 iOS 16.4+
¥iOS 16.4+ for applications installed to the home screen
-
适用于 macOS 13 或更高版本的 Safari 16
¥Safari 16 for macOS 13 or later
-
基于 Chromium 的浏览器
¥Chromium based browsers
-
Firefox
这使得 PWAs 成为原生应用的可行替代方案。值得注意的是,你可以触发安装提示而无需离线支持。
¥This makes PWAs a viable alternative to native apps. Notably, you can trigger install prompts without needing offline support.
即使用户没有主动使用你的应用,Web 推送通知也允许你重新吸引用户。以下是在 Next.js 应用中实现它们的方法:
¥Web Push Notifications allow you to re-engage users even when they're not actively using your app. Here's how to implement them in a Next.js application:
首先,让我们在 app/page.tsx
中创建主页组件。为了便于理解,我们将把它分解成更小的部分。首先,我们将添加一些我们需要的导入和实用程序。引用的服务器操作尚不存在也没关系:
¥First, let's create the main page component in app/page.tsx
. We'll break it down into smaller parts for better understanding. First, we’ll add some of the imports and utilities we’ll need. It’s okay that the referenced Server Actions do not yet exist:
'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
}
现在让我们添加一个组件来管理订阅、取消订阅和发送推送通知。
¥Let’s now add a component to manage subscribing, unsubscribing, and sending push notifications.
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 设备安装到主屏幕,并且仅在应用尚未安装时才显示此消息。
¥Finally, let’s create a component to show a message for iOS devices to instruct them to install to their home screen, and only show this if the app is not already installed.
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>
);
}
现在,让我们创建此文件调用的服务器操作。
¥Now, let’s create the Server Actions which this file calls.
3. 实现服务器操作
¥ Implementing Server Actions
创建一个新文件以包含你在 app/actions.ts
处的操作。此文件将处理创建订阅、删除订阅和发送通知。
¥Create a new file to contain your actions at app/actions.ts
. This file will handle creating subscriptions, deleting subscriptions, and sending notifications.
'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' };
}
}
发送通知将由我们在步骤 5 中创建的服务工作者处理。
¥Sending a notification will be handled by our service worker, created in step 5.
在生产环境中,你可能希望将订阅存储在数据库中,以便在服务器重启时保持持久性并管理多个用户的订阅。
¥In a production environment, you would want to store the subscription in a database for persistence across server restarts and to manage multiple users' subscriptions.
4. 生成 VAPID 密钥
¥ Generating VAPID Keys
要使用 Web Push API,你需要生成 VAPID 密钥。最简单的方法是直接使用 web-push CLI:
¥To use the Web Push API, you need to generate VAPID keys. The simplest way is to use the web-push CLI directly:
首先,全局安装 web-push:
¥First, install web-push globally:
npm install -g web-push
通过运行生成 VAPID 密钥:
¥Generate the VAPID keys by running:
web-push generate-vapid-keys
复制输出并将密钥粘贴到你的 .env
文件中:
¥Copy the output and paste the keys into your .env
file:
NEXT_PUBLIC_VAPID_PUBLIC_KEY=your_public_key_here
VAPID_PRIVATE_KEY=your_private_key_here
5. 创建服务工作线程
¥ Creating a Service Worker
为你的服务工作者创建一个 public/sw.js
文件:
¥Create a public/sw.js
file for your service worker:
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'))
})
此服务工作者支持自定义图片和通知。它处理传入的推送事件和通知点击。
¥This service worker supports custom images and notifications. It handles incoming push events and notification clicks.
-
你可以使用
icon
和badge
属性为通知设置自定义图标。¥You can set custom icons for notifications using the
icon
andbadge
properties. -
可以调整
vibrate
模式以在支持的设备上创建自定义振动警报。¥The
vibrate
pattern can be adjusted to create custom vibration alerts on supported devices. -
可以使用
data
属性将其他数据附加到通知。¥Additional data can be attached to the notification using the
data
property.
请记住彻底测试你的服务工作者,以确保它在不同的设备和浏览器上按预期运行。此外,请确保将 notificationclick
事件监听器中的 'https://your-website.com'
链接更新为适合你应用的 URL。
¥Remember to test your service worker thoroughly to ensure it behaves as expected across different devices and browsers. Also, make sure to update the 'https://your-website.com'
link in the notificationclick
event listener to the appropriate URL for your application.
6. 添加到主屏幕
¥ Adding to Home Screen
步骤 2 中定义的 InstallPrompt
组件向 iOS 设备显示一条消息,指示它们安装到主屏幕。
¥The InstallPrompt
component defined in step 2 shows a message for iOS devices to instruct them to install to their home screen.
要确保你的应用可以安装到移动主屏幕,你必须具有:
¥To ensure your application can be installed to a mobile home screen, you must have:
-
有效的 Web 应用清单(在步骤 1 中创建)
¥A valid web app manifest (created in step 1)
-
通过 HTTPS 提供服务的网站
¥The website served over HTTPS
当满足这些条件时,现代浏览器将自动向用户显示安装提示。你可以使用 beforeinstallprompt
提供自定义安装按钮,但是,我们不建议这样做,因为它不是跨浏览器和平台的(在 Safari iOS 上不起作用)。
¥Modern browsers will automatically show an installation prompt to users when these criteria are met. You can provide a custom installation button with beforeinstallprompt
, however, we do not recommend this as it is not cross browser and platform (does not work on Safari iOS).
7. 本地测试
¥ Testing Locally
要确保你可以在本地查看通知,请确保:
¥To ensure you can view notifications locally, ensure that:
-
¥You are running locally with HTTPS
-
使用
next dev --experimental-https
进行测试¥Use
next dev --experimental-https
for testing
-
-
你的浏览器(Chrome、Safari、Firefox)已启用通知
¥Your browser (Chrome, Safari, Firefox) has notifications enabled
-
在本地提示时,接受使用通知的权限
¥When prompted locally, accept permissions to use notifications
-
确保整个浏览器的通知未被全局禁用
¥Ensure notifications are not disabled globally for the entire browser
-
如果你仍未看到通知,请尝试使用其他浏览器进行调试
¥If you are still not seeing notifications, try using another browser to debug
-
8. 保护你的应用
¥ Securing your application
安全性是任何 Web 应用的关键方面,尤其是对于 PWA。Next.js 允许你使用 next.config.js
文件配置安全标头。例如:
¥Security is a crucial aspect of any web application, especially for PWAs. Next.js allows you to configure security headers using the next.config.js
file. For example:
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'",
},
],
},
]
},
}
让我们来看看这些选项中的每一个:
¥Let’s go over each of these options:
-
全局标头(应用于所有路由):
¥Global Headers (applied to all routes):
-
X-Content-Type-Options: nosniff
:防止 MIME 类型嗅探,降低恶意文件上传的风险。¥
X-Content-Type-Options: nosniff
: Prevents MIME type sniffing, reducing the risk of malicious file uploads. -
X-Frame-Options: DENY
:通过防止你的网站嵌入 iframe 来防止点击劫持攻击。¥
X-Frame-Options: DENY
: Protects against clickjacking attacks by preventing your site from being embedded in iframes. -
Referrer-Policy: strict-origin-when-cross-origin
:控制请求中包含多少引荐来源信息,平衡安全性和功能性。¥
Referrer-Policy: strict-origin-when-cross-origin
: Controls how much referrer information is included with requests, balancing security and functionality.
-
-
服务工作线程特定标头:
¥Service Worker Specific Headers:
-
Content-Type: application/javascript; charset=utf-8
:确保服务工作者被正确解释为 JavaScript。¥
Content-Type: application/javascript; charset=utf-8
: Ensures the service worker is interpreted correctly as JavaScript. -
Cache-Control: no-cache, no-store, must-revalidate
:防止服务工作者缓存,确保用户始终获得最新版本。¥
Cache-Control: no-cache, no-store, must-revalidate
: Prevents caching of the service worker, ensuring users always get the latest version. -
Content-Security-Policy: default-src 'self'; script-src 'self'
:为服务工作者实现严格的内容安全策略,仅允许来自同一来源的脚本。¥
Content-Security-Policy: default-src 'self'; script-src 'self'
: Implements a strict Content Security Policy for the service worker, only allowing scripts from the same origin.
-
了解有关使用 Next.js 定义 内容安全策略 的更多信息。
¥Learn more about defining Content Security Policies with Next.js.
下一步
¥Next Steps
-
探索 PWA 功能:PWA 可以利用各种 Web API 来提供高级功能。考虑探索后台同步、定期后台同步或文件系统访问 API 等功能以增强你的应用。有关 PWA 功能的灵感和最新信息,你可以参考 PWA 今天能做什么 等资源。
¥Exploring PWA Capabilities: PWAs can leverage various web APIs to provide advanced functionality. Consider exploring features like background sync, periodic background sync, or the File System Access API to enhance your application. For inspiration and up-to-date information on PWA capabilities, you can refer to resources like What PWA Can Do Today.
-
静态导出:如果你的应用不需要运行服务器,而是使用文件的静态导出,你可以更新 Next.js 配置以启用此更改。在 Next.js 静态导出文档 中了解更多信息。但是,你需要从服务器操作转移到调用外部 API,以及将定义的标头移动到代理。
¥Static Exports: If your application requires not running a server, and instead using a static export of files, you can update the Next.js configuration to enable this change. Learn more in the Next.js Static Export documentation. However, you will need to move from Server Actions to calling an external API, as well as moving your defined headers to your proxy.
-
离线支持:要提供离线功能,一种选择是使用 Next.js 的 Serwist。你可以在其 documentation 中找到如何将 Serwist 与 Next.js 集成的示例。注意:此插件当前需要 webpack 配置。
¥Offline Support: To provide offline functionality, one option is Serwist with Next.js. You can find an example of how to integrate Serwist with Next.js in their documentation. Note: this plugin currently requires webpack configuration.
-
安全注意事项:确保你的服务工作者得到适当的保护。这包括使用 HTTPS、验证推送消息的来源以及实现适当的错误处理。
¥Security Considerations: Ensure that your service worker is properly secured. This includes using HTTPS, validating the source of push messages, and implementing proper error handling.
-
用户体验:考虑实现渐进式增强技术,以确保即使用户的浏览器不支持某些 PWA 功能,你的应用也能正常运行。
¥User Experience: Consider implementing progressive enhancement techniques to ensure your app works well even when certain PWA features are not supported by the user's browser.