# Progressive Web App(PWA)30 天完整學習筆記
本筆記整理自 iThome 2017 iT 邦幫忙鐵人賽系列文章《30 天 Progressive Web App 學習筆記》(作者:iamya),共 30 篇文章。內容經過重新整理、補充原理說明與設計思路分析。
原系列連結:https://ithelp.ithome.com.tw/users/20071512/ironman/1222
# 目錄
- PWA 是什麼、為什麼需要它
- 從靜態網站到 SPA:網站演進史
- App Shell 架構:PWA 效能的核心秘密
- RAIL 效能模型與 Critical Rendering Path
- Optimizing Content Efficiency:資源最佳化
- Service Worker:PWA 的技術心臟
- Chrome DevTools 除錯工具
- PWA 實際案例分析
- 動手實作:Vanilla JS 版 To-Do List PWA
- Web App Manifest:讓網站更像 App
- 自動化生成 Service Worker:sw-precache 與 webpack plugin
- React + Redux 版本實作
- Offline Storage 選型策略
- 總結與延伸學習方向
# 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,而要拆成殼跟內容分開?
如果不拆分,直接把整個頁面(含當前資料)整包快取,會有兩個問題:
- 資料新鮮度問題:如果整包一起快取,下次打開時看到的內容可能是過期的舊資料,而且很難只更新其中一小部分 —— 要嘛全部重抓,要嘛全部沿用舊的。
- 快取效率問題: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 轉成螢幕像素的完整流程:
- 解析 HTML → 建立 DOM Tree:瀏覽器逐行讀取 HTML,建立文件物件模型樹。
- 解析 CSS → 建立 CSSOM Tree:遇到
<link rel="stylesheet">時,下載並解析 CSS,建立樣式規則樹。 - 合併 DOM + CSSOM → Render Tree:只包含「會被畫出來」的節點(
display: none的元素不會出現在這裡)。 - Layout(排版):計算每個節點在畫面上的確切位置與大小。
- 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、外部資源前,應該先評估:
- 這個資源帶來的「下載成本」與「提供給使用者的價值」是否成正比?
- 如果是外部資源,它的穩定性能被信任嗎?
- 這個資源萬一載入失敗,會不會拖累整個頁面的效能與體驗?
- 這個資源是否還有壓縮/快取空間?
一個很有說服力的例子:某網站加入 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)
- 註冊(Register):頁面透過 JavaScript 呼叫
navigator.serviceWorker.register('/sw.js'),告訴瀏覽器要使用哪支 Service Worker 檔案。 - 安裝(Install):瀏覽器在背景下載並安裝 Service Worker。這個階段通常用來做「預先快取(precache)」—— 把 App Shell 需要的靜態資源存進 Cache Storage。若所有資源都成功快取,才算安裝成功、進入 activate;若有任何一項失敗,整個安裝流程失敗,等待下一次重試。
- 啟用(Activate):安裝成功後進入啟用階段,這是清除舊版快取的最佳時機(詳見 6.3 節)。
- 接管(Control):啟用完成後,Service Worker 開始攔截頁面發出的所有
fetch事件。 - 終止(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? 這是理解這個決策最關鍵的一段推理:
- Local Storage/Session Storage 是同步(synchronous)API。這代表每一次讀寫都會阻塞(block)主執行緒 —— 如果存的資料量稍大,畫面就會明顯卡頓。更嚴重的是,Service Worker 本身是跑在獨立的 Worker 執行緒裡的,而規範明確禁止在任何 Worker 環境(包含 Service Worker)中使用 Local Storage,因為同步 API 若允許在背景執行緒使用,很容易造成整個瀏覽器層級的死鎖風險。也就是說:Local Storage 在 Service Worker 裡根本無法使用,這已經直接排除了它作為 PWA 離線儲存方案的資格。
- Cookie 同樣是同步機制,而且每次 HTTP 請求都會被夾帶在 Header 裡送出,容量限制通常只有 4KB 左右,完全不適合儲存結構化的應用資料。
- 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 天的內容可以濃縮成一條清晰的邏輯脈絡:
- 動機層:網頁與 App 各有優劣(第 1-2 章)→ PWA 想讓網頁「借」到 App 的優點,同時不失去網頁易分享、免安裝的先天優勢。
- 架構層:App Shell 把「殼」與「內容」分離,是達成「快速首屏、感知效能優先」的設計核心(第 3 章);RAIL 模型與 Critical Rendering Path 則提供了具體的效能量化指標與瀏覽器渲染原理(第 4-5 章)。
- 技術實作層:Service Worker 是落實這一切的引擎 —— 它的獨立生命週期(install/activate/fetch)設計,恰好對應「預先快取」「版本清理」「攔截回應」三個各自獨立又環環相扣的職責(第 6 章)。
- 工程化層:手寫 Service Worker 難以維護,於是有了 sw-precache 與其 Webpack Plugin,用「自動掃描、內容雜湊驅動版本」取代人工維護清單(第 11 章);同時整合進 React + Redux 這類現代框架的工程實踐(第 12 章)。
- 資料層:當應用需要離線寫入而非只是離線讀取時,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 的完整脈絡。