跳至内容
构建你的应用配置渐进式 Web 应用 (PWA)

渐进式 Web 应用 (PWA)

渐进式 Web 应用 (PWA) 结合了 Web 应用的覆盖范围和易访问性以及原生移动应用的功能和用户体验。使用 Next.js,你可以创建提供跨所有平台的无缝应用体验的 PWA,而无需多个代码库或应用商店审批。

PWA 允许你

  • 即时部署更新,无需等待应用商店审批
  • 使用单个代码库创建跨平台应用
  • 提供类似原生的功能,例如主屏幕安装和推送通知

使用 Next.js 创建 PWA

1. 创建 Web 应用清单

Next.js 提供了使用 App 路由器创建 Web 应用清单 的内置支持。你可以创建静态或动态清单文件。

例如,创建一个 app/manifest.tsapp/manifest.json 文件。

app/manifest.ts
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',
      },
    ],
  }
}

此文件应包含有关名称、图标以及如何在用户设备上显示为图标的信息。这将允许用户将其 PWA 安装到其主屏幕上,提供类似原生应用的体验。

你可以使用 favicon 生成器 等工具创建不同的图标集并将生成的文件放在你的 public/ 文件夹中。

2. 实现 Web 推送通知

Web 推送通知在所有现代浏览器中都受支持,包括

  • 安装到主屏幕的 iOS 16.4+ 应用
  • macOS 13 或更高版本的 Safari 16
  • 基于 Chromium 的浏览器
  • Firefox

这使得 PWA 成为原生应用的可行替代方案。值得注意的是,你可以在无需脱机支持的情况下触发安装提示。

Web 推送通知允许你即使在用户没有积极使用你的应用时也能重新吸引他们。以下是在 Next.js 应用中实现它们的方法。

首先,让我们在 app/page.tsx 中创建主页面组件。我们将将其分解成更小的部分以更好地理解。首先,我们将添加一些我们需要的导入和实用程序。引用的服务器操作尚不存在,这没关系。

'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
}

现在,让我们添加一个组件来管理订阅、取消订阅和发送推送通知。

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)
    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>
  )
}

现在,让我们创建此文件调用的服务器操作。

3. 实现服务器操作

创建一个新文件以在 app/actions.ts 中包含你的操作。此文件将处理创建订阅、删除订阅和发送通知。

app/actions.ts
'use server'
 
import webpush from 'web-push'
 
webpush.setVapidDetails(
  '<mailto:[email protected]>',
  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' }
  }
}

发送通知将由我们在步骤 5 中创建的服务工作线程处理。

在生产环境中,你希望将订阅存储在数据库中,以便在服务器重新启动时保持持久性并管理多个用户的订阅。

4. 生成 VAPID 密钥

要使用 Web Push API,您需要生成 VAPID 密钥。

创建一个脚本文件,例如:generate-vapid-keys.js

./generate-vapid-keys.js
const webpush = require('web-push')
const vapidKeys = webpush.generateVAPIDKeys()
 
console.log('Paste the following keys in your .env file:')
console.log('-------------------')
console.log('NEXT_PUBLIC_VAPID_PUBLIC_KEY=', vapidKeys.publicKey)
console.log('VAPID_PRIVATE_KEY=', vapidKeys.privateKey)

使用 Node.js 运行此脚本以生成您的 VAPID 密钥

终端
node generate-vapid-keys.js

复制输出并将其粘贴到您的 .env 文件中。

5. 创建服务工作线程

为您的服务工作线程创建一个 public/sw.js 文件

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>'))
})

此服务工作线程支持自定义图像和通知。它处理传入的推送事件和通知点击。

  • 您可以使用 iconbadge 属性为通知设置自定义图标。
  • 可以调整 vibrate 模式以在支持的设备上创建自定义振动警报。
  • 可以使用 data 属性将其他数据附加到通知。

请记住彻底测试您的服务工作线程,以确保它在不同的设备和浏览器上都能按预期工作。此外,请确保将 'https://your-website.com' 链接更新为应用程序的相应 URL(在 notificationclick 事件监听器中)。

6. 添加到主屏幕

步骤 2 中定义的 InstallPrompt 组件会显示一条消息,指导 iOS 设备将其安装到主屏幕。

要确保您的应用程序可以安装到移动主屏幕,您必须拥有

  1. 有效的 Web 应用清单(在步骤 1 中创建)
  2. 通过 HTTPS 提供服务的网站

满足这些条件时,现代浏览器会自动向用户显示安装提示。您可以使用 beforeinstallprompt 提供自定义安装按钮,但是我们不建议这样做,因为它不是跨浏览器和平台的(在 Safari iOS 上不起作用)。

7. 本地测试

要确保您可以在本地查看通知,请确保

  • 您正在 使用 HTTPS 在本地运行
    • 使用 next dev --experimental-https 进行测试
  • 您的浏览器(Chrome、Safari、Firefox)已启用通知
    • 在本地收到提示时,接受使用通知的权限
    • 确保未全局禁用整个浏览器的通知
    • 如果您仍然没有看到通知,请尝试使用其他浏览器进行调试

8. 保护您的应用程序

安全是任何 Web 应用程序的关键方面,对于 PWA 尤其如此。Next.js 允许您使用 next.config.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'",
          },
        ],
      },
    ]
  },
}

让我们回顾一下这些选项

  1. 全局标头(应用于所有路由)
    1. X-Content-Type-Options: nosniff:防止 MIME 类型嗅探,降低恶意文件上传的风险。
    2. X-Frame-Options: DENY:通过防止您的网站嵌入 iframe 中来防止点击劫持攻击。
    3. Referrer-Policy: strict-origin-when-cross-origin:控制包含在请求中的引用程序信息量,在安全性和功能性之间取得平衡。
  2. 服务工作线程特定标头
    1. Content-Type: application/javascript; charset=utf-8:确保服务工作线程被正确解释为 JavaScript。
    2. Cache-Control: no-cache, no-store, must-revalidate:防止缓存服务工作线程,确保用户始终获得最新版本。
    3. Content-Security-Policy: default-src 'self'; script-src 'self':为服务工作线程实施严格的内容安全策略,仅允许来自同一来源的脚本。

了解有关使用 Next.js 定义 内容安全策略 的更多信息。

后续步骤

  1. 探索 PWA 功能:PWA 可以利用各种 Web API 提供高级功能。考虑探索诸如后台同步、定期后台同步或文件系统访问 API 等功能,以增强您的应用程序。您可以参考诸如 PWA 现可实现的功能 等资源以获取灵感和有关 PWA 功能的最新信息。
  2. 静态导出:如果您的应用程序不需要运行服务器,而是使用文件的静态导出,您可以更新 Next.js 配置以启用此更改。在 Next.js 静态导出文档 中了解更多信息。但是,您需要从服务器操作切换到调用外部 API,并将定义的标头移动到您的代理。
  3. 脱机支持:要提供脱机功能,一种选择是使用 Next.js 中的 Serwist。您可以在其 文档 中找到有关如何将 Serwist 与 Next.js 集成的示例。注意:此插件目前需要 Webpack 配置。
  4. 安全注意事项:确保您的服务工作线程得到妥善保护。这包括使用 HTTPS、验证推送消息的来源以及实施适当的错误处理。
  5. 用户体验:考虑实施渐进增强技术,以确保即使用户浏览器不支持某些 PWA 功能,您的应用程序也能正常运行。