# Progressive Web App(PWA)30 天完整學習筆記

本筆記整理自 iThome 2017 iT 邦幫忙鐵人賽系列文章《30 天 Progressive Web App 學習筆記》(作者:iamya),共 30 篇文章。內容經過重新整理、補充原理說明與設計思路分析。

原系列連結:https://ithelp.ithome.com.tw/users/20071512/ironman/1222


# 目錄

  1. PWA 是什麼、為什麼需要它
  2. 從靜態網站到 SPA:網站演進史
  3. App Shell 架構:PWA 效能的核心秘密
  4. RAIL 效能模型與 Critical Rendering Path
  5. Optimizing Content Efficiency:資源最佳化
  6. Service Worker:PWA 的技術心臟
  7. Chrome DevTools 除錯工具
  8. PWA 實際案例分析
  9. 動手實作:Vanilla JS 版 To-Do List PWA
  10. Web App Manifest:讓網站更像 App
  11. 自動化生成 Service Worker:sw-precache 與 webpack plugin
  12. React + Redux 版本實作
  13. Offline Storage 選型策略
  14. 總結與延伸學習方向

# 1. PWA 是什麼、為什麼需要它

# 1.1 網頁與 App 各自的痛點

要理解 PWA 為什麼誕生,得先回到「網頁」與「原生 App」這兩種體驗長期存在的矛盾。

網頁的痛點:

  • 手機網路不穩定、速度慢:Google 官方數據指出,載入超過 3 秒,53% 的使用者會直接放棄瀏覽。在傳統 SPA(Single Page Application)架構下,使用者要先下載 HTML/CSS/JS,再發送 API 請求取得 JSON、才能組出畫面,網路品質差時這個「取得完整內容」的過程會被放大成使用者可感知的痛苦等待。
  • 使用者體驗不如原生 App:原生 App 有 Splash Screen(開場過場動畫)、有推播通知(push notification)可以主動把使用者拉回來;傳統網頁兩者都沒有,互動黏著度天生就弱一截。

原生 App 的痛點:

  • 開發成本高:Native App 通常要分別聘請 iOS/Android/Windows 工程師;Hybrid App 雖然共用一份程式碼,但仍要透過 Cordova 之類的工具打包,並且要遵守各家 App Store 的審核規範,改一版就要重跑一次審核流程。
  • 安裝路徑太長、流失率高:Chrome Dev Summit 2015 中 Alex Russell 引用研究指出,安裝 App 的每一個步驟大約會流失 20% 的使用者 —— 打開 App Store、搜尋、點安裝、同意權限、等待下載……1000 人的漏斗最後可能只剩下 260 人左右。相較之下,網頁只要「點一下連結」就能造訪,沒有這道摩擦力。
  • 使用者對「安裝」本身有戒心:許多使用者不想安裝太多 App,擔心佔用手機容量、擔心過度授權隱私權限;在網路資費昂貴、Wi-Fi 難找的地區,下載和更新 App 的成本更是雪上加霜。而 comScore 的調查也顯示,行動網頁的整體瀏覽量是 App 的將近三倍 ——App 有「不容易被搜尋、不容易被分享」的天生劣勢,網頁反而在這件事上贏了 App。

這裡是理解 PWA 設計哲學的關鍵:PWA 並不是「發明一套全新技術」,而是觀察到「網頁的優勢」(易分享、免安裝、可搜尋)與「App 的優勢」(快、可離線、可推播、有主畫面圖示)剛好互補,於是提出一套漸進式的技術規範,讓網頁「借用」App 的體驗優點,同時完全不犧牲網頁原本的可傳播性。這也是為什麼名字裡有「Progressive(漸進式)」—— 它不要求你一次做到位,而是允許你在支援的瀏覽器裡逐步疊加能力,不支援的環境則優雅降級(graceful degradation),回到普通網頁的基本體驗。

# 1.2 PWA 的正式定義與十大特性

PWA(Progressive Web App)這個名詞由設計師 Frances Berriman 與 Google Chrome 工程師 Alex Russell 在 2015 年提出。Google 官方文件列出 PWA 應具備的十項特性:

特性 說明
Progressive 漸進式:不論瀏覽器支援程度,都能提供基本體驗,環境許可時再疊加進階功能
Responsive 響應式:能在各種裝置尺寸下做最佳化顯示
Connectivity independent 不依賴網路連線:透過 Service Worker,在低頻寬甚至離線時仍可瀏覽
App-like 像 App 一樣:瀏覽速度、互動手感貼近原生體驗
Fresh 保持新鮮:Service Worker 能在背景自動更新內容
Safe 安全:必須透過 HTTPS 提供服務,確保傳輸資料的完整性
Discoverable 可被發現:能被搜尋引擎索引
Re-engageable 可再互動:透過推播通知等機制,主動把使用者找回來
Installable 可安裝:Add to Home Screen,像 App 一樣加到手機桌面
Linkable 可連結分享:透過 URL 就能無摩擦地分享、傳遞

為什麼特別強調「Safe(必須 HTTPS)」? 因為 Service Worker 擁有攔截、竄改所有網路請求的能力,如果透過不安全的 HTTP 連線註冊,就等於給了中間人攻擊者一個「劫持你所有網路流量」的後門,所以規格上直接鎖死:Service Worker 只能在 localhost (方便本機開發)或 HTTPS 環境下運作,這是刻意的安全設計,不是限制而是保護。

# 1.3 真實案例:Flipkart Lite 的啟示

印度電商龍頭 Flipkart 在 2015 年一度因為行動版網站體驗太差而直接關閉 mobile website,2016 年改用 PWA 技術重新推出「Flipkart Lite」,成效相當驚人:

  • 使用者平均停留時間從 70 秒拉長到 3.5 分鐘(3 倍)
  • 顧客回流率提升 40%
  • 透過「加入主畫面」進站的使用者轉換率提升 70%
  • 資料傳輸量降低 3 倍

這個案例之所以重要,是因為它精準點出 PWA 最大的受益場景:行動網路品質不佳、資費昂貴、且使用者對「安裝 App」有心理門檻的市場。印度當時 70% 地區仍使用 2G 網路,PWA 的離線快取特性剛好對症下藥。這也解釋了為什麼 PWA 在新興市場(印度、東南亞)比在已開發國家更早獲得爆發性採用。


# 2. 從靜態網站到 SPA:網站演進史

理解 PWA 為何長成現在這個樣子,需要先理解網站架構的演進脈絡,因為 PWA 本質上是這條演進路線的「下一步」。

# 2.1 靜態網站 → 動態網站(Server Render)

最早期的網站是純靜態頁面:每個網址對應一個獨立的 HTML 檔案。缺點很直觀 —— 如果網站有 100 頁,共用的 LOGO 圖檔要換,就要手動改 100 次。

於是出現了「動態網站」:伺服器上跑一支程式,依照網址(route)決定該從資料庫撈什麼資料、再用樣板引擎(Node/EJS、PHP/Laravel、Ruby on Rails/ERB 等)拼出 HTML 回傳。同一支程式可以產生無數種頁面內容,管理成本大幅降低。

