Astro 的 Service Worker 讓新文章需要在下一次造訪時才會出現 — 將列表頁改成網路優先

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

更新

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

引言

我發佈了一篇文章,打開部落格卻在列表裡找不到它。重新整理之後就出現了。每次都這樣,所以我猜是網站給了我首頁的舊快取。

離線閱讀是兩天前在 0.32.0 上線的,從時間點來看,嫌疑最大的就是跟著它一起加進來的 Service Worker。查下去果然如此:worker 對每一次導覽都套用同一種快取策略,連專門列出文章的列表頁也不例外。解決方法是改成列表頁先問網路。

本文整理了為什麼同一種快取策略沒辦法同時滿足文章快取和最新列表,以及 fetch 期限怎麼差點讓網速慢的讀者永遠只看得到舊列表。

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;
}

本站所有路徑,不管是文章列表還是單篇文章,都會觸發 stale-while-revalidate,沒有例外。旁邊的註解也寫得很清楚:「讀者因此可能會在一次導覽中看到落後一個建置版本的頁面,但對部落格來說,只要頁面能夠立刻顯示的話,這個代價還是值得的。」

這個取捨對文章來說很划算。文章發佈之後內容幾乎不會再變動,就算回傳的是稍舊的版本,讀者頂多錯過一個根本不會注意到的用字修改,但頁面能夠立刻顯示。

列表頁的情況就不一樣了。列表如果是舊的,讀者不但看不見新文章,也不會發現。但 worker 沒辦法分辨這兩種頁面,所以兩者都會套用同一種快取策略。

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

為什麼重新整理看起來能解決這個問題?

快取並不是一直停在舊版本。每次造訪時它都會更新,只是這次的更新要到下一次造訪才會派上用場:

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

所以網站顯示的永遠是上一次造訪時的狀態。重新整理之所以看起來能解決問題,是因為重新整理本身就是第 2 次造訪。

一般的重新整理並不會繞過 Service Worker,只有強制重新整理(Shift+重新整理)才會。所以如果強制重新整理第一次就看得到新文章,就能確定送出舊列表的是 worker,而不是伺服器。

而且這個問題不容易察覺:能注意到一篇文章不見的,只有本來就知道它存在的人,在個人部落格上也就只有作者自己。

另外兩個方案:部署時清快取,或在背後請求最新版本

這兩個方案都沒辦法讓讀者在發佈後第一次造訪時就看到新文章。

部署時把快取清掉

第一個方案是所有頁面都繼續用 stale-while-revalidate,只在每次部署產生新的 worker 時,把快取裡的列表清掉。worker 本來就有一個 VERSION,是用它自己的原始碼和所有預先快取檔案的內容算出來的。所以部署後只要在 activate 時清掉列表,下一次導覽就會因為快取沒命中而改走網路,抓取最新的列表。

但這個方案行不通,因為瀏覽器要等目前的 worker 處理完導覽,才會去檢查 /sw.js 有沒有更新。依照 Service Worker 規格,這項檢查會在 fetch 事件跑完之後才開始。所以部署後的第一次造訪,還是由舊的 worker 回應,新的 worker 在 activate 時做什麼都來不及。結果就算部署時清了快取,新文章終究還是要等下一次造訪才會出現。

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

不管用哪一種方法,都逃不出這個限制:只要那次造訪沒有先從網路取得最新版本,讀者就不可能看到最新的內容。所以想真正解決這個問題,就只能在展示頁面之前先從網路取得最新版本。

先回傳快取,同時在背後請求最新版本

第二個方案是我自己提的:先立刻回傳快取,同時在背後向網路請求最新版本,等結果回來再更新頁面。這確實是常見的做法,但放在列表頁上會有下面幾個問題:

  1. 背後那次請求,跟先等網路回應再顯示頁面所花的時間基本上是一樣的。所以在網路正常時,讀者看到最新列表的時間並不會比較早。
  2. 如果網路連得上卻遲遲沒有回應,那背後那次請求也會一直卡住。讀者雖然馬上看到了頁面,但更新永遠出不來。除了頁面顯示得比較快以外,這基本上沒有解決任何問題。
  3. 在文章列表更新前可能會有幾秒的時間差。如果讀者正要點下某篇文章時列表剛好重新繪製,原本想點的是文章 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] === "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 規格,這時只要中斷訊號一觸發,正在下載的內容還是會跟著中斷。所以在慢速連線上,計時器會在下載到一半時,切斷一個本來可以正常載入的頁面。

