- date
- entry
- 005
- topic
- infrastructure
- rev
- —
sync --delete 為什麼會在部署當下白屏:SPA 的舊 chunk 被你自己刪了
這個壞法有時效性。部署完成後才開頁面的人一切正常,部署當下已經開著頁面的人會白屏——而瀏覽器給的錯誤訊息指向 MIME type,跟真正的原因隔了三層。
使用者回報畫面變白,console 裡是這一條:
Failed to load module script: Expected a JavaScript-or-Wasm module script
but the server responded with a MIME type of "text/html". Strict MIME type
checking is enforced for module scripts per HTML spec.
或者是它的另一個變體:
Uncaught (in promise) TypeError: Failed to fetch dynamically imported module:
https://app.example.com/assets/SomeChunk-A1b2C3.js
兩條都在講「拿到的東西型別不對」。實際發生的事是:那個檔案在幾十秒前還在,現在被刪掉了,刪的人是你的部署腳本。
時間軸
前提是兩個各自都很常見的設定:部署跑 aws s3 sync dist/ s3://bucket --delete,
以及 CloudFront 把來源的 403/404 改寫成 index.html 回 200——SPA 前端路由要能運作就得這樣設。
T+0:00 使用者打開頁面
HTML 引用 /assets/index-A.js,抓回正常 JS,頁面正常運作
T+0:30 你 push code,CI 開始部署
新 build 的 hash 是 B
sync --delete 刪掉 index-A.js,上傳 index.html 與 index-B.js
CloudFront invalidation /*
T+0:35 使用者點了一個按鈕,觸發 dynamic import
瀏覽器裡跑的還是舊那份 app,它記得的 hash 是 A
GET /assets/index-A.js
→ S3 回 NoSuchKey
→ CloudFront 錯誤規則把它換成 index.html,狀態碼 200
→ 瀏覽器收到 Content-Type: text/html
→ module script 的嚴格檢查拒收 → 白屏
錯誤訊息之所以在講 MIME type,是因為它是這條鏈的最後一環。瀏覽器只知道自己要一個 JS module 卻拿到 HTML;它看不到前面兩層的改寫。
這是同一個 SPA fallback 規則的第二種副作用。上一次它讓
/blog/ 變成首頁,這次它把「檔案不存在」偽裝成「檔案是 HTML」。
兩個 curl 就能確認
先問線上的 HTML 現在引用哪個 hash:
curl -s https://app.example.com/ | grep -oE '/assets/index-[A-Za-z0-9_-]+\.js'
再問那個檔案回什麼型別:
curl -sI "https://app.example.com/assets/index-A1b2C3.js" | head -3
# 正常:content-type: text/javascript
# 中招:content-type: text/html ← fallback 生效了,這個 key 不存在
第二條回 text/html 就確定了。要確認是不是部署造成的,看 CI 有沒有正在跑,以及部署腳本裡那行 sync:
grep 's3 sync' .github/workflows/deploy.yml
hard refresh 不算修好。它確實有效——重新抓 index.html 就拿到新的 hash——但那是要求每個當下在線上的使用者自己補救。而且如果邊緣節點還沒吃到 invalidation,refresh 也會拿到舊的 HTML,等於沒解。
修法:把 --delete 拿掉
Vite 和 CRA 的 production 檔名帶 content hash,本來就是 immutable——內容變了檔名就會變。既然如此,舊檔留著不會被覆蓋,也不會被誤用,它唯一的成本是 S3 的儲存空間。
- name: Deploy to S3
run: |
# 不加 --delete:舊 bundle 留著,給已開頁面的使用者一段緩衝
aws s3 sync dist/ s3://$BUCKET
index.html 是同名檔案,還是會被新版覆蓋,所以新訪客拿到的一定是新版。改變的只是「舊 hash 還抓得到」。
累積的舊檔交給 S3 lifecycle 處理,不要用腳本刪:
{
"Rules": [{
"ID": "expire-old-asset-bundles",
"Status": "Enabled",
"Filter": {"Prefix": "assets/"},
"Expiration": {"Days": 60}
}]
}
aws s3api put-bucket-lifecycle-configuration \
--bucket "$BUCKET" --lifecycle-configuration file://lifecycle.json
60 天是我用的值,沒有什麼道理,只是遠大於任何人會讓分頁開著的時間。重點是這件事由 S3 按時間做,而不是由部署按「這次 build 沒有的檔案」做——後者的判斷基準從一開始就是錯的。
為什麼 --delete 會出現在腳本裡
因為它看起來是對的。「把來源目錄同步到 bucket」的直覺結論就是兩邊應該一致,多出來的是垃圾。這個直覺在同步資料時成立,在同步已發佈的資產時不成立——舊版本的 bundle 不是垃圾,它是還在被引用的東西。
另一條路是改 CloudFront,讓 /assets/* 走一個不套用錯誤改寫規則的 cache behavior,這樣舊 hash 會誠實地回 404,前端至少能攔到。可行,但多一份要維護的設定,而且它只是把白屏換成一個比較好懂的錯誤。先把 --delete 拿掉,問題就不存在了。
不要做的事
- 叫使用者按 Ctrl+Shift+R——部署期間每個人都會踩到,這不是解法,是把成本轉嫁出去
- 為了清空間手動
aws s3 rm assets/——這會一次打斷所有正在使用的 session,比部署更慘 - 把 SPA fallback 整個拿掉——你的
/some/route會直接 404,換一個更大的問題
如果只記得一件事
部署不是一個瞬間,是一段區間。在那段區間裡,同時存在兩個版本的 app:伺服器上的新版,和使用者瀏覽器裡還跑著的舊版。舊版還在發請求,所以它需要的檔案得繼續存在一陣子。刪得太快,壞的不是你的部署,是別人正在做的事。
修訂紀錄
- 首次發布