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

Service Worker 讓文章列表永遠慢一次造訪 — 只有列表頁改成先問網路

Astro 的 Service Worker 把每一次導覽都先用快取回應,新文章因此晚一次造訪才出現。列表頁現在會先問網路。

本頁目錄

引言

我發佈了一篇文章,打開部落格,它不在列表裡。重新整理之後就出現了。每次都這樣,所以我猜是網站把自己首頁的快取回給了我。離線閱讀 是兩天前在 0.32.0 出的,時間點直接指向跟著一起進來的 Service Worker。確實如此:worker 對每一次導覽都套用同一種快取策略,其中也包括那個唯一工作就是列出有哪些文章的頁面。 本文整理這個策略為什麼對文章正確、對列表錯誤,兩個行不通的修法,以及一個在程式碼審查抓到之前差點讓原本的問題變成永久狀態的期限。

worker 在每一次導覽做的事

毫無例外的 stale-while-revalidate。 先立刻回傳快取,再把從網路拿到的版本寫回去給下一次用。

scripts/sw/service-worker.js
const cached = await cache.match(key);
if (cached) {
event.waitUntil(revalidate(request, key, cache));
return cached;
}

沒有任何路徑逃得過這個分支。 當初寫這個 worker 時留下的註解就這麼說了:「讀者因此會落後一個建置版本,但只有一次導覽,在部落格上這換來立刻顯示的頁面是值得的。」

這筆交易對文章來說是划算的。文章的內容在發佈之後幾乎不動,所以回傳稍舊的版本只讓讀者付出一個他們永遠不會注意到的用字差異,換來的是立刻繪製完成的頁面。對列出文章的那個頁面來說就不划算,因為列表的「舊」不是「稍微舊一點」,而是讀者要找的東西看不見了。 worker 沒有辦法分辨這兩者,於是兩者走了同一條路。

同一次造訪,在兩種策略下的差別兩條路徑描述的是發佈之後開啟文章列表的同一次造訪。stale-while-revalidate 這條會立刻回傳快取,只把目前的版本存起來給下一次造訪用,所以新文章不見了。network-first 這條會先問網路再回答,所以新文章在列表裡。剛發佈一篇新文章stale-while-revalidate每一次導覽都走這條直接回傳快取的版本舊的列表。新文章不見了也會抓目前的版本,但要下一次造訪才看得到network-first現在列表頁走這條先問網路再回答目前的列表。新文章在裡面同樣的發佈、同樣的造訪,答案不同
同樣的發佈、同樣的造訪。先回傳快取,意思就是新的版本是為了一次還沒發生的造訪而抓的。

為什麼重新整理看起來像是修好了

快取從來沒有被凍住。它每次造訪都會更新,只是那次更新永遠只對下一次有用。

頁面顯示的內容 worker 在背後做的事
發佈一篇新文章
第 1 次造訪 舊的列表,沒有新文章 抓了新的列表,覆蓋快取
第 2 次造訪 新的列表,文章在裡面 再抓一次,再覆蓋一次

所以網站並不是永久快取,而是永遠慢一次造訪,顯示的是上一次造訪那個時間點的狀態。重新整理看起來像是修好了,只是因為重新整理就是第 2 次造訪。

有兩件事讓這個問題不容易被看出來。一般的重新整理不會繞過 Service Worker,只有強制重新整理才會,所以症狀看起來像是部署很慢,而不是快取出錯。而且唯一能注意到一篇文章不見的人,是本來就知道它存在的人,在個人部落格上那就是一個人。

兩個行不通的修法

部署時把快取清掉

看起來最聰明的修法,是整體保留 stale-while-revalidate,只在部署產生新的 worker 時把快取起來的列表丟掉。worker 本來就帶著一個 VERSION,由它自己的原始碼和所有預先快取檔案的位元組算出來,所以部署後可以在 activate 清掉那些項目,下一次導覽就會沒中快取而走向網路。

這行不通,而原因跟這份程式碼完全無關。 瀏覽器檢查 /sw.js 有沒有更新,是在處理完導覽之後,不是之前。所以部署後的第一次造訪,不管新的 worker 在 activate 會做什麼,都是由舊的 worker 回應。結果還是同樣「兩次造訪」的形狀,只是上面多疊了一套清快取的機制。

