# 讓 Astro 部落格讀過的頁面也能離線閱讀 — Service Worker、50 頁上限、不設 TTL

> 量測結果決定了 Astro 部落格的 Service Worker 設計：預先快取 287KB 而不是 11.7MB 的 HTML，頁面上限 50 筆，而且不設過期時間。

- Source: https://oharu121.com/zh-tw/blog/astro-service-worker-offline-reading-visited-pages-cap/
- Published: 2026-08-20T09:05:14+09:00
- Tags: Astro, Web API, 網頁效能, Service Worker, PWA, iOS

---
**重點摘要**

- `localStorage` 無法離線提供一個頁面。它從網路層根本碰不到，導覽請求永遠不會去查它。
- 量測建置產物之後，原本看似理所當然的設計就出局了：42MB 的輸出裡有 11.7MB 是 HTML，預先快取整個網站從一開始就不成立。shell 是 287KB，其餘的都在你讀到時才留下。
- 不設過期時間。每次部署已經精準地讓改動的部分失效，所以改用筆數上限來限制成長：頁面 50 筆、圖片 200 筆。
- DevTools 的「Offline」測試對 Service Worker 什麼都證明不了。限流只作用在頁面那個 target，worker 保有自己的網路，會從還在運作的伺服器抓下來回應。

## 引言

我會讀這個部落格。由寫的人說出來有點奇怪，但我確實會回頭查自己的文章，而那通常發生在飛機上，沒有網路，分頁裡空空如也。我想要的很單純：把讀過的文章打開，Wi-Fi 關著，內容還在。

我不知道這辦不辦得到。我最初的想法是把內文塞進 `localStorage` 之後再讀回來，而這件事我請人去查證，而不是直接假設。**答案是辦不到，理由是結構性的：`localStorage` 從網路層碰不到，導覽請求永遠不會去查它。** 能夠回應一個離線導覽的，只有把回應保存在 Cache API 裡的 Service Worker。

決定其餘一切的不是偏好，而是量測建置產物。本文整理那些讓理所當然的設計出局的數字、由此長出來的四個快取，以及過程中四個信心十足卻是錯的檢查。

## localStorage 無法回應一個導覽

`localStorage` 的吸引力在於寫入一行、讀出也是一行。問題出在瀏覽器去取一個 URL 的時候：它只會查 HTTP 快取和網路，沒有別的。頁面自己存下來的字串，沒有任何一個環節會被拿出來當作候選回應。

另外三個限制讓情況更糟。它的上限大約 5MB，這裡一篇文章就幾乎填滿。它存的是字串，所以一份 HTML 文件會變成一條長字串，沒有標頭、沒有狀態碼、沒有 Content-Type。而且它是同步的，讀取大筆資料會卡住主執行緒。

**Cache API 在這四點上都相反。** 它保存 `Request` 對 `Response` 的組合，連標頭和狀態碼一起，而這正是回應一個導覽所需要的東西。Service Worker 站在網路前面當代理伺服器，分頁關掉之後仍然活著，而且能把保存的回應交回去。

```js
// A service worker can answer a navigation. Nothing else in the browser can.
self.addEventListener('fetch', (event) => {
	if (event.request.mode === 'navigate') {
		event.respondWith(handleNavigation(event));
	}
});
```

## 量測建置產物之後看到什麼

這時候的直覺是「把整站預先快取起來就好」。阻止這件事的是對 `.vercel/output/static/` 的量測。

| 項目 | 大小 | 檔案數 |
| --- | --- | --- |
| 文章 HTML（已發布 73 頁、3 種語言） | 8.87 MB | 73 |
| 標籤、列表、搜尋與隱私權的 HTML | 2.81 MB | 109 |
| `_astro/` 的圖片 | 22.81 MB | 489 |
| `pagefind/` | 2.20 MB | 約 100 |
| Markdown 副本 | 1.42 MB | 73 |
| **CSS、JS 與網頁字型** | **0.21 MB** | 19 |
| feed、sitemap、`llms.txt` 與圖示 | 3.68 MB | 約 20 |
| **合計** | **42 MB** | 883 |