fetch 的期限如果不清掉,header 到了之後仍會繼續倒數時間軸從送出請求到第四秒。使用 AbortSignal.timeout 時,期限在 header 抵達後仍然繼續走,並在 2500 毫秒切斷內容,於是寫入快取失敗、回傳舊的快取。改成在 header 抵達時清掉的 AbortController,內容就能完整傳完,快取也會更新。header 抵達 2.0 秒期限觸發 2500 毫秒內容傳完 4.0 秒AbortSignal.timeout中斷內容傳到一半被中斷。cache.put 失敗,回傳舊的快取,已經收到的回應被丟掉。AbortController,header 抵達就清掉計時器清掉header 一到就清掉計時器,內容要傳多久都可以。快取更新,回傳的是新的版本。
使用 AbortSignal.timeout 時,內容傳到一半就被中斷,cache.put 失敗,回傳舊的快取,已經收到的回應則會被丟掉。正確做法是 header 一抵達就取消計時器,這樣計時器只會限制等待伺服器回應的時間,而不會中斷下載。

這時 cache.put 會以 AbortError 失敗,catch 就會回傳舊的快取,而已經收到的新回應則直接被丟掉。只要連線一直很慢,每次造訪都會發生同樣的事。

這甚至比修正之前還糟。原本在背後更新快取的做法完全沒有期限,所以就算連線很慢,讀者也只會看到一次舊版本,下一次導覽時就是新的了。

解決方法是改用一個可以取消的計時器,header 一抵達就把它取消:

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 });
// header 已經抵達,代表等待伺服器回應的時間已經結束。
// 接下來只要等回應內容下載就好,要花多久都可以。
if (deadline !== null) clearTimeout(deadline);

程式碼裡的 fallback 就是 cache.match 的結果,所以計時器只會在快取裡有舊版本時才啟動。否則一位第一次造訪本站、用慢速連線打開一篇 120KB 文章的讀者,有可能會在頁面快載完時收到離線頁面,完全看不到文章。

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

驗證的做法是用 headless Chromium 載入實際建置好的網站和 worker,並在兩次造訪之間直接修改建置出來的 HTML,假裝發佈了一篇新文章。結果這套測試在有問題的 AbortSignal.timeout 版本上,7 項竟然全部通過。

測試怎麼進行,以及為什麼從不回應的伺服器分不出兩種實作上半部是四個步驟:建置網站和 worker、第一次造訪把頁面存進快取、修改建置好的 HTML 假裝發佈新文章、第二次造訪檢查快取裡的內容。下半部是兩個並排的面板,時間軸都是 0 秒到 4 秒,期限在 2500 毫秒。左邊是從不回應的伺服器:期限到的時候兩種實作都還在等 header,在同一時間中斷並改用快取,結果相同。右邊是立刻送出 header、內容傳到第 4 秒的伺服器:header 抵達之後,AbortSignal.timeout 在期限時切斷內容,可以取消的計時器則讓內容傳到第 4 秒,結果不同。測試怎麼進行1建置網站和 worker2第一次造訪把頁面存進快取3修改建置好的 HTML假裝發佈新文章4第二次造訪檢查快取裡的內容兩種實作的差別從不回應的伺服器header 永遠不會抵達0 秒4 秒期限 2500 毫秒回應很慢的伺服器header 立刻抵達,內容傳到第 4 秒0 秒4 秒期限 2500 毫秒兩者都還在等 headerAbortSignal.timeout改用快取回應可以取消的計時器改用快取回應結果相同header 抵達之後AbortSignal.timeout內容被切斷可以取消的計時器結果不同兩種實作只有在 header 抵達之後才有差別,而從不回應的伺服器永遠走不到那一步
面對從不回應的伺服器,兩種實作都會通過。只有先送出 header、再慢慢傳內容的伺服器,才測得到計時器在 header 抵達後還繼續倒數的情況。

問題出在測試用的伺服器從頭到尾都不回應。它一直不送 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 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

如果一個測試根本不可能失敗,那它同時也什麼都證明不了。所以我也用新的測試針對修改前的 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

我們需要確定 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 抵達時取消,就會繼續倒數,結果原本用來應付慢速網路的機制,反而讓網速慢的讀者永遠只看到舊頁面。

參考連結

分享這篇文章