但動態網站有個先天限制:每次換頁都是整頁重新請求、整頁刷新。使用者沒辦法「邊聽音樂邊看文章」,因為切換頁面等於瀏覽器重新載入整份文件,任何正在執行中的 JS 狀態(音樂播放器)都會被砍掉重來。

# 2.2 SPA(Single Page Application)的興起與代價

為了解決「連續性操作」的需求,SPA 應運而生。核心技術是 AJAX:透過 JavaScript 發送請求拿到資料後,直接在前端置換 DOM,不必整頁刷新。2006 年 Gmail 就是知名的早期案例,體驗媲美桌面應用程式;Spotify 早期網頁版也採用同樣策略,讓使用者「點進去就能聽」,比下載 App 更容易推廣。

隨著 SPA 邏輯越來越複雜,Backbone.js、Ember.js、Angular.js 這類框架陸續出現,用來管理 DOM 抽換與資料綁定。但 SPA 也帶來新問題:

  • 首次載入很慢:因為所有邏輯都打包在 JS 裡,使用者要等 JS 全部下載、解析、執行完才看得到內容。
  • JavaScript 檔案過大:功能越豐富,bundle 就越肥。
  • SEO 不友善:搜尋引擎爬蟲若不執行 JS,就看不到內容(雖然現代爬蟲已大幅改善,但當年是硬傷)。

這正是 PWA 要解決的下一道題目:SPA 已經把「App 般的互動流暢度」做出來了,但犧牲了「首次載入速度」與「離線可用性」。PWA 的核心命題就是:能不能讓網頁保留住 SPA 的優勢,同時解決它引入的新問題? 答案就是接下來要談的 App Shell 架構與 Service Worker。


# 3. App Shell 架構:PWA 效能的核心秘密

# 3.1 什麼是 App Shell?

App Shell 是 PWA 最重要的架構思想之一。核心概念很簡單:把網站切分成「不常變動的外殼(Shell)」與「常常變動的內容(Content)」兩部分。

  • Shell:Logo、導覽列(Navbar)、版面骨架、固定的 CSS/JS—— 這些東西幾乎不會變。
  • Content:文章內容、清單資料、使用者輸入 —— 這些才是每次造訪可能不同的部分。

透過 Service Worker,把 Shell 這部分的檔案(最小化的 HTML/CSS/JS)在第一次造訪時快取到本機,之後再次造訪時就能「立即」把 Shell 顯示出來,不用再等網路請求。等 Shell 出現後,再非同步去抓真正的內容資料填進去。

可以把這個概念類比成建構原生 App 時「打包 APK/IPA」的動作 —— 這份打包好的殼只包含程式邏輯與核心元件,不含任何動態資料,之後要顯示的資料是另外透過 API 拉取的。

# 3.2 為什麼要這樣設計?「感知效能 > 實際效能」

App Shell 設計背後有一個關鍵的心理學原理:

感知效能(Perceived Performance)比實際效能(Actual Performance)更重要。

同樣是「網站真正完全就緒」要花 3 秒,如果使用者在 0.3 秒看到 Logo 和版面骨架出現、緊接著內容陸續填入,體感速度會遠比「螢幕整整空白 3 秒、內容一次全部跳出來」要快很多。這是因為:

  • 不確定的等待感覺更長:空白畫面沒有給使用者任何回饋,使用者無法判斷網站是當機了還是正在載入,這種不確定性本身就會拉長主觀等待時間。
  • 漸進式呈現提供「進度感」:先看到殼、再看到內容,等於給了使用者一個進度指標,即使總時間沒變,體驗也會顯著改善。

這也是為什麼原生 App 普遍會做 Splash Screen—— 不是單純好看,而是刻意用視覺回饋來「欺騙」等待感。

# 3.3 為什麼不整包一起 cache,而要拆成殼跟內容分開?

如果不拆分,直接把整個頁面(含當前資料)整包快取,會有兩個問題:

  1. 資料新鮮度問題:如果整包一起快取,下次打開時看到的內容可能是過期的舊資料,而且很難只更新其中一小部分 —— 要嘛全部重抓,要嘛全部沿用舊的。
  2. 快取效率問題:Shell 部分(Logo、CSS、JS 框架程式碼)幾乎不會變,理論上可以永久快取、不必每次重新下載;但如果跟內容綁在一起快取,Shell 也會被迫跟著內容的更新頻率一起失效重抓,造成頻寬浪費。

拆分之後,Shell 走快取優先(cache-first)策略 —— 幾乎不變,永久快取、瞬間顯示;Content 走網路優先或 stale-while-revalidate 策略 —— 保證資料新鮮度,同時允許離線時退回舊快取。這種「依照資源變動頻率選擇不同快取策略」的思路,後面會在 Service Worker 的 runtimeCaching 設定中具體落地(見第 11 章)。

# 3.4 實測效益:App Shell 到底快多少?

在 3G 網路、Nexus 5 裝置、Chrome Dev 瀏覽器的測試條件下:

  • Google 官方範例:第一次造訪(無快取)看到畫面要 2.5 秒、完全載入要 7.1 秒;透過 Service Worker 快取後的後續造訪,只要 0.8 秒就能看到畫面。
  • Weather App 案例(Google Codelab):首次造訪 3.0 秒出現畫面、1.9 秒完全 render;快取後只要 0.3 秒出現畫面、0.6 秒完全 render。

兩個案例都顯示:在 3G 網路下,App Shell + Service Worker 大約可以帶來 10 倍的載入速度提升。這正是為什麼在印度這類仍大量依賴 2G/3G 的市場,PWA 帶來的體驗提升特別有感。

# 3.5 如何設計 App Shell?

實務上設計 App Shell 時,可以自問幾個問題:

  • 使用者打開網站的瞬間,「一定」要馬上看到什麼?(First Paint 的內容)
  • 網站最關鍵的 UI 元件是什麼?(Header、Logo、輸入框?)
  • 這些元件依賴哪些資源?(images、CSS、JS)

例如一個 To-Do List 應用,App Shell 通常會包含:Logo、頂部「未完成事項數量」的顯示區塊、輸入框 —— 這些是「殼」;而真正的待辦事項清單內容(從 API 抓來的資料),屬於「內容」,不算進 Shell 裡。


# 4. RAIL 效能模型與 Critical Rendering Path

# 4.1 RAIL Model:以使用者感受為中心的效能指標

RAIL 是 Google 提出的效能評估模型,分別代表四個階段,各自對應不同的時間預算:

階段 全名 時間預算 說明
R Response 回應 < 100ms 從使用者點擊到畫面出現回饋的時間
A Animation 動畫 每幀 < 16ms(60fps) 動畫與捲動要維持流暢
I Idle 閒置 每個工作區塊 < 50ms 善用瀏覽器閒置時間做背景工作
L Load 載入 < 1000ms 網站可互動的時間

為什麼是 100 毫秒? 這個數字不是隨便訂的,而是人類神經反應的生理極限 —— 研究顯示,從動作發生到人類意識到「有延遲」大約需要 100 毫秒,所以只要在這個時間窗口內給出回饋(哪怕只是按鈕變色),使用者就會感覺「即時」。