**單一篇文章頁面的 HTML 可以到 300KB。** 這不是臃腫。本站的圖表是行內 SVG 元件，好讓標籤可以翻譯，於是圖就跟著進到文件裡，而不是變成另一個檔案。對三種語言來說這是對的取捨，同時也是「只預先快取 HTML 就好」要花 11.7MB 的原因。

對一台只要一個頁面的手機推 11.7MB 過去，換來的是多數讀者用不到的保證，這筆交易並不划算。**於是設計反了過來：預先快取 shell，經過的頁面就順手留下。** 離線於是變成「我開過的都還在」，而這正是真正會發生的情境。而且不花額外的流量，因為那些回應本來就得抓下來才畫得出頁面。

shell 最後是 27 筆、287KB：14 個樣式表、4 個指令碼、網頁字型、圖示，以及三份 manifest。這個數字也順帶回答了我先前提出的疑問：網站默默下載東西，讀者會不會反感。如果是 11.7MB，答案是會。到了 287KB，一個同意對話框耗掉的注意力比它想保護的東西還多。所以沒有任何提示，而該讓步的地方放在另一處：`navigator.connection.saveData`。開了數據節省模式的讀者一樣會裝上 worker，讀過的一樣會留著，只是不必為預先抓取付出流量。

**這個讓步沒有聽起來那麼大，與其讓它被讀成通則，不如把話說清楚。** Network Information API 並非 Baseline，MDN 以「在一些最廣泛使用的瀏覽器上無法運作」為由，將它標記為*有限的可用性*。在沒有 `navigator.connection` 的環境裡，選擇性串連得到 `undefined`，這道防護什麼也沒做，shell 照樣被抓下來。這個讓步在有實作的瀏覽器上是真的，在其他地方則形同虛設。

## 四個快取，以及其中一個永不裁減的理由

*Figure — CacheLayout: shell 是用上限而不是清除來控制，好讓部署前就快取起來的 HTML 仍然找得到它引用的樣式表。*

關於成長，我提了三個問題：是不是只快取讀者實際所在的語言、項目是否應該有期限、要不要設上限。答案是要、不要、要。

**只有讀者當下語言的首頁會被預先快取。** 樣式表和指令碼由三種語言共用，所以真正因語言而異的只有首頁和離線頁面。先抓一個讀者永遠不會打開的語言，正是整套設計要避開的那種預判。

**不設過期時間，因為 Cache API 沒有過期機制，自己造一個比不做更糟。** 設定 TTL 等於要額外維護一張時間戳記表，只為了猜測內容是不是舊了；而建置本身已經精準地讓改動的部分失效：每次部署都會產生新的 worker 版本，shell 也會據此重新對齊。同時每一次導覽都會在背景重新驗證。最後這一點對文章成立，對列出文章的頁面卻不成立，後續文章做了修正：**[列表頁必須先問網路](/zh-tw/blog/astro-service-worker-stale-index-network-first/)**。搬一個時鐘進來，只會依照跟內容有沒有變無關的週期，丟掉讀者刻意留下的頁面。

真正合適的手段是上限，而數值來自量測到的平均值：文章 HTML 是 121KB，`_astro` 的圖片是 48KB。

| 快取 | 存放什麼 | 上限 | 上限時的大小 |
| --- | --- | --- | --- |
| `oharu-pages` | 讀過的文章 HTML | 50 | 約 6 MB |
| `oharu-images` | 頁面畫出來的圖表 | 200 | 約 9.6 MB |
| `oharu-shell` | CSS、JS、字型、圖示、manifest | 120 | 約 1 MB |
| `oharu-offline` | 三個離線頁面 | 不裁減 | 約 60 KB |

**淘汰是依照寫入順序的 FIFO，因為那是 Cache API 唯一暴露出來的順序。** 真正的 LRU 需要一張 IndexedDB 的側表來記錄存取順序，而對一個大致依時間順序閱讀的部落格來說，兩者根本分辨不出來。為了這點差別搬一套資料庫進來並不值得。

值得停下來看的是最後一列。**離線頁面放在自己的快取裡，正是因為它們最先被寫入，而 FIFO 是從最前面開始刪。** 留在 shell 裡的話，它們會是最先被淘汰的一批，也就是說網站用得愈久，那個備援頁面愈可能消失。

