Skip to content
Anonymous

實作 PWA 升級指北:以「物品期限追蹤」App 為例的開發與避坑指南

漸進式網頁應用程式 (PWA) 能為 Web 專案帶來原生的安裝與離線體驗。本篇文章以我們最近升級的「物品期限追蹤」App 為實戰案例,詳細解構從 Manifest、Service Worker 到 iOS 相容性與 SSR 水合錯誤的各種實務細節與大坑。

#PWA #Service Worker #React #iOS #Tutorial

漸進式網頁應用程式 (Progressive Web App, PWA) 長期以來被視為打破 Web 與 Native App 邊界的關鍵技術。它讓網站可以被「安裝」到使用者的主畫面、支援離線瀏覽,甚至能發送系統層級的推播通知。

最近,我們為專案中的「物品期限追蹤 (Inventory Expiry Tracker)」App 進行了 PWA 升級。這不只是一次功能迭代,更是一場與各家瀏覽器(尤其是 iOS Safari)相容性搏鬥的實戰。這篇文章將以本次升級為例,分享 PWA 實作的核心要點以及我們踩過的所有「坑」。

1. 為什麼要升級 PWA?(需求背景)

「物品期限追蹤」是一個透過 Cloudflare D1 與 Access 建置的 Web 應用,使用者用它來記錄食物、藥品或證件的到期日。

傳統網頁最大的痛點在於:缺乏主動提醒的能力,且每次開啟都要重新連線。

透過 PWA 升級,我們達成了三大目標:

  1. 可安裝性 (Installability):讓使用者一鍵把 App 加到手機主畫面,體驗如同原生應用程式。
  2. 到期推播通知 (Push Notifications):在物品即將到期時,透過瀏覽器送出本機通知。
  3. 離線快取 (Offline Cache):在沒有網路或訊號極差的環境,依舊能打開 App 查看最後一次同步的物品清單。

2. PWA 的三大核心基石實作

基石一:Web App Manifest (應用程式清單)

Manifest 是一份 JSON 檔案(通常命名為 manifest.json),它告訴瀏覽器你的 App 長什麼樣子:名稱、圖示大小、主題顏色以及啟動網址。

{
  "name": "物品期限追蹤",
  "short_name": "期限追蹤",
  "start_url": "/apps/inventory",
  "display": "standalone",
  "theme_color": "#00ff88",
  "icons": [
    {
      "src": "/icons/inventory-192.png",
      "sizes": "192x192",
      "type": "image/png"
    }
  ]
}

💡 開發建議與避坑:

  • 純 SVG 是行不通的:雖然現代 Web 開發愛用 SVG,但如果要讓 PWA 順利被安裝,強烈建議提供至少 192x192 甚至 512x512 解析度的 PNG 圖示,因為 iOS 對於 PWA icon 的支援極為挑剔。
  • 善用 Shortcuts (捷徑):在 manifest 裡加入 shortcuts 陣列,使用者長按手機主畫面的 App 圖示時,就能直接跳出「新增物品」等快速操作,這能大幅提升原生感。

基石二:Service Worker (核心引擎)

Service Worker 是一支在背景獨立運作的 JavaScript 檔案,它是 PWA 的靈魂。我們實作了以下幾個關鍵事件:

  1. installactivate:負責預先快取 (Precache) 必要的靜態資源(CSS、字型),並在更新版本時清理舊快取。
  2. fetch (攔截網路請求):我們採用了 Network First (網路優先) 策略。
    • 原理:優先向伺服器要最新資料;如果斷線或超時,就退回來尋找快取 (Cache Storage) 中的舊畫面。這保證了使用者在離線時不會只看到一隻小恐龍。

基石三:本地推播與 IndexedDB

一般講到 PWA 通知,大家會聯想到需要架設一個 Node.js 後端搭配 VAPID keys 使用 Push API 來推播。但在我們的案例中,我們不依賴伺服器推播

  1. 主畫面(React元件)在每次更新物品清單時,會透過 postMessage 把最新資料傳給 Service Worker。
  2. Service Worker 接手後,將清單存入瀏覽器的 IndexedDB
  3. 當 App 啟動時,Service Worker 會檢查資料庫裡有沒有「今天或最近 N 天內要到期」的物品,並利用 self.registration.showNotification() 直接觸發系統通知。
  4. 為了避免每次開網頁都跳通知轟炸使用者,必須把已通知過的紀錄 (Notification History) 也存一筆在 IndexedDB 裡做防呆。