為什麼是 16 毫秒? 60fps 的螢幕更新,每一幀的時間預算是 1000ms ÷ 60 ≈ 16.7ms。瀏覽器要在這個時間內完成 JavaScript → Style → Layout → Paint → Composite 這一整條像素管線(pixel pipeline)。特別要注意:更改會觸發 Layout(重新排版)的 CSS 屬性(例如 width 、 height )成本很高,動畫時應盡量只改 transform 、 opacity 這類只影響 Composite 階段的屬性,避免掉幀。

為什麼是 50 毫秒的 Idle 區塊? 因為如果背景任務跑太久沒有中斷點,使用者若在這期間做了操作(點擊、輸入),畫面會卡住沒反應。把工作切成 50ms 以內的小區塊(chunk),代表主執行緒隨時能被使用者的操作插隊,保持互動的即時性。

為什麼是 1 秒的 Load? 超過 1 秒會打斷使用者對當前任務的專注力。這裡的重點同樣是「感知」—— 不是要求 1 秒內載完所有東西,而是要在 1 秒內讓使用者感覺「東西已經可以用了」(呼應第 3.2 節的 App Shell 邏輯)。

# 4.2 Critical Rendering Path:瀏覽器怎麼把程式碼變成畫面?

要優化效能,得先理解瀏覽器內部把 HTML/CSS/JS 轉成螢幕像素的完整流程:

  1. 解析 HTML → 建立 DOM Tree:瀏覽器逐行讀取 HTML,建立文件物件模型樹。
  2. 解析 CSS → 建立 CSSOM Tree:遇到 <link rel="stylesheet"> 時,下載並解析 CSS,建立樣式規則樹。
  3. 合併 DOM + CSSOM → Render Tree:只包含「會被畫出來」的節點( display: none 的元素不會出現在這裡)。
  4. Layout(排版):計算每個節點在畫面上的確切位置與大小。
  5. Paint(繪製):把每個節點畫成實際的像素。

CSS 屬於「render blocking(阻塞渲染)」資源 —— 瀏覽器必須等 CSSOM 建好才能繼續往下產生 Render Tree,所以 CSS 要盡量精簡、放在 <head> 盡早載入;JavaScript 若同步執行,還會阻塞 HTML 解析本身,所以才有 async / defer 這類延遲執行的屬性設計。

# 4.3 為什麼要區分「Critical」與「Non-Critical」資源?

一個常見的最佳化手法:把「首屏必須用到的 CSS」直接內聯(inline)寫進 <head> 的 <style> 裡,其餘非首屏需要的 CSS 用 JavaScript 非同步載入:

<!doctype html>
<head>
  <style>/* 首屏必要的關鍵 CSS,直接內聯 */</style>
  <script>loadCSS('non-critical.css');</script>
</head>
<body>
  ...
</body>
</html>

這樣設計的理由:外部 CSS 檔案需要多一次網路請求(DNS 查詢、建立連線、下載),在網路延遲較高的環境下,這個往返時間(RTT)可能就佔了首屏出現時間的大半。把關鍵 CSS 直接寫進 HTML,等於省下這一次網路往返,讓首屏能更快出現 —— 這與 App Shell「盡快顯示殼」的邏輯是一致的。


# 5. Optimizing Content Efficiency:資源最佳化

這一部分討論的是更基礎、卻常被忽略的效能功課:你真的需要下載這些東西嗎?

# 5.1 先問:這個資源真的有必要存在嗎?

在加入任何圖片、Carousel、外部資源前,應該先評估:

  1. 這個資源帶來的「下載成本」與「提供給使用者的價值」是否成正比?
  2. 如果是外部資源,它的穩定性能被信任嗎?
  3. 這個資源萬一載入失敗,會不會拖累整個頁面的效能與體驗?
  4. 這個資源是否還有壓縮/快取空間?

一個很有說服力的例子:某網站加入 Carousel 輪播圖,讓使用者點擊瀏覽更多照片,但所有照片一次全部下載。根據 ND.edu 的研究,Carousel 第一張(也就是使用者不需要點擊就看到的那張)點擊率佔了 89.1%—— 也就是說,網站為了服務那剩下 10.9% 的互動,讓 100% 的使用者付出了下載全部圖片的頻寬成本。這正是 PWA 精神的延伸:效能優化不只是前端工程師寫程式的事,而是產品設計階段就要考慮的成本效益取捨。

# 5.2 文字類資源的壓縮

HTML/CSS/JS 屬於「Text-based Assets」,是壓縮效益最高的一類資源。常見手法:

  • Minify(壓縮):移除註解、空白、換行,並縮短變數名稱。jQuery Core 2.2.4 未壓縮版本 258KB,壓縮後只剩 86KB,差距接近三倍。
  • GZIP 壓縮:伺服器端啟用 GZIP,對文字類資源效果最佳,通常能再省下可觀的傳輸體積。

# 5.3 圖片與 SVG 最佳化

  • 簡單幾何圖形(Logo、文字圖示)建議用向量圖(SVG),因為不受解析度與縮放比例影響,一份檔案適用各種螢幕密度。
  • 用 gulp-imagemin 、 image-webpack-loader 這類工具自動化壓縮 PNG/JPEG/GIF/SVG。
  • SVG 檔案(尤其是設計軟體匯出的)常含有大量不必要的中繼資料,可以用 svgo / svgomg 這類工具清理,實測可縮減約 58% 的檔案大小。

# 5.4 透過 HTTP 快取降低重複傳輸

利用 HTTP 的 ETag 與 Cache-Control header,讓瀏覽器自己判斷資源是否需要重新下載:

// Node.js Express 範例
res.setHeader('ETag', '"' + hash + '"'); //hash 是資源內容的指紋
res.setHeader('Cache-Control', 'public, max-age=31557600');

這裡值得注意的設計含意:HTTP 快取與 Service Worker 快取是兩層不同的機制。HTTP 快取由瀏覽器原生管理,規則簡單但控制粒度粗;Service Worker 快取則完全由開發者用 JavaScript 程式碼控制,可以做到「離線也能存取」「依 URL pattern 決定策略」這種細緻的行為 —— 這也是為什麼 PWA 選擇 Service Worker 作為核心技術,而不只是依賴傳統 HTTP 快取頭。


# 6. Service Worker:PWA 的技術心臟

如果說 App Shell 是「設計思想」,Service Worker 就是把這個思想落地的「技術引擎」。這是整個 PWA 系列裡份量最重的部分。

# 6.1 Service Worker 是什麼?為什麼要這樣設計?

Service Worker 本質上是一支獨立於網頁之外、跑在瀏覽器背景的 JavaScript 程式。它有幾個關鍵特性,而每個特性背後都有明確的設計理由:

  • 不能直接操作 DOM:Service Worker 執行在獨立的執行緒(Worker context),沒有 window 、 document 物件。若要通知頁面做什麼事,只能透過 postMessage 傳遞訊息,讓頁面自己去操作 DOM。為什麼這樣設計? 因為 Service Worker 需要在頁面完全關閉的情況下也能繼續運作(例如接收推播通知),如果它綁死在某個特定頁面的 DOM 上,頁面一關閉它就失去意義了。把它設計成獨立於任何單一頁面之外的「背景代理人」,才能真正做到「即使網頁沒開也能運作」。
  • 必須在 localhost 或 HTTPS 下運作:如前面 1.2 節提過,因為它能攔截所有網路請求,安全性要求極高。
  • 擁有獨立的生命週期:不隨頁面關閉而消失,能持續在背景攔截請求、管理快取。