## 對著還在運作的伺服器通過的離線測試

第一次驗證看起來很有說服力，實際上毫無用處。

智能體把 Chrome DevTools 設成 `Network: Offline`，切到一篇已快取的文章，看著它顯示出來。接著切到一篇從來沒開過的文章，那篇也顯示了，而這是「讀到才留」的設計不可能辦到的事。定案的是在頁面裡跑的一行：

```js
try { await fetch('/robots.txt', { cache: 'no-store' }); }
catch (e) { console.log('THREW: ' + e.message); }   // THREW: Failed to fetch
```

**頁面確實沒有網路。worker 有。**

*Figure — TargetSplit: `Network.emulateNetworkConditions` 以 target 為單位套用，而 worker 和它所服務的頁面是不同的 target。*

**Service Worker 是獨立的 DevTools target，而 `Network.emulateNetworkConditions` 只對你設定的那個 target 生效。** 頁面離線了，worker 沒有，於是 worker 當場把每個頁面抓下來交回去，所有測試都因為錯誤的理由通過。在那種模擬底下 `navigator.onLine` 也維持 `true`，於是你可能藉此察覺的另一個訊號也消失了。

修法是別再模擬，直接把伺服器關掉：

```bash
lsof -ti:4321 | xargs kill
```

對著一個真正死掉的來源重跑，結果才有意義。造訪過的文章完整顯示，63 個段落連同樣式表，以及當初真的抓下來的那兩張圖。沒造訪過的網址則回傳正確語言的離線頁面，狀態碼 503，而要求的網址仍留在網址列上。

## 一個永遠不會成立的重新導向判斷

程式碼審查找到一個當掉的情況，而且是那種你去找才會冒出來的。

*Figure — RedirectPath: 在 opaqueredirect 上 `redirected` 是 false，所以檢查它的那道判斷從未執行，回應一路掉進一個拒絕 status 0 的建構子。*

**導覽的 `Request` 帶著 `manual` 這個重新導向模式。** 這表示 3xx 不會以「已跟隨的重新導向」形式抵達。抵達的是 *opaqueredirect*：`type` 是 `'opaqueredirect'`、`status` 是 `0`、`ok` 是 `false`，而 `redirected` 也是 `false`。原本的程式碼檢查的，正好是那個永遠不會為真的欄位：

```js
// Never true for a navigation. The response falls straight through.
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`，這個 reject 逸出 `respondWith`，於是對一個沒裝 worker 就能正常開啟的網址，讀者拿到的是瀏覽器自己的錯誤頁。** 在一台會回 301 的本機伺服器上重現時是 `net::ERR_FAILED`，修好之後同一個網址就打得開了。

```js
if (response.type === 'opaqueredirect' || response.status === 0) return response;
```

**這裡要更正一件事，因為關於它有多急迫，我被告知了錯誤的說法。** 當時給的理由是，Vercel 對少了結尾斜線的目錄網址會回 308，所以每一個被去掉斜線的連結都會壞掉。事後去探測正式環境，什麼都沒有重新導向：`/ja`、`/privacy`、`/ja/tags`，甚至 `/…/index.html` 全都回 200。當掉是真的，也重現過，但為它舉出的那個觸發條件，在目前的路由設定下並不會發生。這個修正實際買到的，是將來加上重新導向規則時的安全，而不是解掉今天流量裡的一個 bug，這兩者不是同一個主張。

## PNG 的位元組跨平台不會一致

建置會從 `public/favicon.svg` 產生主畫面用的圖示，而我寫了一個 `--check` 模式來抓它們與來源脫節的情況。它比對的是產生出來的 PNG 位元組與已提交的版本。在本機通過，在 CI 失敗：

```text
PWA icons are stale:
  public/pwa-icon-512-maskable.png — differs from 20575 bytes
  public/apple-touch-icon.png — differs from 6395 bytes
```

本機上這兩個檔案是 20596 與 6399 位元組。**出現差異的兩個，剛好就是會經過 `sharp.composite()` 的那兩個**；純粹靠點陣化產生的另外兩個則完全一致。libvips 在 macOS 的 arm64 與 CI 的 Linux x64 上，合成與編碼的方式都不同，所以這個檢查會一邊回報完全沒問題的圖檔，一邊在 CI 上永遠失敗下去。