3. 魔鬼藏在細節裡:我們踩過的雷與解法

在開發與部署的過程中,如果沒有遇到問題,那大概是還沒放到生產環境(笑)。以下是這次升級的經典報錯與除錯精華。

坑一:React SSR 與 window 的 Hydration 衝突

當我們在 InventoryKanban.tsx 裡快樂地寫下這行判斷時:

{'Notification' in window && notifyStatus !== 'granted' && (
  <button onClick={handleEnableNotify}>開啟提醒</button>
)}

編譯通過了,但一部署到產品環境,畫面卻整片空白,開發者工具噴出大大的 ReferenceError: window is not defined 與 React Hydration 錯誤 (#418, #423)。

🔥 問題原因: 專案使用 Astro 進行 Server-Side Rendering (伺服器端渲染)。在 Server 端(Node.js / Cloudflare Workers 環境裡)是沒有 windowNotification 這些瀏覽器專屬物件的。當 Server 產生的 HTML(沒有按鈕)傳到終端,遇到 Client 端 React(發現有 window,準備渲染按鈕)時,兩邊比對 DOM 結構不一致,導致水合 (Hydration) 失敗,整組壞掉。

✅ 解決方案:延遲渲染 (isMounted 模式) 必須加入一個 isMounted 狀態,確保這塊 UI 只有在用戶端載入完成後才執行渲染:

const [isMounted, setIsMounted] = React.useState(false);
useEffect(() => setIsMounted(true), []);

{isMounted && typeof window !== 'undefined' && 'Notification' in window && (
  /* 安全渲染 PWA 專屬按鈕 */
)}

坑二:iOS Safari 的傲嬌與堅持

在 Android 或 Chrome 開發 PWA 是一種享受:只要符合標準,瀏覽器會主動拋出 beforeinstallprompt 事件,讓你做出漂亮的「安裝為應用程式」自訂按鈕。

但當你滿懷期待打開 iPhone 時,按鈕就是死活不出來。

🔥 問題原因: iOS Safari 至今不支援 beforeinstallprompt API。此外,Apple 還有個更嚴格的規定:一般 Safari 網頁裡的 Notification API 是殘廢的;使用者必須手動從分享選單「加入主畫面 (Add to Home Screen)」,並且從主畫面啟動該 PWA 版 App 後,才能請求與發送通知。

✅ 解決方案:針對 iOS 顯示手動操作指引 我們必須透過 User Agent 判斷這是不是 iPhone/iPad,如果是,就在原本的安裝按鈕位置,改為顯示「iOS 安裝指引」:告訴使用者必須手動點擊下方「分享」圖標再點擊「加入主畫面」。

坑三:Service Worker 的 Scope 陷阱

🔥 問題狀況: 我們發現 /apps/inventory 這個頁面無論怎麼重整,Service Worker 都攔截不到前端傳來的 postMessage,IndexedDB 始終空空如也。

✅ 解決方案:留意結尾的反斜線 原來我們在註冊 SW 時寫成了 { scope: '/apps/inventory/' }(多了一個斜線)。這導致 /apps/inventory(網址列沒有斜線時)被視為「不在控制範圍內」。 將其精準修正為 /apps/inventory/apps/ 即可瞬間解除封印。在使用 Service Worker 時,作用域 (Scope) 路徑的精確度至關重要


4. 結語:PWA 仍然是門好投資

把現有的 Web 應用升級為 PWA 的 CP 值非常高,只需要撰寫 manifest 與幾百行的 Vanilla JS (Service Worker),就能讓普通的網頁擁有媲美 Native App 的桌面體驗與離線能力。

透過本次「物品期限追蹤」的升級實戰,我們解決了 SSR 水合地雷、並克服了 iOS 上的環境限制。希望這份紀錄能成為你下次實作 PWA 時的最佳避坑指南!