worker 的更新檢查發生在導覽之後四個步驟依序排列。導覽進來,舊的 worker 用快取回應它。瀏覽器要到這之後才會檢查 worker 腳本有沒有更新、裝上新的並清掉快取。第二個步驟之後的分隔線,標出這次造訪已經回應完畢的位置。這次造訪已經回應完了1導覽進來2舊的 worker用快取回應它3瀏覽器才去看/sw.js有沒有更新4新的 worker 裝好並清掉快取不管新的 worker 在 activate 做什麼,都慢了一次造訪
更新檢查跑在它本來要修的那次導覽之後,所以部署後的造訪一定是由前一個 worker 回應。

推廣來看,每一個候選修法都在同一個限制底下:沒有在那次造訪問過網路,那次造訪就不可能是最新的。 這裡的網路優先不是偏好問題,而是唯一能修好這個缺陷的形狀。

先回傳快取,在背後問網路

第二個候選是我提的:立刻回傳快取,在背後問網路,等結果回來再更新頁面。這是實際存在的模式,代理人用三個理由反對,而三個都站得住腳。

背後那次詢問,跟網路優先在等的是同一趟來回。 在健康的連線上大概只省下十分之一秒。在連得上但不回應的連線上,那次詢問一樣會卡住,於是讀者拿到立刻顯示的頁面,而更新永遠不會到,這是原本的問題換了一件比較好看的衣服。再者,部落格列表是一整片可以點的目標,停留時間大約兩秒,在讀者已經伸手要點某張卡片時重繪列表,等於他點了文章 A 卻落在文章 B。

這個模式適合停留時間長、而且不是由導覽目標構成的頁面,例如儀表板或訊息流。列表兩者都不是。

列表頁先問網路

修法是依照 URL 是為了什麼而存在來分策略,而不是依照抓它有多貴。

scripts/sw/service-worker.js
function isListing(pathname) {
const segments = pathname.split('/').filter(Boolean);
if (LOCALES.includes(segments[0])) segments.shift();
return segments.length === 0 || segments[0] === 'tags';
}

在任何語言下符合的只有兩種形狀:作為文章索引的首頁,以及 /tags 底下的一切。這兩者每次發佈都會被改寫。其餘的都是內容,而內容維持 stale-while-revalidate 不動,這正是保住當初做這個 worker 的目的,也就是離線閱讀。

用結構而不是路徑清單來判斷,代表之後多一個語言也不用動這裡。 可能出現在最前面的前綴 LOCALES 已經都有了,而預設語言沒有前綴,所以它的首頁就是 /。搜尋頁是刻意排除的:它的 HTML 裡沒有結果,有結果的是 Pagefind 的 bundle,而 fetch 處理函式本來就把那個留給網路。

這是我從代理人列出的選項裡挑的,勝過我自己提的背後詢問方案,也勝過讓網路和計時器賽跑的版本。

限制錯東西的期限

網路優先帶來一個真正的退步,而且不是這個取捨平常被討論的那一面。離線會立刻失敗,因為沒有訊號也沒有請求,所以快取馬上就出來。連得上但不回應的連線根本不會失敗。 隧道裡、旅館的網路登入頁、只剩一格訊號,這些情況會讓請求一直懸著直到瀏覽器自己逾時,而那遠比任何人願意盯著白畫面的時間長。

所以我要求加上有上限的等待,而且只在有快取可以退回時才套用。第一版實作用的是 AbortSignal.timeout,而程式碼審查的代理人在出貨前找出了它的問題。

fetch 在標頭抵達時就完成了,但中斷訊號仍然掛在回應內容的串流上。 一個持續走著的期限,因此會在慢速連線上於下載途中觸發。cache.putAbortError 失敗,catch 回傳舊的快取,而那個真的已經抵達的新回應被丟掉。只要連線一直慢,每次造訪都是如此。

這比那次發佈原本要修的問題更糟,也比它取代掉的行為更糟:先前在背後的重新驗證完全沒有期限,所以慢速連線的讀者只會舊一次導覽,下一次就是新的。

fetch 的期限如果不清掉,會比標頭活得更久時間軸從送出請求到第四秒。使用 AbortSignal.timeout 時,期限在標頭抵達後仍然繼續走,並在 2500 毫秒切斷內容,於是寫入快取失敗、回傳舊的快取。改成在標頭抵達時清掉的 AbortController,內容就能完整傳完,快取也會更新。標頭抵達 2.0 秒期限觸發 2500 毫秒內容傳完 4.0 秒AbortSignal.timeout中斷內容傳到一半被中斷。cache.put 失敗,回傳舊的快取,已經收到的回應被丟掉。AbortController,標頭抵達就清掉計時器清掉標頭一到就清掉計時器,內容要傳多久都可以。快取更新,回傳的是新的版本。
在標頭抵達時清掉計時器,正是「限制等待」和「限制下載」之間的分界。