這個檢查真正要抓的，是有人改了 favicon 卻忘記重新產生。那是輸入變了，所以現在比對的就是輸入：SVG 的雜湊、圖示規格的雜湊，以及從每個檔案實際讀回來的尺寸。

```json title="scripts/pwa-icons.lock.json"
{
	"source": "public/favicon.svg",
	"sourceHash": "64655d5b744ddbff",
	"specHash": "ebf2f5ba3712cce2"
}
```

尺寸是從檔案讀出來而不是相信鎖定檔的記載，所以被截斷或被手動替換的 PNG 一樣抓得到。而且這個檢查本身是刻意弄壞來驗證的：竄改 `sourceHash` 會以 1 結束，重新產生則以 0 結束。**一個只被看過通過的檢查，等於還沒被測試過。**

## 一個留在已經完成的程式碼上的 TODO

另外三個都是在沒問題時說「沒問題」的檢查。這一個方向相反。

`offlineLocaleFor` 負責決定要回傳哪個語言的離線頁面，而它出貨時帶著一個 `TODO`，以及二十行把「沒有語言前綴的網址」描述成待決編輯問題的註記。它並不是待決的。因為 `prefixDefaultLocale: false`，英文頁面就是放在 `src/pages/` 根目錄的那些，所以 `/blog/some-slug/` 不是一個語言曖昧的位址。它是一個英文位址，而這個函式對本站能產生的每一個網址，早就回傳正確答案了。

```js
function offlineLocaleFor(url) {
	const prefix = url.pathname.split('/')[1];
	return LOCALES.includes(prefix) ? prefix : DEFAULT_LOCALE;
}
```

**真正待決的是一個小得多的偏好，而且不是缺陷：** 要不要讀 `navigator.language`，用讀者的語言蓋掉網址的語言。這件事被否決了。理由是，一個去嗅探瀏覽器的 worker 會變成本站唯一會猜測語言的元件；而在這裡，有不少讀者帶著 `ja-JP` 卻是刻意在讀英文，那樣做等於用日文的提示回應一個英文連結。標記拿掉了，理由則搬進了說明註解，在那裡它可以被反駁，而不是只被察覺為「少了什麼」。

## 出貨了什麼，又有什麼還沒驗證

worker 已經在線上。讀一篇文章就會被快取，`/api/likes/*` 和 `/pagefind/*` 不會進入任何快取，shell 穩定在 27 個資源。讀者自己那個語言的首頁不放在 shell，而是和讀過的頁面存在一起，另外兩種語言的一個都沒有。

接著我把手機切到飛航模式，打開了網站。

*Image: 飛航模式下的 iPhone 顯示網站的離線頁面，標題是「You are offline」，下方有 Try again 與 Home 兩個按鈕，上方是完整的網站頁首*

*一個從未被快取過的網址。文字用的是離線那一版而不是找不到頁面那一版，而這正是 DevTools 測試碰不到的分支。*

這張截圖替桌面端驗證做不到的事定了案。頁面是靠 `navigator.onLine` 在兩種文字之間做選擇，而在 DevTools 的模擬下這個屬性維持 `true`，所以無論怎麼限流，離線那一版的文字都不會出現。**只有真的把訊號關掉才會走到這個分支，而它選對了。**

*Image: 同一台 iPhone 仍在飛航模式，顯示先前讀過的一篇文章，連同縮圖與標題完整呈現*

*先前讀過的一篇文章，在沒有訊號的情況下由快取送出。縮圖會一起出現，是因為那個頁面早就抓過它了。*

**這就是這套東西當初要做的事，在它當初設想的裝置上運作起來的樣子。** 頁首、語言切換與頁尾都在，代表 shell 有到位；文章與圖片則來自兩個執行期快取。

有一個問題從規劃階段就懸著沒有答案，而正式環境的部署替它定了案。Vercel 的轉接器會自己寫出 `.vercel/output/config.json`，而 CI 是用 `--prebuilt` 部署的，所以 `vercel.json` 裡的 `headers` 區塊能不能存活下來，當時真的無法確定。