# 6.2 Service Worker 生命週期詳解

生命週期分為幾個關鍵階段:

註冊 (register) → 安裝 (install) → 等待/啟用 (waiting/activate) → 接管頁面 (control) → 閒置終止 (terminated)
  1. 註冊(Register):頁面透過 JavaScript 呼叫 navigator.serviceWorker.register('/sw.js') ,告訴瀏覽器要使用哪支 Service Worker 檔案。
  2. 安裝(Install):瀏覽器在背景下載並安裝 Service Worker。這個階段通常用來做「預先快取(precache)」—— 把 App Shell 需要的靜態資源存進 Cache Storage。若所有資源都成功快取,才算安裝成功、進入 activate;若有任何一項失敗,整個安裝流程失敗,等待下一次重試。
  3. 啟用(Activate):安裝成功後進入啟用階段,這是清除舊版快取的最佳時機(詳見 6.3 節)。
  4. 接管(Control):啟用完成後,Service Worker 開始攔截頁面發出的所有 fetch 事件。
  5. 終止(Terminated):頁面沒有被使用時,瀏覽器會把 Service Worker 進程終止以節省記憶體,但已註冊的狀態仍會保留,下次有請求時會被重新喚醒。

為什麼要設計成「一定要全部資源快取成功才算安裝成功」? 這是刻意的 all-or-nothing 設計。如果允許部分快取成功就繼續,App Shell 就可能缺了某個關鍵 CSS 或 JS,導致離線時畫面顯示不完整甚至壞掉。寧可整個安裝失敗、下次重試,也不要讓一個「不完整的殼」上線。

# 6.3 三大核心事件:install、activate、fetch

Service Worker 的所有行為都圍繞著監聽這三個事件展開。

# install:預先快取靜態資源

const filesToCache = [
  '/',
  '/index.html',
  '/src/main.css'
];
const cacheName = 'static-v1';
self.addEventListener('install', event => {
  console.log('installing…');
  event.waitUntil(
    caches.open(cacheName).then(cache => {
      return cache.addAll(filesToCache);
    })
  );
});

這裡有兩個關鍵 API 需要理解原理:

  • event.waitUntil(promise) : install 事件物件( InstallEvent )繼承自 ExtendableEvent ,它提供 waitUntil 方法,接受一個 Promise。為什麼需要它? 因為 install 事件的 callback 函式本身是同步執行完就結束的,但快取資源是非同步操作(需要時間下載)。如果不用 waitUntil 告訴瀏覽器「請等這個 Promise 完成再繼續」,瀏覽器可能會誤以為安裝已經完成、提前進入下一階段,導致資源還沒快取好就被判定安裝成功。 waitUntil 就是用來「延長事件的生命週期」,確保非同步工作真的做完了才算數。
  • caches.open(cacheName) : CacheStorage (也就是全域的 caches 物件)可以想像成一個資料庫, cache.open() 就是「取得(或建立)一張資料表」;再用 cache.addAll(urls) 把一批 URL 對應的 response 存進這張表,key 是 request URL,value 是 response 物件。

# activate:清除舊版快取

self.addEventListener('activate', event => {
  event.waitUntil(
    caches.keys().then(cacheNames => {
      const deletePromises = cacheNames.map(name => {
        if (name !== cacheName) {
          return caches.delete(name);
        }
      });
      return Promise.all(deletePromises);
    })
  );
});

為什麼一定要在 activate 做這件事,而不是 install? 因為 install 階段時,舊版的 Service Worker(如果有的話)可能還在服務目前開著的頁面,此時如果直接刪掉舊快取,正在瀏覽的使用者會突然拿不到原本的資源。瀏覽器的設計是:新的 Service Worker 安裝完後會先進入「等待(waiting)」狀態,等所有使用舊版本的頁面都關閉後,新版本才會真正 activate、接管頁面。所以在 activate 這個時間點清除舊快取,才能保證不會影響到還在使用舊版本的使用者體驗。

cacheName 通常會加上版本號(例如 static-v1 、 static-v2 ),這是一種常見的快取失效(cache invalidation)策略:不直接修改同一個 cache 內容,而是每次改版就換一個全新的 cache 名稱,然後在 activate 階段清掉所有「名稱不等於目前版本」的舊 cache。這樣可以確保快取內容永遠是「整批換新」,不會出現新舊資源混雜的不一致狀態。

# fetch:攔截請求、決定回應策略

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request).then(response => {
      // 命中快取就直接回傳;沒命中就發真正的網路請求
      return response || fetch(event.request).then(networkResponse => {
        return caches.open(cacheName).then(cache => {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
      });
    })
  );
});

幾個容易誤解的細節:

  • event.respondWith() :這是 fetch 事件特有的方法,用來「劫持」這次的網路請求、由開發者決定要回傳什麼內容(而不是讓瀏覽器照原本的方式去要資料)。如果 fetch 事件裡不呼叫 respondWith ,請求就會照常規流程走,Service Worker 只是單純旁觀。
  • 為什麼要 res.clone() ?因為 Response 物件的 body 是一個 Stream,只能被讀取一次。這次的請求既要「回傳給頁面顯示」,又要「存進快取供下次使用」,等於要讀兩次,所以必須先 clone 一份。
  • 第一次造訪為什麼攔截不到請求? 因為 Service Worker 要先完成註冊 → 安裝 → 啟用整個流程才會開始攔截 fetch 事件,而頁面在第一次造訪時發出的請求(例如去抓 API 資料)通常搶在 Service Worker 啟用「之前」就已經送出去了。這是設計上必然的結果,也解釋了為什麼 PWA 通常需要使用者造訪兩次以上,離線體驗才會真正生效。

# 6.4 四種快取策略:依資源特性選擇對應行為

在自動化工具( sw-precache 、 sw-precache-webpack-plugin )的 runtimeCaching 設定中,可以針對不同 URL pattern 指定不同的處理策略:

策略 行為 適合場景
cacheFirst 先查快取,有就直接回傳;沒有才發網路請求 Logo、字型、圖示等幾乎不變的靜態資源
networkFirst 先發網路請求,成功就存快取並回傳;失敗才退回舊快取 需要保持新鮮度的內容(新聞、動態資料)
fastest (也稱 stale-while-revalidate) 同時發網路請求與查快取,誰先回來就先用誰,即使用了快取版本,仍然背景更新快取 希望「快」又希望「新」的折衷方案
cacheOnly 只查快取,沒有就直接失敗,不發請求 確定資源已預先快取、且絕不應該走網路的情境
networkOnly 只走網路,完全不使用快取 不希望被快取影響的即時性資料(如支付相關 API)

這張表格正是第 3.3 節「為什麼要拆分 Shell 跟 Content」的具體實踐:Shell(Logo、CSS、JS)用 cacheFirst ;Content(動態資料)視情境用 networkFirst 或 stale-while-revalidate 。策略選擇的本質,是在「速度」與「新鮮度」這兩個互相拉扯的目標之間,依照每種資源的更新頻率找一個合理的平衡點。

# 6.5 為什麼 self 而不是 this?

