Astro 的 Service Worker 讓新文章需要在下一次造訪時才會出現 — 將列表頁改成網路優先
Astro 的 Service Worker 把每一次導覽都先用快取回應,新文章因此需要在下一次造訪時才會出現。列表頁現在會先問網路。

本頁目錄
引言
我發佈了一篇文章,打開部落格卻在列表裡找不到它。重新整理之後就出現了。每次都這樣,所以我猜是網站給了我首頁的舊快取。
離線閱讀是兩天前在 0.32.0 上線的,從時間點來看,嫌疑最大的就是跟著它一起加進來的 Service Worker。查下去果然如此:worker 對每一次導覽都套用同一種快取策略,連專門列出文章的列表頁也不例外。解決方法是改成列表頁先問網路。
本文整理了為什麼同一種快取策略沒辦法同時滿足文章快取和最新列表,以及 fetch 期限怎麼差點讓網速慢的讀者永遠只看得到舊列表。
worker 在每一次導覽做的事
讀者在本站的每一次導覽,都會觸發 stale-while-revalidate。它會先立刻回傳之前存取的快取內容,讓頁面能夠馬上顯示,這樣一來就算離線也能讀到之前看過的文章;如果有網路的話,它同時會在背後抓取最新的版本以便在下一次造訪時使用:
const cached = await cache.match(key);if (cached) { event.waitUntil(revalidate(request, key, cache)); return cached;}本站所有路徑,不管是文章列表還是單篇文章,都會觸發 stale-while-revalidate,沒有例外。旁邊的註解也寫得很清楚:「讀者因此可能會在一次導覽中看到落後一個建置版本的頁面,但對部落格來說,只要頁面能夠立刻顯示的話,這個代價還是值得的。」
這個取捨對文章來說很划算。文章發佈之後內容幾乎不會再變動,就算回傳的是稍舊的版本,讀者頂多錯過一個根本不會注意到的用字修改,但頁面能夠立刻顯示。
列表頁的情況就不一樣了。列表如果是舊的,讀者不但看不見新文章,也不會發現。但 worker 沒辦法分辨這兩種頁面,所以兩者都會套用同一種快取策略。
為什麼重新整理看起來能解決這個問題?
快取並不是一直停在舊版本。每次造訪時它都會更新,只是這次的更新要到下一次造訪才會派上用場:
| 頁面顯示的內容 | worker 在背後做的事 | |
|---|---|---|
| 發佈一篇新文章 | ||
| 第 1 次造訪 | 舊的列表,沒有新文章 | 抓了新的列表,覆蓋快取 |
| 第 2 次造訪 | 新的列表,文章在裡面 | 再抓一次,再覆蓋一次 |
所以網站顯示的永遠是上一次造訪時的狀態。重新整理之所以看起來能解決問題,是因為重新整理本身就是第 2 次造訪。
一般的重新整理並不會繞過 Service Worker,只有強制重新整理(Shift+重新整理)才會。所以如果強制重新整理第一次就看得到新文章,就能確定送出舊列表的是 worker,而不是伺服器。
而且這個問題不容易察覺:能注意到一篇文章不見的,只有本來就知道它存在的人,在個人部落格上也就只有作者自己。
另外兩個方案:部署時清快取,或在背後請求最新版本
這兩個方案都沒辦法讓讀者在發佈後第一次造訪時就看到新文章。
部署時把快取清掉
第一個方案是所有頁面都繼續用 stale-while-revalidate,只在每次部署產生新的 worker 時,把快取裡的列表清掉。worker 本來就有一個 VERSION,是用它自己的原始碼和所有預先快取檔案的內容算出來的。所以部署後只要在 activate 時清掉列表,下一次導覽就會因為快取沒命中而改走網路,抓取最新的列表。
但這個方案行不通,因為瀏覽器要等目前的 worker 處理完導覽,才會去檢查 /sw.js 有沒有更新。依照 Service Worker 規格,這項檢查會在 fetch 事件跑完之後才開始。所以部署後的第一次造訪,還是由舊的 worker 回應,新的 worker 在 activate 時做什麼都來不及。結果就算部署時清了快取,新文章終究還是要等下一次造訪才會出現。
不管用哪一種方法,都逃不出這個限制:只要那次造訪沒有先從網路取得最新版本,讀者就不可能看到最新的內容。所以想真正解決這個問題,就只能在展示頁面之前先從網路取得最新版本。
先回傳快取,同時在背後請求最新版本
第二個方案是我自己提的:先立刻回傳快取,同時在背後向網路請求最新版本,等結果回來再更新頁面。這確實是常見的做法,但放在列表頁上會有下面幾個問題:
- 背後那次請求,跟先等網路回應再顯示頁面所花的時間基本上是一樣的。所以在網路正常時,讀者看到最新列表的時間並不會比較早。
- 如果網路連得上卻遲遲沒有回應,那背後那次請求也會一直卡住。讀者雖然馬上看到了頁面,但更新永遠出不來。除了頁面顯示得比較快以外,這基本上沒有解決任何問題。
- 在文章列表更新前可能會有幾秒的時間差。如果讀者正要點下某篇文章時列表剛好重新繪製,原本想點的是文章 A,最後打開的可能卻是文章 B。
這個做法比較適合會長時間開著、內容又不是一整排連結的頁面,例如儀表板或動態消息。列表頁這兩個條件都不符合。
列表頁先從網路取得最新版本
所以解決方法是依照 URL 的種類來決定快取策略:
function isListing(pathname) { const segments = pathname.split("/").filter(Boolean); if (LOCALES.includes(segments[0])) segments.shift(); return segments.length === 0 || segments[0] === "posts" || segments[0] === "tags";}會因為發佈新文章而有變動的路徑基本有三類:列出最新文章的首頁、/posts 底下的文章彙整頁和它的分頁,以及 /tags 底下的所有頁面。每次發佈新文章,這三類頁面都會跟著更新。其他頁面都是文章本身,會繼續使用 stale-while-revalidate,這樣也保住了當初做這個 worker 的目的:離線閱讀。
因為是依照路徑的結構來判斷,而不是比對一份路徑清單,所以之後就算多加一個語言,這裡也不需要修改。LOCALES 已經列出所有可能出現在路徑開頭的語言前綴,而預設語言(英文)沒有前綴,所以它的首頁就是 /。
搜尋頁則是刻意排除的。因為它的 HTML 裡並沒有搜尋結果,結果是放在 Pagefind 的 bundle 裡,而 fetch 處理函式本來就不會快取這個 bundle,每次都會直接向網路請求。
等待時間的上限,以及 AbortSignal.timeout 為什麼會中斷下載
不過改成先從網路取得之後,有一種情況反而變糟了。正常情況下離線時因為沒有網路會立刻失敗,這時快取就會馬上回傳之前存取的網頁內容。但網路連得上卻遲遲沒有回應時,請求並不會失敗,只會一直懸著。在隧道裡、使用旅館的網路,或是只剩一格訊號的時候,請求都會卡到瀏覽器自己逾時為止。
所以我要求替等待時間設一個上限:如果讀者的快取裡已經有這一頁的舊版本,等太久就直接顯示舊版本;如果是第一次開啟這個頁面、因為快取裡什麼都沒有,所以就不設上限,讓瀏覽器等到底。因為就算中途放棄,也沒有任何內容可以顯示。第一版的實作用的是 AbortSignal.timeout。
計時器原本是用來處理伺服器一直不回應的情況,但 AbortSignal.timeout 在伺服器開始回應之後還會繼續倒數。fetch 在 header 一抵達就會回傳結果,可是頁面內容其實還在下載;依照 Fetch 規格,這時只要中斷訊號一觸發,正在下載的內容還是會跟著中斷。所以在慢速連線上,計時器會在下載到一半時,切斷一個本來可以正常載入的頁面。
AbortSignal.timeout 時,內容傳到一半就被中斷,cache.put 失敗,回傳舊的快取,已經收到的回應則會被丟掉。正確做法是 header 一抵達就取消計時器,這樣計時器只會限制等待伺服器回應的時間,而不會中斷下載。這時 cache.put 會以 AbortError 失敗,catch 就會回傳舊的快取,而已經收到的新回應則直接被丟掉。只要連線一直很慢,每次造訪都會發生同樣的事。
這甚至比修正之前還糟。原本在背後更新快取的做法完全沒有期限,所以就算連線很慢,讀者也只會看到一次舊版本,下一次導覽時就是新的了。
解決方法是改用一個可以取消的計時器,header 一抵達就把它取消:
const controller = new AbortController();const deadline = fallback ? setTimeout(() => controller.abort(), NETWORK_TIMEOUT) : null;
try { const response = await fetch(request, { signal: controller.signal }); // header 已經抵達,代表等待伺服器回應的時間已經結束。 // 接下來只要等回應內容下載就好,要花多久都可以。 if (deadline !== null) clearTimeout(deadline);程式碼裡的 fallback 就是 cache.match 的結果,所以計時器只會在快取裡有舊版本時才啟動。否則一位第一次造訪本站、用慢速連線打開一篇 120KB 文章的讀者,有可能會在頁面快載完時收到離線頁面,完全看不到文章。
為什麼第一版測試在壞掉的 worker 上通過?
驗證的做法是用 headless Chromium 載入實際建置好的網站和 worker,並在兩次造訪之間直接修改建置出來的 HTML,假裝發佈了一篇新文章。結果這套測試在有問題的 AbortSignal.timeout 版本上,7 項竟然全部通過。
問題出在測試用的伺服器從頭到尾都不回應。它一直不送 header,所以「header 已經抵達、內容還在下載時期限就到了」這種情況根本不會發生,而兩種實作的差別偏偏只出現在這種情況。換句話說,測試只測到伺服器完全卡住的情況,缺陷卻出在伺服器有回應、只是很慢的時候。
改良後的測試,會讓伺服器先立刻送出 header,再把 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() : "";});使用 AbortSignal.timeout 的版本:
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 visitPASS /tags/ shows new content on the FIRST visitPASS article keeps stale-while-revalidatePASS a 308 on a cached listing still redirects with the timeout attachedPASS an unslashed article URL is answered from cache, not redirectedPASS a hanging network falls back to cache within the timeoutPASS a slow body still lands in the cachePASS home is readable offline after PRIME, without ever visiting it如果一個測試根本不可能失敗,那它同時也什麼都證明不了。所以我也用新的測試針對修改前的 worker 跑了一遍:
FAIL listing shows a new article on the FIRST visit home served a stale copy — the reported bugFAIL /tags/ shows new content on the FIRST visit tag index served stalePASS article keeps stale-while-revalidate first visit stale: true, second visit fresh: trueFAIL home is readable offline after PRIME, without ever visiting it offline navigation rendered the offline fallback我們需要確定 article keeps stale-while-revalidate 這一項在新舊版本的兩次測試中都通過。因為文章頁使用 stale-while-revalidate 並不在這次修改的範圍內,如果這一項不通過,就代表這次的修改無意間改變了文章頁原本的行為。
在網路卡住的測試裡,worker 在 2541 毫秒時改用快取回應,和 2500 毫秒的上限相比只差了 41 毫秒。另外,改用 AbortController 也順便解決了一個相容性問題:AbortSignal.timeout 是在 Chrome 103、Firefox 100、Safari 16 版本後才加入的,而且 Chrome 要到 124 版才會按照規格丟出 TimeoutError。相較之下,AbortController 推出得早,各家瀏覽器很早就支援了。
總結
| 文章 | 列表 | |
|---|---|---|
| 快取策略 | stale-while-revalidate | 先從網路取得 |
| 發佈後的第一次造訪 | 先回傳快取,再在背後更新 | 最新版本 |
| 內容過時的代價 | 沒人會注意到的用字差異 | 讀者看不到新文章 |
| 離線時 | 從快取回傳 | 從快取回傳 |
| 網路連得上卻沒有回應 | 從快取回傳 | 等 2500 毫秒後回傳快取 |
- 快取策略需要依照 URL 的種類來決定。列表頁和文章頁在內容過時時,應該採取的策略完全不同。
- Service Worker 要等導覽處理完,才會檢查更新,所以就算每次部署時都讓新的 worker 刪掉列表頁的快取,第一次造訪還是由舊的 worker 用快取回應,新文章要等下一次造訪才會出現。
fetch的計時器如果沒有在 header 抵達時取消,就會繼續倒數,結果原本用來應付慢速網路的機制,反而讓網速慢的讀者永遠只看到舊頁面。