```text
$ curl -sSI https://oharu121.com/sw.js | grep -i cache-control
cache-control: public, max-age=0, must-revalidate
```

**這個標頭除了 `vercel.json` 之外哪裡都沒有，所以它確實被合併進去了。** 結果根本不需要任何後處理步驟。

前面那兩張是 Safari 的畫面，網址列與分頁數都看得到，所以它們自己能證明的只有「在瀏覽器分頁裡可以離線閱讀」，對已安裝的應用程式則什麼都沒說。於是接下來，網站被放上了主畫面。

*Image: 安裝到 iPhone 主畫面上的圖示：朱紅色圓角方形，中間是白色的櫻字，四角是填滿的而不是透明的*

*iOS 會把圖示裁成自己的圓角方形。四角是朱紅色而不是黑色，正是那個 PNG 以不透明方式產生的原因。*

**manifest 要求的每一項都被照做了。** 圖示是那個印記本身，而不是頁面的截圖，iOS 的圓角遮罩在每個角落碰到的都是朱紅色，不會出現透明 PNG 會造成的黑色楔形。「加入主畫面」的面板提供的名稱是 `oharu`，也就是 manifest 裡的 `short_name`。啟動之後沒有網址列也沒有分頁列，代表 `display: standalone` 有被讀到。而從英文根目錄安裝，打開的就是英文首頁，這是 `start_url` 在發揮作用。

**還沒解決的是要花一週的那一項。** Safari 會在閒置七天後清掉網站存下來的所有東西，而主畫面上的應用程式被記載為例外。至於 `navigator.storage.persist()` 在那裡是否真的會被授權、快取能不能撐過八天不開啟，兩者都還沒驗證。`saveData` 那個分支也一樣，它是一道沒有辦法誠實模擬的防護。

## 總結

這裡的設計不是選出來的。它是量測完一個 42MB 的建置產物、發現其中 11.7MB 是 HTML 之後剩下來的東西，而之所以如此，是因為可翻譯的圖表就住在文件裡。預先快取整站從一開始就不成立，所以 shell 是 287KB，頁面則在讀到時才留下。

四件出錯的事情裡有三件形狀相同：**檢查存在、跑過了，卻什麼都沒證明。** 離線測試會通過，是因為限流從來沒到達 worker。重新導向的判斷讀的是一個在它所針對的回應上永遠為 false 的欄位。圖示檢查比對的是兩台機器永遠不會一致的位元組。這幾件都看起來像驗證卻不是，而那比完全沒有檢查更昂貴，因為它還一併買下了錯誤的安心。

第四件方向相反。**一個對本站所有可能網址都已經處理妥當的函式，帶著 `TODO` 就出貨了，於是完成的程式碼被標成了未完成。** 它已經拿掉，而取代它的東西，正是這條規則之所以如此的理由。

我想要的那件事確實可行。我把手機切到飛航模式，打開先前讀過的一篇文章，它就在那裡。安裝到主畫面也可行，圖示、名稱與獨立視窗啟動都沒問題。還檢查不了的是那個要花一週的版本：把安裝好的東西擱著不動，看 iOS 會不會留住它存下來的內容。

## 參考連結

- [Using Service Workers, including the fetch event and the Cache API](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers)
- [Response() constructor, whose status must be in the range 200 to 599](https://developer.mozilla.org/en-US/docs/Web/API/Response/Response)
- [Response.type, listing opaqueredirect and what it means for a navigation](https://developer.mozilla.org/en-US/docs/Web/API/Response/type)
- [WorkerNavigator, the reason a service worker can read navigator.language](https://developer.mozilla.org/en-US/docs/Web/API/WorkerNavigator)
- [WebKit: Updates to Storage Policy, the seven-day eviction and the Home Screen exception](https://webkit.org/blog/14403/updates-to-storage-policy/)
- [StorageManager.persist(), which prompts in some browsers](https://developer.mozilla.org/en-US/docs/Web/API/StorageManager/persist)
- [NetworkInformation.saveData, marked limited availability rather than Baseline](https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/saveData)