Service Worker 程式碼裡常看到 self.addEventListener(...) ,而不是常見的 this 或 window 。原因是 Service Worker 執行在 ServiceWorkerGlobalScope 這個獨立的全域環境,沒有 window 物件;規範上這個全域環境的自我參照就是 self (這與一般 Web Worker 的慣例一致)。用 self 才能正確存取 Service Worker 專屬的屬性與 API(例如 self.registration 、 self.skipWaiting() 等)。


# 7. Chrome DevTools 除錯工具

Chrome DevTools 新增了 Application 面板,專門用來檢查 PWA 相關的技術狀態,主要分三塊:

# 7.1 Manifest 檢視

可以直接看到目前套用的 manifest.json 內容(名稱、圖示、起始網址等),並提供「Add to homescreen」按鈕,模擬把網站加到主畫面的體驗,不必真的用手機實測。

# 7.2 Service Workers 面板

  • Offline:勾選後可以模擬完全無網路的環境,是測試「離線瀏覽是否真的成功」最直接的方式。
  • Update on reload:每次重新整理都強制套用最新的 Service Worker,開發階段非常實用 —— 因為 Service Worker 預設有「舊版本要等所有分頁關閉才會被新版本取代」的機制(見 6.3 節),開發時勾選這個選項可以跳過等待,避免每次改完程式碼還要手動關掉所有分頁。
  • Bypass for network:完全繞過 Service Worker,直接走原生網路請求,用來排除「我看到的是不是舊快取」這個變因。

# 7.3 Cache Storage 檢視

可以逐一查看目前有哪些 cache、每個 cache 存了哪些 URL 對應的 response,也能手動刪除,是驗證「install 階段是否真的把該快取的東西存進去了」最直接的方式。此外 Clear storage 頁籤可以一次清除 Service Worker、Cache、IndexedDB、Local Storage 等所有儲存資料,方便重新測試整個生命週期。


# 8. PWA 實際案例分析

除了前面提到的 Flipkart Lite,還有幾個值得參考的實戰案例,共同點是都能量化出具體的商業效益:

  • Housing.com(印度租屋平台):轉換率提升 38%、跳出率降低 40%、平均停留時間增加 10%、頁面載入速度加快 30%。
  • AliExpress(阿里巴巴國際版淘寶):新使用者跨瀏覽器成長 104%、iOS 轉換率提升 82%、每次造訪瀏覽頁數翻倍、停留時間增加 74%。
  • pwa.rocks:Google 官方彙整的 PWA 展示網站集合,涵蓋電商、新聞、遊戲、社群等多種類型,可作為設計參考。
  • Pokedex.org:由 Web 效能專家 Nolan Lawson 開發的示範專案,是學習「Cache API + IndexedDB」搭配使用的經典開源範例。

這些案例共同印證的結論:PWA 帶來的效益不是單純的「網站變快了」,而是直接反映在商業指標(轉換率、回流率、停留時間)上 —— 這也是為什麼越來越多電商與內容平台願意投入 PWA 改造,因為它是少數能同時改善使用者體驗與商業數字的技術投資。


# 9. 動手實作:Vanilla JS 版 To-Do List PWA

系列文章接下來用一個 To-Do List 範例貫穿實作,逐步疊加 PWA 的各項能力。這裡整理實作的完整脈絡與關鍵設計決策(程式碼經過重新整理示範,非逐字照搬原文)。

# 9.1 開發環境:為什麼要用 json-server + http-server + concurrently?

實作前,需要模擬一個貼近真實專案的開發環境:

  • json-server:讀取一份 db.json ,自動生成完整的 REST API(支援 GET/POST/PUT/DELETE),不必自己寫後端就能有一個可操作的假資料 API。這樣做的好處是前端開發可以完全獨立於後端進度,同時前端串接的程式碼寫法與真正對接後端 API 時幾乎一致,之後要換成真正的 API 只需要改網址。
  • http-server:零設定就能啟動一個靜態網站伺服器,把前端網頁跑起來。這裡有個容易被忽略但很關鍵的原因:Service Worker 要求執行在 localhost 或 HTTPS 環境,如果直接用 file:// 開啟本機 HTML 檔案是無法註冊 Service Worker 的,所以哪怕只是本機開發,也一定要透過某種本地伺服器(如 http-server)提供服務。
  • concurrently:因為前端網站與假資料 API 是兩個各自獨立的伺服器行程, concurrently 讓你在一個終端機視窗同時啟動兩個 command,任何一個掛掉時可以連帶關閉另一個,簡化開發流程。

# 9.2 需求拆解

一個典型的 To-Do List 功能需求:

  • 顯示待辦事項清單(GET)
  • 新增待辦事項(POST),按 Enter 送出並清空輸入框
  • 修改待辦事項狀態:點擊可切換「已完成/未完成」(PUT)
  • 刪除待辦事項(DELETE)
  • 顯示目前未完成事項的數量

# 9.3 純 JavaScript 實作重點

以「顯示清單」為例,實作邏輯拆成幾個步驟:

// 用立即函式包裹,避免污染全域變數
(function (window, document) {
  const todoListDOM = document.getElementById('todoList');
  let todoList = [];
  //render 函式:只負責把資料轉成 HTML,不碰任何業務邏輯
  function renderTodoList(list) {
    const html = list.map(item => `
      <li class="list">
        <a class="${item.isComplete ? 'finish' : 'unfinished'}" data-id="${item.id}"></a>
        <p class="desc" data-id="${item.id}">${item.desc}</p>
        <a class="del" data-id="${item.id}"></a>
      </li>
    `).join('');
    todoListDOM.innerHTML = html;
  }
  // 透過 fetch 向假 API 拿資料
  fetch('http://localhost:3000/todolist')
    .then(res => res.json())
    .then(json => {
      todoList = json;
      renderTodoList(todoList);
    })
    .catch(err => console.log(err));
}(window, document));

為什麼要用立即函式(IIFE)包起來? 這是傳統 Vanilla JS(在還沒有 ES Modules/打包工具普及的年代)避免全域變數污染的慣用手法。所有變數都封閉在函式作用域內,不會不小心跟頁面上其他 script 的變數互相覆蓋。

為什麼要把 render 拆成獨立函式? 這是「資料與畫面分離」的雛型概念 —— 不論資料是「初次載入」「新增後」還是「刪除後」得到的,最終都呼叫同一個 render 函式重繪畫面,維持單一資料來源(single source of truth)的一致性。這個思路其實已經是 React 這類宣告式框架的核心概念的簡化版,只是這裡用手動呼叫取代了框架自動化的重新渲染機制。

新增/修改/刪除的邏輯都遵循相同模式:監聽 DOM 事件 → 發送對應 HTTP method 的 request → 拿到成功回應後更新記憶體中的 todoList 陣列 → 呼叫 render 重繪畫面。例如修改狀態:

const toggleItem = id => {
  const item = todoList.find(t => t.id === id);
  item.isComplete = !item.isComplete;
  fetch(`http://localhost:3000/todolist/${id}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(item)
  })
    .then(res => res.json())
    .then(() => renderTodoList(todoList));
};

# 9.4 加上離線能力:App Shell + Service Worker 整合

把 App Shell 的圖片、CSS、HTML 放進 install 階段的預先快取清單,再搭配前面第 6 章介紹的 activate (清舊快取)與 fetch (攔截請求)事件,整個 To-Do List 就具備了離線瀏覽 App Shell 與已快取內容的能力:

const filesToCache = [
  '/',
  '/index.html',
  '/src/main.css',
  '/assets/images/logo_todo.png',
  '/assets/images/ic_add.png',
  '/assets/images/btn_check.png',
  '/assets/images/btn_del.png'
];
const cacheName = 'todolist-v1';
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(cacheName).then(cache => cache.addAll(filesToCache))
  );
});

這裡要誠實面對一個限制:這個階段的實作只能透過 Cache API 快取「GET 請求的靜態資源與 API 回應」。POST/PUT/DELETE 這類會修改資料的操作,在離線狀態下是無法真正送達伺服器的 —— 這也是為什麼後面第 13 章要另外討論 IndexedDB:純內容型網站用 Cache API 就足夠,但需要離線寫入、離線操作的應用(像完整的 To-Do List),勢必需要更完整的本地資料庫方案。


# 10. Web App Manifest:讓網站更像 App

# 10.1 Manifest File 是什麼?

manifest.json 是一份單純的 JSON 檔案,用來描述網站在「被安裝成類 App 體驗」時的各種外觀與行為設定。透過 <link rel="manifest" href="/manifest.json"> 掛載到頁面上。

{
  "name": "PWA To-Do List with Vanilla JS",
  "short_name": "To-Do List",
  "start_url": "/",
  "icons": [
    { "src": "/assets/images/icon-192x192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/assets/images/icon-512x512.png", "sizes": "512x512", "type": "image/png" }
  ],
  "background_color": "#707477",
  "theme_color": "#f2f2f2",
  "display": "standalone",
  "orientation": "portrait"
}

# 10.2 各欄位的作用與設計考量

欄位 作用 設計說明
name / short_name 網站名稱 name 顯示不下時自動退回顯示 short_name ,用在安裝提示、Splash Screen、主畫面圖示下方文字
icons 多組不同尺寸的圖示 提供多組尺寸讓瀏覽器依裝置螢幕密度挑選最合適的一組,避免單一圖示在高解析度螢幕被拉伸模糊
background_color Splash Screen 背景色 在圖示與內容都還沒載入完成前,先用純色背景撐住畫面,避免白屏閃爍
theme_color 系統 UI 主題色 例如 Android 上狀態列的顏色會跟著變,強化「這是一個獨立 App」的視覺一致性
display 顯示模式: browser / standalone / fullscreen standalone 會隱藏瀏覽器網址列等 UI,讓網站看起來完全像原生 App;瀏覽器不支援時會優雅降級回 browser
orientation 強制螢幕方向 應謹慎使用,通常只用在遊戲類應用;一般網站應尊重使用者自行旋轉裝置的意願
start_url 從主畫面圖示啟動時開啟的網址 常搭配 ?utm_source=homescreen 這類查詢參數,方便在 Analytics 中追蹤「透過主畫面圖示進站」的流量來源

為什麼要有「Add to Home Screen」的自動提示機制? 瀏覽器(如 Chrome)會偵測使用者在短時間內(例如 5 分鐘內)多次造訪同一個網站,判斷這是「高互動意願」的訊號,才主動跳出安裝提示。這個設計是為了避免每個網站都濫用彈窗騷擾使用者 —— 只有真正展現出留存價值的網站,才「配得上」被推薦安裝,這也呼應了 PWA「漸進式」的精神:功能是被賺取的,不是被強塞的。

# 10.3 用工具驗證與生成

  • 可透過線上工具(如 Web App Manifest Generator)輸入基本資訊、上傳圖示,自動產生完整 manifest.json ,省去手刻多組尺寸圖示的麻煩。
  • Chrome DevTools 的 Application 面板可以直接驗證目前的 manifest 設定是否正確載入,是除錯的第一站。

# 11. 自動化生成 Service Worker:sw-precache 與 webpack plugin

# 11.1 為什麼需要自動化工具?

回顧第 6 章手寫的 Service Worker,會發現一個明顯的痛點: filesToCache 陣列需要手動列出每一個要快取的檔案路徑。專案稍微成長,靜態資源動輒幾十上百個,手動維護這份清單既容易漏掉、也容易在改版後忘記更新,導致快取內容與實際部署內容不同步。

sw-precache 是 Google Chrome 團隊推出的 Node.js 模組,用途正是:掃描你指定的檔案規則(glob pattern),自動產生一支完整的 Service Worker 檔案,不必再手寫 install/activate/fetch 的邏輯。

# 11.2 sw-precache 基本用法

// sw-precache-config.js
module.exports = {
  staticFileGlobs: [
    'index.html',
    'src/main.css',
    'assets/images/**.*'
  ],
  runtimeCaching: [{
    urlPattern: /\/api\//,
    handler: 'networkFirst'
  }],
  swFile: 'sw-generated.js'
};
sw-precache --config=./sw-precache-config.js --verbose

執行後會自動產生 sw-generated.js ,內容涵蓋了完整的預先快取清單、版本管理(自動依內容雜湊產生 cache 版本,內容沒變就不會重新產生新版本,避免不必要的更新)、以及 runtimeCaching 指定的執行期快取策略。

這裡的關鍵設計價值在於「內容雜湊(content hash)驅動的版本控制」:手寫版本時,開發者要自己決定何時該把 cacheName 從 v1 改成 v2 (很容易忘記), sw-precache 則是根據檔案「內容」自動算出 hash 值當作版本識別,只要檔案內容真的變了,快取就會自動失效重建;內容沒變,即使你重新部署了,也不會觸發不必要的重新下載。這比人工手動遞增版本號可靠得多。

# 11.3 搭配 Webpack:sw-precache-webpack-plugin

大型專案通常已經用 Webpack 做模組打包, sw-precache-webpack-plugin 把 sw-precache 包裝成一個 Webpack Plugin,直接讀取 Webpack 打包後產出的檔案清單,不必再另外維護一份靜態檔案列表:

const SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin');
module.exports = {
  //... 其他 webpack 設定
  plugins: [
    new SWPrecacheWebpackPlugin({
      cacheId: 'todo-list',
      filepath: './sw-generated-webpack.js',
      maximumFileSizeToCacheInBytes: 4194304, // 4MB
      runtimeCaching: [{
        handler: 'cacheFirst',
        urlPattern: /[.]png$/
      }]
    })
  ]
};

為什麼要跟 Webpack 整合,而不是各自獨立跑? 因為現代前端專案的檔案名稱通常會帶 hash(如 bundle.a1b2c3.js ),這個 hash 是 Webpack 打包當下才決定的。如果 Service Worker 的快取清單是獨立於 Webpack 之外手動維護,兩者的檔名版本很容易對不上 —— 你可能快取了一個舊 hash 的檔案,但實際部署的是新 hash 的檔案,導致快取形同虛設。整合成 Webpack Plugin 後,Service Worker 產生的時機點永遠是「打包完成、確定最終檔名」之後,兩者版本天然保持一致。

# 11.4 五種 handler 策略的完整語意

延續第 6.4 節的表格,這裡是 sw-precache-webpack-plugin 官方支援的完整策略定義:

  • networkFirst :先嘗試網路請求,成功則存入快取並回傳;失敗(離線)才退回舊快取,確保至少有基本資料可用。
  • cacheFirst :先查快取,有則直接回傳;沒有才發送網路請求。
  • fastest :同時發送網路請求並查詢快取,哪個先回應就先用哪個(通常快取會先回來),但即使用了快取結果,仍然會讓網路請求繼續執行,用最新結果更新快取,兼顧速度與新鮮度。
  • cacheOnly :只查快取,沒有對應內容就直接失敗,不會發出任何網路請求。
  • networkOnly :只透過網路,完全不經過快取層,網路失敗就是失敗。

# 12. React + Redux 版本實作

系列文章最後把同一個 To-Do List 案例,改用 React + Redux + Webpack 重新實作一次,展示 PWA 技術如何整合進現代前端框架的工作流程。

# 12.1 環境安裝

npm install --save-dev react react-dom react-redux react-router redux redux-thunk
npm install --save-dev babel-preset-react babel-preset-stage-0
npm install --save-dev file-loader url-loader webpack-dev-server
  • redux-thunk :Redux 原生的 action creator 只能回傳一個純物件(同步),但這個 To-Do List 需要串接非同步的 API 請求(GET/POST/PUT/DELETE)。 redux-thunk 讓 action creator 可以回傳一個函式而非物件,函式內部再自行決定何時 dispatch ,這是處理非同步流程最經典的 Redux Middleware 解法。

# 12.2 Container / Component 分離架構

專案架構刻意把元件拆成兩層:

Component/     ← 純顯示元件(Presentational Components)
  Header.jsx
  Input.jsx
  TodoList.jsx
  TodoListItem.jsx
Container/     ← 負責串接 Redux store 與 dispatch actions
  Main.jsx
  InputContainer.jsx
  TodoListContainer.jsx

例如:

// TodoListContainer.jsx —— 負責「接資料」
import TodoList from '../Component/TodoList.jsx';
import { connect } from 'react-redux';
import { getTodoList, toggleTodoList, delTodoList } from '../Reducers/todolist.js';
export default connect(
  state => ({ todos: state.todolist }),
  { getTodoList, toggleTodoList, delTodoList }
)(TodoList);
// TodoList.jsx —— 只負責「畫出來」
export default class TodoList extends Component {
  componentDidMount() {
    this.props.getTodoList();
  }
  render() {
    const { todos, toggleTodoList, delTodoList } = this.props;
    return (
      <ul id="todoList">
        {todos.map((item, i) => (
          <TodoListItem key={`todolist_${i}`} {...item}
            toggleTodoList={toggleTodoList} delTodoList={delTodoList} />
        ))}
      </ul>
    );
  }
}

為什麼要刻意拆成 Container 跟 Component 兩層,而不是全部寫在一起? 這是社群中著名的 Presentational and Container Components 模式(Dan Abramov 提出)。核心理由是關注點分離(Separation of Concerns):

  • Component(純顯示元件) 只關心「這些 props 要怎麼畫出來」,不知道資料從哪來、也不知道點擊事件最終會怎麼處理業務邏輯。這讓它極易被重用、被單獨測試、被 Storybook 這類工具單獨展示。
  • Container(容器元件) 負責「跟 Redux store 對話」—— 訂閱哪些 state、要 dispatch 哪些 action。所有跟資料來源、狀態管理相關的邏輯都集中在這一層。

在小型的 To-Do List 範例裡,這樣拆分看起來有點小題大作,但作者刻意強調:專案規模越大,這種拆分帶來的可維護性與重用性優勢就越明顯 —— 因為當畫面邏輯與資料邏輯攪在一起時,任何一邊的需求變更都可能牽動另一邊,測試與重構的成本會隨專案增長而急遽上升。

# 12.3 Redux Reducer 與非同步 Action

// Reducer:純函式,只負責依照 action.type 計算出新的 state
export default function todolist(state = [], action) {
  switch (action.type) {
    case RECEIVE_TODO:
      return action.data;
    case ADD_TODO:
      return [...state, action.payload];
    case TOGGLE_TODO:
      return state.map(item =>
        item.id === action.payload.id ? action.payload : item
      );
    case DEL_TODO:
      return state.filter(item => item.id !== action.id);
    default:
      return state;
  }
}
// Action creator:搭配 redux-thunk,回傳函式而非物件,處理非同步請求
export function addTodoList(value) {
  return function (dispatch) {
    return axios({
      method: 'post',
      url: `${URL}todolist`,
      data: { isComplete: false, desc: value }
    }).then(res => {
      dispatch({ type: ADD_TODO, payload: res.data });
    });
  };
}

這裡體現的設計原則:Reducer 必須是「純函式」—— 同樣的輸入(state + action)永遠得到同樣的輸出,不能有 side effect(例如發 API 請求)。所有跟外部世界互動(發 HTTP 請求)的「不純」邏輯,統統被推到 action creator 這一層(透過 thunk),Reducer 保持絕對單純只做「狀態計算」。這種分工讓狀態變化的邏輯變得可預測、易於測試(因為 Reducer 不需要 mock 網路請求就能單元測試)。

# 12.4 React 版的 PWA 整合

React 版本套用 PWA 能力的方式,跟 Vanilla JS 版本原理完全一致 —— 一份對應內容的 manifest.json ,再搭配 sw-precache 自動產生 Service Worker,快取 Webpack 打包後的 bundle.js 與其他靜態資源,達成離線瀏覽 App Shell 與已請求過的 GET 資料。

作者在原文中誠實指出目前實作的邊界:這個階段做到的離線能力,只能顯示已快取過的 GET 資料;若要讓「離線時新增/修改/刪除」的操作也能正常運作(等網路恢復後再同步回伺服器),就必須另外實作 IndexedDB 搭配 Background Sync 之類更完整的離線優先(offline-first)架構,這已超出這個系列的範圍,但是很值得的延伸學習方向。


# 13. Offline Storage 選型策略

當你的應用不只需要「讀」資料(GET),還需要在離線狀態下「寫」資料(POST/PUT/DELETE 暫存待同步)時,就必須認真考慮該用哪一種瀏覽器儲存機制。

# 13.1 各種儲存方案的技術比較

方案 資料模型 瀏覽器支援度(原文寫作當下) 同步/非同步 現況
Cache API key/value 約 60% 非同步 專為配合 Service Worker 設計,適合「URL 可定位」的資源
IndexedDB hybrid(物件導向、支援索引) 約 83% 非同步 適合結構化、大量資料
Local Storage key/value 約 93% 同步 簡單但有明顯限制
Web SQL SQL — 非同步 已被 W3C 於 2010 年正式棄用

# 13.2 官方建議與背後的技術理由

Google 官方文件《Offline Storage for Progressive Web Apps》給出明確的建議準則:

  • 對於「可以用 URL 定位的資源」(例如靜態檔案、GET API 回應),使用 Cache API(Service Worker 的一部分)。
  • 對於「其他所有資料」(例如需要離線寫入、結構化查詢的應用資料),使用 IndexedDB(搭配 Promise 包裝簡化 API)。

為什麼不能用 Local Storage 或 Cookie? 這是理解這個決策最關鍵的一段推理:

  1. Local Storage/Session Storage 是同步(synchronous)API。這代表每一次讀寫都會阻塞(block)主執行緒 —— 如果存的資料量稍大,畫面就會明顯卡頓。更嚴重的是,Service Worker 本身是跑在獨立的 Worker 執行緒裡的,而規範明確禁止在任何 Worker 環境(包含 Service Worker)中使用 Local Storage,因為同步 API 若允許在背景執行緒使用,很容易造成整個瀏覽器層級的死鎖風險。也就是說:Local Storage 在 Service Worker 裡根本無法使用,這已經直接排除了它作為 PWA 離線儲存方案的資格。
  2. Cookie 同樣是同步機制,而且每次 HTTP 請求都會被夾帶在 Header 裡送出,容量限制通常只有 4KB 左右,完全不適合儲存結構化的應用資料。
  3. Web SQL 雖然支援 SQL 查詢語法、對熟悉關聯式資料庫的人很友善,但因為 W3C 認為「用某一套資料庫引擎的方言(SQLite)當標準」不利於跨瀏覽器的長期互通性,已在 2010 年正式宣布廢止,不應該再用於新專案。

反觀 Cache API 與 IndexedDB 都是非同步的,並且都被設計成可以在 window 、 Web Worker 、 Service Worker 這三種執行環境下通用運作 —— 這正是它們被官方欽定為「PWA 標準離線儲存方案」的根本原因:只有非同步、且能跨執行環境使用的 API,才符合 Service Worker 架構的根本限制。

# 13.3 實務搭配模式

  • 靜態資源與 GET API 回應 → Cache API(本筆記第 6 章已詳細說明操作方式)
  • 應用程式狀態、需要離線寫入的資料、需要索引查詢的結構化資料 → IndexedDB

Pokedex.org 這個知名案例正是兩者搭配使用的典範:可透過 URL 取得的寶可夢圖鑑資源用 Cache API 快取,而應用程式本身的執行狀態則存在 IndexedDB 裡。


# 14. 總結與延伸學習方向

# 14.1 整體知識地圖回顧

這 30 天的內容可以濃縮成一條清晰的邏輯脈絡:

  1. 動機層:網頁與 App 各有優劣(第 1-2 章)→ PWA 想讓網頁「借」到 App 的優點,同時不失去網頁易分享、免安裝的先天優勢。
  2. 架構層:App Shell 把「殼」與「內容」分離,是達成「快速首屏、感知效能優先」的設計核心(第 3 章);RAIL 模型與 Critical Rendering Path 則提供了具體的效能量化指標與瀏覽器渲染原理(第 4-5 章)。
  3. 技術實作層:Service Worker 是落實這一切的引擎 —— 它的獨立生命週期(install/activate/fetch)設計,恰好對應「預先快取」「版本清理」「攔截回應」三個各自獨立又環環相扣的職責(第 6 章)。
  4. 工程化層:手寫 Service Worker 難以維護,於是有了 sw-precache 與其 Webpack Plugin,用「自動掃描、內容雜湊驅動版本」取代人工維護清單(第 11 章);同時整合進 React + Redux 這類現代框架的工程實踐(第 12 章)。
  5. 資料層:當應用需要離線寫入而非只是離線讀取時,Cache API 已經不夠用,必須理解 IndexedDB 為何是唯一被官方推薦、且技術上唯一可行(非同步、跨 Worker 環境通用)的選擇(第 13 章)。

# 14.2 原系列未完成、值得延伸研究的主題

原作者在系列文章的最後也誠實列出當初規劃但未及完成的主題,這些同樣是完整理解 PWA 生態系不可或缺的一塊,適合作為後續自學方向:

  • HTTP/2:多工(multiplexing)、伺服器推送(server push)等特性如何與 PWA 的資源載入策略互補。
  • PRPL Pattern(Push, Render, Pre-cache, Lazy-load):Google 針對 PWA 提出的另一套載入策略框架,與 App Shell 概念緊密相關,強調用 HTTP/2 Push 搭配 Service Worker 預先快取來加速首屏,其餘路由則延遲載入。
  • Offline UX Considerations:離線狀態下的 UI/UX 設計準則(如何提示使用者「目前離線」、離線時哪些操作應該被暫時禁用或改為排入佇列)。
  • HTTPS 部署細節:憑證申請、混合內容(Mixed Content)問題排查。
  • Push Notifications(推播通知):搭配 Service Worker 的 push 事件與 Notification API,實現「Re-engageable」這項 PWA 特性。
  • Lighthouse:Google 釋出的開源自動化稽核工具,可以針對效能、PWA 規範符合度、無障礙(Accessibility)、SEO 等面向給出量化評分,是驗收一個 PWA 專案品質的標準工具。
  • IndexedDB 進階應用:搭配 Background Sync API,實作「離線時的寫入操作先暫存到 IndexedDB、等網路恢復後自動同步回伺服器」的完整離線優先(Offline-First)架構 —— 這正是本系列 To-Do List 範例還未完全解決的那塊拼圖。

# 14.3 常見問答整理(來自作者於實體分享會後的補充)

Q1:Service Worker 程式碼裡的 self 是什麼?
A: self 指向 ServiceWorkerGlobalScope ,是 Service Worker 執行環境的全域物件(因為沒有 window ),必須用 self 才能存取對應的事件監聽與屬性(詳見 6.5 節)。

Q2:Cache 有沒有容量限制?
A:有,且限制因瀏覽器與裝置可用磁碟空間而異,沒有一個固定的絕對數字,開發時應留意透過 DevTools 監控實際佔用量,避免無限制堆積快取。

Q3:Service Worker 檔案一定要手寫嗎?
A:不用,如第 11 章所述,可以用 sw-precache 或搭配 Webpack 的 sw-precache-webpack-plugin 自動產生,大幅降低維護成本。

Q4:有沒有工具可以檢測一個網站的 PWA 完整度?
A:有,官方工具 Lighthouse(開源專案,也內建在 Chrome DevTools 裡)可以針對 PWA 規範逐項打分,是上線前檢查的標準做法。


# 附錄:關鍵術語速查表

術語 一句話解釋
App Shell 把「不常變的外殼」與「常變的內容」分離快取的架構模式
Service Worker 跑在瀏覽器背景、獨立於頁面之外的 JS 程式,負責攔截網路請求與管理快取
Cache API Service Worker 專用、非同步的 key/value 快取儲存介面
IndexedDB 瀏覽器內建的非同步、結構化資料庫,用於離線寫入與複雜查詢
Manifest File 描述網站被安裝為類 App 體驗時的外觀與行為設定的 JSON 檔
RAIL Model Response/Animation/Idle/Load 四階段的效能評估模型
Critical Rendering Path 瀏覽器把 HTML/CSS/JS 轉換成螢幕像素的完整流程
sw-precache 自動掃描檔案並產生 Service Worker 程式碼的 Node.js 工具
stale-while-revalidate 先用舊快取回應、同時背景更新快取的折衷快取策略
Lighthouse Google 官方的網站品質自動稽核工具,涵蓋 PWA 規範檢查

本筆記內容為原創重寫與延伸解說,原始資料來源為 iThome 2017 iT 邦幫忙鐵人賽系列文章,作者 iamya,感謝其完整記錄了學習 PWA 的完整脈絡。


更新於 閱讀次數 次