用 Service Worker 讓 Astro 部落格讀過的文章離線也打得開 — 205KB 的 shell 與 50 頁上限
為 Astro 部落格打造的 Service Worker:預先快取 205KB 的全站 CSS 與 JS,讀過的文章最多保留 50 篇,各篇的圖表樣式則等打開那篇文章時才快取。

本頁目錄
引言
我常回頭翻自己的文章查東西,而且往往是在沒有網路的飛機上。我想達到的效果很單純:沒有網路的時候,也能打開以前讀過的文章。
我最初想把內文存進 localStorage,動手之前先問了 Claude Code 可不可行,答案是不行。離線時在瀏覽器打開網址,瀏覽器會直接去連網路,結果只會看到「沒有網際網路連線」的畫面。存在 localStorage 裡的文字要等頁面載入後才讀得到,但頁面根本載入不了。
最後可行的是一個 Service Worker:預先快取 205KB 的 shell,也就是全站共用的 CSS 與 JS,再把讀到的每篇文章留下來,最多 50 篇。裝好之後,讀者的離線書庫會隨著他打開的頁面自己長出來,除了 shell 之外不必額外下載任何東西。
我的直覺錯了:localStorage 回應不了導覽
瀏覽器要取得一個網址時,只會查 HTTP 快取和網路,不會去別的地方找。瀏覽器也沒有任何機制會拿頁面自己存的字串來回應請求,所以存在 localStorage 裡的文字,到了真正需要的那一刻根本派不上用場。
拿來存頁面的話,localStorage 還有三個限制:
- 每個來源大約只能存 5MB,相當於本站幾十篇文章。
- 它只存字串,文件存進去時沒有 header、沒有狀態碼,也沒有 Content-Type。
- 它是同步的,讀取大筆資料會卡住主執行緒。
Cache API 以 Request 為 key 存放對應的 Response,連 header 和狀態碼都一併保留,而回應導覽需要的正是這種形式。Service Worker 擋在頁面與網路之間,獨立於任何頁面執行,能把存好的回應直接交回去:
// 能回應導覽的只有 Service Worker,瀏覽器裡沒有別的東西辦得到。self.addEventListener('fetch', (event) => { if (event.request.mode === 'navigate') { event.respondWith(handleNavigation(event)); }});量測建置產物:文章 HTML、shell 與單篇文章專用的樣式
圖表是行內 SVG,文章頁面因此達到 317KB
第一個念頭是把所有頁面都預先快取,但量一下 .vercel/output/static/ 就知道行不通。整個建置產物是 73MB,共 1,819 個檔案:
| 項目 | 大小 | 檔案數 |
|---|---|---|
| 文章 HTML,三種語系 | 22.6 MB | 179 |
| 首頁、彙整、標籤、搜尋等其他 HTML | 8.3 MB | 217 |
_astro/ 的圖片 | 33.6 MB | 892 |
pagefind/ | 3.4 MB | 237 |
| 純文字版本 | 3.0 MB | 179 |
_astro/ 裡的 CSS 與 JS | 0.39 MB | 94 |
| feed、網站地圖、圖示、網頁字型 | 2.1 MB | 21 |
| 合計 | 73.4 MB | 1,819 |
最大的文章頁面光是 HTML 就有 317KB。本站的圖表是行內 SVG 元件,好讓圖中的文字可以翻譯,代價是每張圖都直接寫在 HTML 裡。要是把每個頁面都預先快取,一台只想看一頁的手機就得下載 31MB 的 HTML。
所以預先快取的只有 shell,頁面則是讀到一頁留一頁。離線就變成「我開過的都還在」,剛好就是飛機上的情境。留下頁面也不會多花任何流量,畢竟要畫出那個頁面,本來就得先把回應抓回來。
shell 只放每個頁面都會載入的東西
shell 共 43 筆、205KB:20 個樣式表、14 個指令碼、網頁字型、5 個圖示,以及三個語系各自的 web app manifest。每個 CSS 與 JS 的檔名都帶著內容雜湊,要等建置跑完才會確定,所以這份清單由 scripts/build-sw.ts 在建置時寫進 worker。
這支指令碼每次建置也會印出 shell 的大小,超過 500KB 的預算就發出警告。每位讀者第一次造訪時都會下載整個 shell,所以 shell 的大小(目前是 205KB)就是這套設計的初期成本。
用萬用字元收進 _astro/*.css,連所有文章的圖表樣式都會被預先快取
每個圖表元件都帶著自己的 scoped CSS,Astro 會把它輸出成 _astro/ 底下的獨立檔案。本站設定了 inlineStylesheets: 'never',讓 style-src 'self' 這條 Content Security Policy 能涵蓋所有樣式表,理由寫在那篇 CSP 文章裡。
因此,要是 shell 收進 _astro/ 底下所有的 CSS 與 JS,就等於連全站每篇文章的圖表樣式都一起預先快取了。某篇文章新增了三張圖之後,預算警告就跳了出來:
輸入node scripts/build-sw.ts輸出sw.js: version 93b802612533, 103 shell entries (503.1KB), 3 offline pages shell is 503.1KB, over the 500.0KB budget in scripts/build-sw.ts. Every reader downloads this on their first visit — check what got included.只有單篇文章的圖表會用到的樣式,佔了 94 個 chunk 中的 60 個,合計 299KB。但光看名稱挑不出這些 chunk,因為 Astro 是拿 CSS chunk 裡的第一個元件來命名的:同樣叫 Pipeline.*.css,在兩篇不同的文章裡指的是兩張不同的圖表。
所以指令碼會逐一讀取建置出來的每個 HTML 檔,記下每個 chunk 被哪些頁面連結,因為頁面實際連結了什麼,才是可靠的依據。
| Chunk | 連結它的頁面 | 放進 shell |
|---|---|---|
BaseLayout.*.css,29KB | 每個頁面 | 是 |
ArticleLayout.*.css,10KB | 每篇文章 | 是 |
pagefind-ime-guard.*.js | 沒有頁面連結,由搜尋用的指令碼匯入 | 是 |
IndexNowKey.*.css,7.3KB | 單篇文章的三種語系 | 否 |
VanishingGradientTwoAxes.*.css,52KB | 單篇文章的三種語系 | 否 |
只有單一篇文章用到、其他頁面都沒用到的 chunk,才會被排除在 shell 之外。例如 IndexNowKey.*.css 只有 IndexNow 那篇文章會載入,所以被排除;BaseLayout.*.css 每個頁面都會載入,所以留下來。
有些 chunk 不會出現在任何頁面的 HTML 裡,因為載入它的是指令碼,而不是頁面本身。pagefind-ime-guard.*.js 就是一個例子:雖然掃描 HTML 看不出誰在用它,但每個頁面上的搜尋指令碼都會匯入它,所以需要把它留在 shell 裡;少了它,搜尋指令碼離線時就無法啟動。
for (const page of files.filter((path) => path.endsWith(".html"))) { const html = await readFile(join(OUT_DIR, page), "utf8"); const segments = page.split("/"); if (LOCALES.includes(segments[0])) segments.shift(); const owner = segments.length === 3 && segments[0] === "blog" && segments[2] === "index.html" ? `blog/${segments[1]}` : "site";
for (const [asset] of html.matchAll(/\/_astro\/[^"'\s)]+\.(?:css|js)/g)) { const set = owners.get(asset) ?? new Set(); set.add(owner); owners.set(asset, set); }}// 只被單一 `blog/<slug>` 連結的 chunk,交給執行期快取處理。修改之後,同一次建置的輸出:
輸入node scripts/build-sw.ts輸出sw.js: version 4d9dcd1871d4, 43 shell entries (204.5KB), 3 offline pages 60 per-article chunks (298.6KB) left to runtime cachingshell 變成了 205KB,就算新文章加入了新的圖表也不會再讓它變大。Google 用來打造 Service Worker 的函式庫 Workbox,其官方指引也是這樣建議:每個頁面都需要的東西才預先快取,只有特定頁面用到的 chunk 就交給執行期快取。
只有瀏覽器回報了數據節省模式,才會略過 shell
我曾想過,網站默默下載東西,會不會讓讀者感到反感?不過整個 shell 也才 205KB 而已,為了它跳出同意對話框,反而比默默下載更打擾讀者,所以乾脆不問。開了數據節省模式的讀者一樣會裝上 worker,讀過的頁面也一樣留著,只是頁面不會要求 worker 去抓 shell。
判斷用的是 navigator.connection?.saveData,而它只在以 Chromium 為基礎的瀏覽器上有作用。MDN 把 Network Information API 標為「Limited availability」(支援度有限),Safari 與 Firefox 也都沒有實作 saveData。在這些瀏覽器上,頁面無從得知讀者有沒有開數據節省模式:這個判斷不會報錯,只會回傳 undefined,程式把它當成「沒開」,所以就算讀者想省流量,205KB 的 shell 還是照樣下載。
五個快取:shell、頁面、圖片、文章樣式與離線頁面
快取會不斷變大,所以我問了三件事:要不要只快取讀者所在的語系、快取項目要不要過期、要不要設上限。AI 給的答案是要、不要、要。
只預先快取讀者所在語系的首頁
worker 只預先快取讀者目前所在語系的首頁,其他語系一概不抓。樣式表和指令碼由三種語言共用,所以多一種語言,多出來的其實只有一個首頁。從來不打開日文版的讀者,也用不到日文首頁。
用上限取代過期時間
Cache API 沒有過期機制,worker 也沒有。設 TTL 就得另外維護一張時間戳記表,只為了猜內容是不是舊了,而且照著時鐘淘汰,還會丟掉讀者刻意留下的頁面。其實內容有沒有變,看部署就知道:每次部署都會產生新版的 worker,shell 也會跟著新版本重新比對、更新。
文章在每次造訪時都會在背景重新驗證;列表頁則改成網路優先,原因寫在後續的文章裡。
於是改用數量上限來控制快取大小,上限是依量測到的平均大小算出來的:文章 HTML 是 129KB,_astro 的一張圖片是 39KB,單篇文章專用的樣式 chunk 則是 5KB。
| 快取 | 存放什麼 | 上限 | 達上限時的大小 |
|---|---|---|---|
oharu-pages | 讀過的文章 HTML | 50 | 約 6.3 MB |
oharu-images | 頁面顯示時載入的圖片 | 200 | 約 7.7 MB |
oharu-assets | 只有單篇文章用到的樣式 | 150 | 約 0.8 MB |
oharu-shell | 全站共用的 CSS 與 JS、字型、圖示、manifest | 120 | 約 0.6 MB |
oharu-offline | 三個離線頁面 | 不裁減 | 約 100 KB |
淘汰採用依寫入順序的 FIFO,這是 Cache API 唯一提供的順序。真正的 LRU(Least Recently Used:先淘汰最久沒被打開的項目)得另外用 IndexedDB 記錄每筆項目的存取時間;但部落格的讀者大致是照發文順序往後讀,兩種做法的結果其實一樣。
單篇文章專用的樣式有自己的快取
文章載入圖表樣式時,worker 會把它們放進 oharu-assets。只要在線上打開過一次,離線時圖表照樣畫得出來。worker 依照預先快取清單來分流:
if (SHELL_ASSET.test(url.pathname)) { event.respondWith( SHELL_PATHS.has(url.pathname) ? cacheFirst(request, SHELL_CACHE, SHELL_CAP) : cacheFirst(request, ASSET_CACHE, ASSET_CAP), ); return;}兩個快取的累積方式不同。每次部署都會往 shell 追加帶著新雜湊的檔案,圖表的 chunk 則只在第一次讀到時寫入一次。如果共用同一個 FIFO 上限,連續幾次部署就可能淘汰掉某篇文章的圖表樣式,而那篇文章本身還留在 oharu-pages 裡。
上限 150 相當於每個留存的頁面配三個。一篇文章大約只會載入一個這種 chunk,所以要讀過約 150 篇各自帶著不同 chunk 的文章,FIFO 才會淘汰最舊的那個,而本站根本沒有那麼多 chunk。
離線頁面永不裁減
離線頁面放在自己的快取裡,是因為它們最先寫入,而 FIFO 是從最前面開始刪。放在 shell 裡的話,它們會是最先淘汰的一批,網站用得愈久,離線頁面就愈可能消失。
離線頁面的語言只看網址決定:/ja/… 和 /zh-tw/… 直接寫明了,沒有前綴的路徑則是英文,因為英文頁面就放在 src/pages/ 的根目錄。
function offlineLocaleFor(url) { const prefix = url.pathname.split('/')[1]; return LOCALES.includes(prefix) ? prefix : DEFAULT_LOCALE;}worker 不會去讀 navigator.language。本站有不少讀者的瀏覽器設定是 ja-JP,卻是刻意在讀英文;對英文連結回一個日文提示,等於推翻了他們的選擇。離線頁面帶著完整的網站頁首,要是網址的語言不是想要的,點一下語言切換選單就能換。
測試時踩到的三個坑:DevTools 離線模式、3xx 導覽、圖示的位元組比對
DevTools 離線模式下,Service Worker 仍然連得上網
Network.emulateNetworkConditions 只對設定它的那個 target 生效,而 Service Worker 和它服務的頁面是不同的 DevTools target。把 Chrome DevTools 設成 Network: Offline 之後,從沒造訪過的文章依然顯示得出來,但這套設計只快取讀過的頁面,照理說根本不可能做到。在頁面裡跑一行就知道原因:
try { await fetch('/robots.txt', { cache: 'no-store' }); }catch (e) { console.log('THREW: ' + e.message); } // THREW: Failed to fetch頁面確實沒有網路,但 worker 有,所以它當場把每個頁面抓下來交了回去。
Network.emulateNetworkConditions 以 target 為單位套用,而 worker 和它所服務的頁面是不同的 target。在這種模擬下,navigator.onLine 同樣維持 true,連另一個能察覺問題的訊號也被藏住了。直接把伺服器關掉,測試才算數:
lsof -ti:4321 | xargs kill伺服器真的關掉之後,造訪過的文章完整顯示了出來,樣式表和當初抓過的圖片都在。沒造訪過的網址則回傳了正確語言的離線頁面,狀態碼 503,網址列上仍是原本要求的網址。
導覽收到的 3xx 會變成 opaqueredirect
導覽的 Request 帶著 manual 這個重新導向模式,所以 3xx 回來時是一個 opaqueredirect:type 是 'opaqueredirect'、status 是 0、ok 是 false,redirected 也是 false。用 response.redirected 判斷是否為重新導向的那個 if,條件永遠不會成立,裡面的處理也就從來不會執行。
opaqueredirect 是一種被封起來的回應:它只告訴 worker「伺服器回了重新導向」,其他內容一律看不到,連要導向哪裡都不知道。只要原封不動交回去,瀏覽器就會自己完成這次重新導向。
redirected 是 false,所以檢查它的那道判斷從未執行,回應最後被送進一個不接受 status 0 的建構子。// opaqueredirect 的 `redirected` 是 false,所以這一行永遠不會執行。if (response.redirected) return Response.redirect(response.url, 302);
if (response.ok) { /* … */ }
return offlineResponse(url, response.status); // status === 0接著 offlineResponse 會呼叫 new Response(body, { status: 0 }),而 Response 的建構子只接受 200 到 599。它丟出 RangeError,交給 respondWith 的 Promise 因此被 reject,於是一個沒裝 worker 就能正常開啟的網址,讀者看到的卻是瀏覽器的錯誤頁。拿一台會回 301 的本機伺服器測試,結果是 net::ERR_FAILED。
if (response.type === 'opaqueredirect' || response.status === 0) return response;把 opaqueredirect 原樣交回去,瀏覽器就會自己跟著重新導向,同一個網址也就打得開了。production 環境目前不會走到這條路徑:2026-10-04 當天,/ja、/privacy 和一個沒有結尾斜線的文章網址全都直接回了 200,沒有重新導向。這段檢查是替本站將來加上的第一條重新導向規則預先準備的。
sharp.composite() 的輸出在 macOS 和 Linux 上不一樣
建置時會從 public/favicon.svg 產生主畫面用的圖示,並用 --check 模式防止圖示和來源脫節。比對 PNG 位元組的檢查在 macOS 上通過,到了 CI 卻失敗:
PWA icons are stale: public/pwa-icon-512-maskable.png — differs from 20575 bytes public/apple-touch-icon.png — differs from 6395 bytes本機上這兩個檔案是 20,596 與 6,399 位元組。出現差異的兩個,正好是會經過 sharp.composite() 的那兩個;直接從 SVG 點陣化的另外兩個則一個位元組都不差。libvips 在 macOS arm64 與 Linux x64 上的合成與編碼方式不同,所以只要比對位元組,就算圖檔本身完全沒問題,檢查也永遠過不了。
這個檢查要抓的是 favicon 改了卻沒人重新產生,也就是輸入變了。所以它比對的是輸入:SVG 的雜湊、圖示規格的雜湊,以及從每個檔案讀回來的尺寸。
{ "source": "public/favicon.svg", "sourceHash": "64655d5b744ddbff", "specHash": "ebf2f5ba3712cce2"}尺寸是從檔案讀出來,而不是取自鎖定檔,所以截斷或手動替換過的 PNG 一樣抓得到。竄改 sourceHash 後,檢查會以結束碼 1 失敗;重新產生之後,則以結束碼 0 通過。
在 production 環境驗證:離線的 iPhone、sw.js 的 header、Safari 的七天期限
飛航模式下的 iPhone
手機切到飛航模式後,打開一個從沒快取過的網址,出現的是離線頁面:

頁面靠 navigator.onLine 在兩種文字之間選擇,而 DevTools 的模擬會讓它維持 true,所以在桌機上永遠看不到離線那一版。真的把無線訊號關掉,才會走到這個分支,結果頁面選對了。

沒有訊號,先前讀過的文章照樣打開了。頁首、語言切換選單和頁尾來自 shell,文章與縮圖則來自兩個執行期快取。
這兩張截圖都是在看得到網址列的 Safari 分頁裡拍的,只能證明在分頁裡可以離線閱讀,對已安裝的 app 則什麼也證明不了。所以接下來,我把網站加到了主畫面:

「加入主畫面」的面板提供的名稱是 oharu,也就是 manifest 裡的 short_name。啟動後沒有網址列,可見 display: standalone 確實生效;從英文根目錄安裝的話,start_url 會讓它打開英文首頁。
用 —prebuilt 部署時,vercel.json 的 header 照樣送到 production 環境
Vercel 轉接器會自己寫出 .vercel/output/config.json,而 CI 是用 --prebuilt 部署的,所以 vercel.json 裡的 headers 區塊有可能在部署時消失。production 環境的回應給出了答案:
輸入curl -sSI https://oharu121.com/sw.js | grep -i cache-control輸出cache-control: public, max-age=0, must-revalidate這個 header 只存在於 vercel.json,所以兩邊的設定確實會合併,不需要任何後處理步驟。
尚未驗證:主畫面 app 能否撐過七天期限
如果使用者用了七天 Safari 都沒和某個網站互動,Safari 就會刪掉該網站所有指令碼可寫入的儲存空間,Cache API 也包含在內。WebKit 讓主畫面上的 web app 不受這條規則限制,改以 app 本身的使用天數來計算。
安裝好的 app 擱著一週不開,快取還留不留得住,目前還沒測過;navigator.storage.persist() 在主畫面 app 裡會不會獲准,也還沒驗證;saveData 那個分支同樣沒測,因為根本沒辦法如實模擬。
總結
這套設計是量測建置產物之後才定下來的。為了讓圖表能翻譯,圖表直接寫在 HTML 裡,全站 HTML 合計就有 31MB。所以 worker 只預先快取 205KB 的 shell,讀過的頁面才留下來,最多 50 篇。
shell 只放每個頁面都會載入的東西。只有單篇文章的圖表用到的樣式,則在打開那篇文章時放進 oharu-assets,和 shell 分開存放,部署就淘汰不到它們。要分辨這兩種檔案得看建置出來的 HTML,因為光看 chunk 的名稱,完全看不出有哪些頁面連結它。
有三個檢查給出了錯誤的答案:DevTools 離線模式沒讓 worker 斷線;重新導向的那段檢查讀了一個在導覽上永遠是 false 的欄位;圖示檢查比對的位元組在兩台機器上永遠不會一致。修正後,三者分別改成確認伺服器真的關掉了、看回應的 type,以及比對圖示的輸入。
我把手機切到飛航模式,打開先前讀過的一篇文章,照樣完整顯示出來。
參考連結
- Using Service Workers, including the fetch event and the Cache API
- Workbox: precaching dos and don’ts, on precaching only what every page needs
- Storage quotas and eviction criteria, including the 5 MiB localStorage limit
- Response() constructor, whose status must be in the range 200 to 599
- Response.type, listing opaqueredirect and what it means for a navigation
- WebKit: Full Third-Party Cookie Blocking and More, the seven-day cap and the home-screen exception
- WebKit: Updates to Storage Policy
- StorageManager.persist(), which prompts in some browsers
- NetworkInformation.saveData, marked limited availability rather than Baseline


