跳至內容
建置您的應用程式設定漸進式網頁應用程式 (PWA)

漸進式網頁應用程式 (PWA)

漸進式網頁應用程式 (PWA) 結合了網頁應用程式的觸及範圍和易用性,以及原生行動應用程式的功能和使用者體驗。使用 Next.js,您可以建立 PWA,在所有平台上提供無縫的應用程式般體驗,而無需多個程式碼庫或應用程式商店的批准。

PWA 允許您

  • 無需等待應用程式商店審核,即可立即部署更新
  • 使用單一程式碼庫建立跨平台應用程式
  • 提供類似原生應用程式的功能,例如主螢幕安裝和推播通知

使用 Next.js 建立 PWA

1. 建立 Web 應用程式資訊清單

Next.js 使用應用程式路由器提供內建支援來建立 網頁應用程式資訊清單。您可以建立靜態或動態資訊清單檔案

例如,建立一個 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 安裝到主螢幕上,提供類似原生應用程式的體驗。

您可以使用像 網站圖示產生器 之類的工具來建立不同的圖示集,並將產生的檔案放置在您的 public/ 資料夾中。

2. 實作網頁推播通知

所有現代瀏覽器都支援網頁推播通知,包括

  • 安裝到主螢幕的應用程式,適用於 iOS 16.4 以上版本
  • 適用於 macOS 13 或更高版本的 Safari 16
  • 基於 Chromium 的瀏覽器
  • Firefox

這使得 PWA 成為原生應用程式可行的替代方案。值得注意的是,您可以在不需要離線支援的情況下觸發安裝提示。

即使使用者沒有 actively 使用您的應用程式,網頁推播通知也能讓您再次吸引他們。以下是在 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: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' }
  }
}

發送通知將由我們在步驟 5 中建立的服務工作線程 (Service Worker) 處理。

在正式環境中,您會希望將訂閱儲存在資料庫中,以便在伺服器重新啟動後持續存在,並管理多個使用者的訂閱。

4. 產生 VAPID 金鑰

要使用 Web 推播 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. 建立服務工作線程 (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>'))
})

此服務工作線程支援自訂圖片和通知。它會處理傳入的推播事件和通知點擊。

  • 您可以使用 iconbadge 屬性設定通知的自訂圖示。
  • 可以調整 vibrate 模式,以便在支援的裝置上建立自訂震動提示。
  • 可以使用 data 屬性將額外資料附加到通知中。

請務必徹底測試您的服務工作線程,以確保它在不同裝置和瀏覽器上的行為符合預期。此外,請務必將 notificationclick 事件監聽器中的 'https://your-website.com' 連結更新為您的應用程式適用的網址。

6. 新增至主畫面

在步驟 2 中定義的 InstallPrompt 元件會顯示一條訊息給 iOS 裝置,指示他們安裝至主畫面。

為了確保您的應用程式可以安裝到行動裝置主螢幕,您必須具備:

  1. 一個有效的網路應用程式資訊清單(在步驟 1 中建立)
  2. 網站透過 HTTPS 提供服務

符合這些條件時,現代瀏覽器會自動向使用者顯示安裝提示。您可以使用 beforeinstallprompt 提供自訂的安裝按鈕,然而,我們不建議這樣做,因為它並非跨瀏覽器和平台(在 iOS Safari 上無法運作)。

7. 本機測試

為了確保您可以在本機檢視通知,請確認:

  • 您正在 使用 HTTPS 執行本機開發
    • 使用 next dev --experimental-https 進行測試
  • 您的瀏覽器(Chrome、Safari、Firefox)已啟用通知
    • 在本機收到提示時,接受使用通知的權限
    • 確保沒有在整個瀏覽器中全域停用通知
    • 如果您仍然沒有看到通知,請嘗試使用其他瀏覽器進行除錯

8. 保護您的應用程式安全

安全性是任何網路應用程式的一個關鍵面向,尤其是對於 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 可以利用各種網頁 API 來提供進階功能。您可以考慮探索像是背景同步、定期背景同步或檔案系統存取 API 等功能,來增強您的應用程式。如需 PWA 功能的靈感和最新資訊,您可以參考像是 What PWA Can Do Today 等資源。
  2. 靜態匯出:如果您的應用程式不需要運行伺服器,而是使用靜態匯出的檔案,您可以更新 Next.js 設定來啟用此變更。在 Next.js 靜態匯出說明文件 中了解更多資訊。但是,您需要將伺服器動作改為呼叫外部 API,並將您定義的標頭移至您的代理伺服器。
  3. 離線支援:要提供離線功能,一個選項是將 Serwist 與 Next.js 搭配使用。您可以在他們的 說明文件 中找到如何將 Serwist 與 Next.js 整合的範例。注意:此插件目前需要 webpack 設定。
  4. 安全性考量:確保您的服務工作程式已妥善保護。這包括使用 HTTPS、驗證推播訊息的來源,以及實作正確的錯誤處理。
  5. 使用者體驗:請考慮實作漸進式增強技術,以確保即使使用者的瀏覽器不支援某些 PWA 功能,您的應用程式也能正常運作。

後續步驟