修法是一個可以清掉的計時器,標頭一落地就清掉。

scripts/sw/service-worker.js
const controller = new AbortController();
const deadline = fallback ? setTimeout(() => controller.abort(), NETWORK_TIMEOUT) : null;
try {
const response = await fetch(request, { signal: controller.signal });
// Headers are in, so the wait this bounds is over. Anything still to come
// is the body draining, which gets as long as it needs.
if (deadline !== null) clearTimeout(deadline);

這個期限也只有在 cache.match 有東西可回時才啟動。快取是空的時候沒有東西可以退回,而一位第一次來、正用慢速連線開一篇 112KB 文章的讀者,會因此收到離線頁面,儘管那個請求本來就要成功了。

為什麼第一版測試在壞掉的 worker 上通過

驗證是用無頭 Chromium 跑正式建置和實際出貨的 worker,並在兩次造訪之間改寫建置出來的 HTML 來代替一次部署。它對著上面描述的那個壞掉的實作,回報 7 項全過。

一台從不回應的伺服器,分不出「沒有回應」和「回應很慢」。 它不送標頭,所以「標頭在期限內抵達、而內容在期限之後還在傳」這個情況根本不會發生。測試看的是卡住,缺陷住在慢速成功那一邊。

找得到它的檢查會立刻送出標頭,再把 72KB 的內容分成小段花四秒傳完,然後去問快取裡放的是什麼,而不是問頁面畫出了什麼

const stored = await page.evaluate(async () => {
const cache = await caches.open('oharu-pages');
const hit = await cache.match(new URL('/', location.origin).href);
return hit ? await hit.text() : '';
});

對著第一版實作:

FAIL a slow body still lands in the cache
served stale; cache NOT updated — staleness would persist

對著可以清掉的計時器:

PASS a slow body still lands in the cache
served fresh; cache updated

驗證

對著實際出貨的 worker,8 項全過:

PASS listing shows a new article on the FIRST visit
PASS /tags/ shows new content on the FIRST visit
PASS article keeps stale-while-revalidate
PASS a 308 on a cached listing still redirects with the timeout attached
PASS an unslashed article URL is answered from cache, not redirected
PASS a hanging network falls back to cache within the timeout
PASS a slow body still lands in the cache
PASS home is readable offline after PRIME, without ever visiting it

有意義的數字不是 8,而是同一套檢查對著修改前的 worker 跑出來的結果,因為一個不可能失敗的測試什麼都證明不了。

FAIL listing shows a new article on the FIRST visit
home served a stale copy — the reported bug
FAIL /tags/ shows new content on the FIRST visit
tag index served stale
PASS article keeps stale-while-revalidate
first visit stale: true, second visit fresh: true
FAIL home is readable offline after PRIME, without ever visiting it
offline navigation rendered the offline fallback

兩次都通過的那一項才是整件事的重點。 文章上的 stale-while-revalidate 是不能改變的行為,所以如果有哪個檢查是修好之後才變綠的,那就代表這次修改拿走了什麼。

連線卡住那項檢查量到的是 2541 毫秒,對上 2500 毫秒的上限。AbortSignal.timeout 是被換掉而不是用功能偵測繞過,順帶也消掉一個相容性問題:它需要 Chrome 103、Firefox 100 或 Safari 16,而 AbortController 比這三者都更早就能用。

總結

文章 列表
策略 stale-while-revalidate 網路優先
發佈後的第一次造訪 回傳快取,在背後更新 目前的版本
舊掉的代價 沒人會注意到的用字差異 文章看不見
離線 從快取回傳 從快取回傳
連得上但不回應 從快取回傳 2500 毫秒後回傳快取

有三件事可以推廣到這個部落格之外。快取要依照 URL 是為了什麼而分,而不是依照抓它有多貴,因為列表和它所索引的內容,舊掉時壞的方式並不一樣。Service Worker 的更新落在它本來要修的那次導覽後面,所以任何在部署時清快取的做法,都會直接繼承慢一次造訪的落差。還有**fetch 的期限只要不清掉,就會活得比標頭更久**,於是一道防慢速網路的防線,變成了保證你看到舊資料。

最後一點也是為什麼一個大約四十行的修改值得跑一次程式碼審查。它找到的缺陷不在那個被仔細討論過的策略上,而在最後為了防一個沒有人量測過的情況、順手加上的三行安全網裡。

參考連結

分享這篇文章