接近黑色的卡片上有白色 PWA 標誌,P、W、A 共用筆畫,連成一體的稜角字樣

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

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

本頁目錄

引言

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

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

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

localStorage 無法回應一個導覽

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

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

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

// 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 照樣被抓下來。這個讓步在有實作的瀏覽器上是真的,在其他地方則形同虛設。

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

四個快取,其中三個有上限四個快取方框。shell 存放樣式表、指令碼、字型、圖示與 manifest,上限 120 筆。pages 存放文章 HTML 上限 50 筆,images 存放圖表上限 200 筆,兩者都只在讀者開啟頁面時才累積。離線快取存放三個離線頁面,永不裁減。oharu-shellCSS、JS、字型、圖示、manifest上限 120預先快取oharu-pages讀過的文章HTML上限 50讀到才存oharu-images頁面畫出來的圖表上限 200讀到才存oharu-offline三個離線頁面不做裁減預先快取
shell 是用上限而不是清除來控制,好讓部署前就快取起來的 HTML 仍然找得到它引用的樣式表。

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

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

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

真正合適的手段是上限,而數值來自量測到的平均值:文章 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,切到一篇已快取的文章,看著它顯示出來。接著切到一篇從來沒開過的文章,那篇也顯示了,而這是「讀到才留」的設計不可能辦到的事。定案的是在頁面裡跑的一行:

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

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

DevTools 的網路限流以 target 為單位兩條路徑指向同一台預覽伺服器。上方來自頁面的路徑被 Offline 模擬切斷,fetch 失敗。下方來自 Service Worker 的路徑不受影響,仍然連得到伺服器,因此 worker 會用當場抓下來的頁面回應導覽。頁面 targetfetch 失敗Offline 只套用在這裡Worker targetService Workerfetch 仍然通得過預覽伺服器限流只影響其中一個 target
Network.emulateNetworkConditions 以 target 為單位套用,而 worker 和它所服務的頁面是不同的 target。

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

修法是別再模擬,直接把伺服器關掉:

終端機視窗
lsof -ti:4321 | xargs kill

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

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

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

opaqueredirect 穿過 redirected 判斷導覽的 fetch 碰到 3xx 時會解析成 opaqueredirect,status 為 0、ok 為 false、redirected 也是 false。舊程式碼檢查的是 redirected,在這裡永遠不成立,因此穿過該判斷、也過不了 ok 判斷,最後帶著 status 0 進到 Response 建構子並丟出 RangeError。修正後改看 type,把轉址原樣交還,由瀏覽器自己跟隨。導覽的 fetch 碰到 3xxopaqueredirectstatus 0 · ok false · redirected false修正前if (response.redirected)在這裡永遠不會成立,直接穿過if (response.ok)status 0 不算 oknew Response(body, { status: 0 })RangeError · respondWith 失敗 · 瀏覽器錯誤頁修正後if (type === "opaqueredirect") return response交給瀏覽器自己跟隨轉址
在 opaqueredirect 上 redirected 是 false,所以檢查它的那道判斷從未執行,回應一路掉進一個拒絕 status 0 的建構子。

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

// 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,修好之後同一個網址就打得開了。

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 失敗:

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 的雜湊、圖示規格的雜湊,以及從每個檔案實際讀回來的尺寸。

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/ 不是一個語言曖昧的位址。它是一個英文位址,而這個函式對本站能產生的每一個網址,早就回傳正確答案了。

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,而是和讀過的頁面存在一起,另外兩種語言的一個都沒有。

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

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

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

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

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

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

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

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

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

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

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

安裝到 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 會不會留住它存下來的內容。

參考連結

分享這篇文章