模組六|測試、效能與上線(Day 26–30)
昨天講完效能預算訂了沒量。今天講部署,而且要先撤回一個標題。
這一天原本要叫「GitHub Pages 的 404 地獄」,病灶預設是 base path 寫錯。我在 2026-08-07 把所有東西重跑了一次,發現前提就不對:這個專案的主要部署是 Vercel,而 GitHub Pages 站根本不存在。
| 目標 | 狀態 | 指令 |
|---|---|---|
| Vercel | HTTP 200,遊戲本體 | curl -sSI https://save-the-dog-web.vercel.app/ |
| GitHub Pages | HTTP 404 | curl -sS -o /dev/null -w '%{http_code}' -L https://harryfan.github.io/save-the-dog-web/ |
| Pages 站台設定 | 不存在 | gh api repos/HarryFan/save-the-dog-web/pages → {"message":"Not Found","status":"404"} |
結論先講:build、lint、test 全過,不代表站活著。 這一篇要拆的是三個 404,三個成因完全不同——一個是設定寫錯、一個是工具不碰的那一行、一個回 404 給你的甚至不是 GitHub。
GitHub Pages 的專案網址帶著 repo 名稱(https://<user>.github.io/<repo>/),Vercel 給的是網域根目錄。同一份程式碼,兩個不同的路徑前綴。
如果 build 出來的 index.html 用錯了 base,<script src> 會 404,然後整頁空白。這個症狀的討厭之處被寫在 vite.config.js 的檔頭註解裡(英文原文,實查):
// GitHub Pages serves this repo under /save-the-dog-web/, Vercel serves it at
// the domain root. Building with the wrong base ships an index.html whose asset
// URLs all 404, which renders as a blank page with no console error worth
// reading. Vercel sets VERCEL=1 during the build, so key off that.
const base = process.env.VERCEL ? '/' : '/save-the-dog-web/'
export default defineConfig({
base,
build: { target: 'es2022', outDir: 'dist', sourcemap: true, assetsInlineLimit: 0 }
})
「白畫面,而且 console 裡沒有一行值得讀的錯誤。」 這句話是我當時自己寫下來的,它比任何「404 地獄」的形容都準確——你不會看到紅字,你只會看到白。
解法是一行環境變數判斷:Vercel 在 build 期間會設 VERCEL=1,用它切 base。
執行時期的素材路徑則全部收在一個函式裡(src/config/paths.js,全檔六行):
export function assetUrl(relativePath, baseUrl = import.meta.env.BASE_URL) {
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`
const normalizedPath = String(relativePath).replace(/^\/+/, '')
return `${normalizedBase}${normalizedPath}`.replace(/([^:])\/{2,}/g, '$1/')
}
有一個細節值得抄走:baseUrl 做成預設參數而不是在函式體裡直接讀 import.meta.env.BASE_URL。因為 Day 26 講過,這個專案的單元測試跑在 environment: 'node',那裡沒有 import.meta.env。做成預設參數,測試就能直接把 base 傳進去驗(tests/unit/paths.test.js)。
「拿不到環境就用注入的」是一個一行成本的決定,但它決定了這個函式能不能被測。
而 ESLint 那邊還有一條規則在守它:no-restricted-syntax 直接用 AST 選擇器擋掉 src/**/*.js 裡任何以 /assets/ 開頭的字串字面值。想繞過 assetUrl() 手寫路徑,lint 會紅。

第一種 404 已經解掉了。第二種是我這次查證當場才發現的,它一直活在線上,沒有人回報過。
index.html 第 5 行,favicon 的路徑當初被寫死成 /save-the-dog-web/favicon.svg。實測 curl 線上版:/save-the-dog-web/favicon.svg 回 404,/favicon.svg 回 200。檔案就在那裡,只是路徑多了一層。
我把那一行改回舊寫法重現了一次,兩種 build 各跑一遍,比對產出的 dist/index.html 第 5 行:
| 原始碼寫法 | npm run build(Pages base) |
VERCEL=1 npm run build |
|---|---|---|
舊:href="/save-the-dog-web/favicon.svg" |
/save-the-dog-web/favicon.svg |
/save-the-dog-web/favicon.svg ❌ |
新:href="/favicon.svg"(3df9840 修掉) |
/save-the-dog-web/favicon.svg ✅ |
/favicon.svg ✅ |
差別在這裡:Vite 會把 index.html 裡的根相對路徑替換成 base + 路徑;但當你已經把 base 手寫進去,那個字串就成了常數,兩種 build 拿到的是同一份,其中一份必然是錯的。舊寫法那一列兩格完全一樣,就是「這個字串沒有被處理過」的證據。
你以為你在幫工具寫對答案,其實你把它的可變參數變成了常數。
這件事還有第二層:grep -rn "/save-the-dog-web" src/ index.html 的唯一一筆命中就是這一行。src/ 底下乾乾淨淨——因為那裡有 lint 在擋。漏的是 index.html,一個沒有任何檢查覆蓋的地方。
為什麼它能溜到線上?playwright.config.js:12-18 把 webServer.url 與 baseURL 都寫死成 http://127.0.0.1:4173/save-the-dog-web/。E2E 從頭到尾只驗過 Pages 那一種 base,Vercel 的 / base 一次都沒被驗過。 而 E2E 本身還不在 CI 裡(Day 26 講過)。兩道缺口疊在一起,這個 bug 就上線了。

打開 https://harryfan.github.io/save-the-dog-web/,你會拿到一個 404 頁。很自然的推論是「Pages 沒部署成功」。
把 body 抓下來看,它長這樣:
<meta name="generator" content="Astro v5.7.13">
<title>找不到頁面 | Harry / Tech</title>
<link rel="canonical" href="https://harryfan.github.io/404/">
這是我自己的部落格。 harryfan.github.io 是 user site,已經被一個 Astro 部落格佔用;/save-the-dog-web/ 這個路徑在部落格裡不存在,所以落到它的 catch-all 404 頁,由它回應。
這不是 GitHub Pages 的原生 404,這是另一個站接走了請求。
差別很重要:如果我照著「Pages 原生 404」去除錯,我會去翻 artifact 上傳、翻 base、翻檔案有沒有進 dist——全部都是死路,因為請求根本沒有走到那個站。
看到 404,第一件事是分辨它是誰回的。 看 server header、看 body 有沒有 generator meta、看 canonical 指向哪裡。這三樣加起來三十秒,可以省掉半天。
現在看部署鏈本身。.github/workflows/deploy.yml 的 verify job 是這樣的:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages-${{ github.ref }}
cancel-in-progress: true
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- run: npm ci
- run: npm run assets:validate
- run: npm run lint
- run: npm run test:unit -- --run
- run: npm run build
- uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5
素材驗證放第一關的理由是它最快,而且它擋下的是最容易發生的錯(Day 8 講過)。
實際跑起來(gh api .../actions/runs/31155199448/jobs,2026-08-07 06:47 UTC):
| 步驟 | 結果 |
|---|---|
1–4 Set up job/Checkout/Setup Node/npm ci |
success |
| 5 Validate SVG assets | success |
| 6 Lint | success |
| 7 Unit tests | success |
| 8 Build | success |
| 9 Configure Pages | failure |
| 10 Upload Pages artifact | skipped |
| deploy job | skipped |
錯誤訊息是 Get Pages site failed ... HttpError: Not Found,來自 actions/configure-pages 的 enablement: false——這個 action 預設不會替你開 Pages,它只會去問「Pages 站在不在」,不在就倒。
這條線可以整條攤開。deploy.yml 至今 18 次執行:15 次 failure、2 次 success、1 次 cancelled【實測:gh run list --workflow=deploy.yml --limit 25 --json conclusion】。我逐一抽查失敗的 run,卡點全部是同一個步驟:Configure Pages——前八個步驟(checkout、setup-node、npm ci、四個 verify 命令、build)每次都是 success,第九步倒下,deploy job 直接 skipped。
唯一那段綠燈是 31092450008 與 31092698702(08-06 10:14、10:18),後者連 deploy job 都綠了,log 印出 Evaluated environment url: https://harryfan.github.io/save-the-dog-web/。十五分鐘後的下一次推送(31093702959,10:33)就回到失敗,之後再也沒成功過——包括 08-07 又補了兩次(31154916106、31155199448),一樣卡在第九步。
為什麼 10:18 成功、10:33 就查不到 Pages site,我到現在沒有答案。 這需要人工進 repo Settings → Pages 看,我還沒做。我不編一個聽起來合理的解釋。
但有一件事是確定的,而且它是這一整篇的重點:那四個把關的命令,五次失敗的 run 裡全部都是綠的。 程式碼沒問題、素材沒問題、測試沒問題、build 沒問題——問題在程式碼管不到的地方:一個平台上的開關。
CI 綠不綠,回答的是「這份程式碼合不合格」;站活不活,回答的是另一個問題。這兩件事之間沒有任何一條線把它們綁在一起,除非你自己去打那個網址。
順帶一個值得抄的細節:這五個 action 全部釘在 commit SHA 上,旁邊用註解標語意版本(# v4、# v5)。tag 是可以被移動的,SHA 不行。這是供應鏈安全最便宜的一道防線。
前兩種 404 有一個共同點:它們都不會出現在 npm run dev 裡。開發伺服器的 base 跟正式站不一樣,public/ 的檔案也是即時服務的。
所以 AGENTS.md:31 有一句很短的規則:用 npm run preview 驗證正式 build 的產物,不要用 file:// 開 dist/index.html。 file:// 沒有網域根目錄的概念,所有以 / 開頭的路徑都會指到你的檔案系統根目錄,於是你會看到一個跟正式站完全不同的失敗方式——或者更糟,一個假的成功。
這條規則不是掛在牆上的:playwright.config.js:12-16 的 webServer.command 就是 npm run preview。E2E 打的是真的靜態伺服器、真的 base、真的 dist/。
只是如同前面說的,它只打其中一種 base。
vercel.json實測 ls vercel.json .vercel → 兩者皆不存在。
Vercel 那一側的部署,repo 內零設定檔:平台自動偵測到 Vite,build 期間設 VERCEL=1,vite.config.js 那一行判斷把 base 切成 /。
跨平台的差異用一行環境變數判斷解決,比維護兩份設定檔好。 兩份設定檔會漂移,一行判斷不會。
public/ 目錄裡的檔案是原樣複製的,Vite 不會給它們內容雜湊。也就是說改了素材之後,回訪的玩家可能拿到瀏覽器快取裡的舊檔。
原本規劃的權宜解是在素材 Manifest 加一個建置版本查詢字串。實查 src/config/assets.js:沒有做。 零命中。
這一條我就寫成「知道要做,MVP 沒做」,不寫成「評估後認為不需要」——那會是事後補上去的理由。
部署這一段我交出去的是查證,不是決策。
vite.config.js 那一行 base 切換、paths.js 那六行、deploy.yml 的關卡順序,都是 AI 寫的;驗收條件是四個命令跑得過,以及兩種 build 的產物路徑各自正確——後者我這次是自己重跑了兩次 build 逐行比對的,因為它是唯一無法從測試看出來的部分(測試只驗一種 base)。
而這次查證本身也是委派出去的:curl 兩個網址、gh api 問 Pages 設定、gh run list 翻歷次執行、逐步取 job 的 conclusion。這種工作有一個很好的性質——每一步都留下一份可以貼出來的原始輸出,所以我不需要相信任何轉述,我只需要看那些輸出。上面每一張表的數字,都可以用同一條指令重跑。
它一開始給我的答案是「GitHub Pages 已啟用,與 Vercel 並存」。這句話是錯的,被 gh api .../pages 回的那個 404 推翻。我要求的是指令輸出而不是結論,所以錯誤在第一輪就被自己的證據駁倒了。
部署環境跟開發環境的差異,幾乎全部集中在「路徑」跟「快取」上;而 CI 全綠只證明程式碼合格,不證明站活著。
三件今天就能做的事:
server header、body 裡的 generator meta、canonical 指向哪裡。三十秒的事。明天 Day 29,把 AI 那條線收起來。我會攤開一張逐條對映表:AGENTS.md 這份合約總共 27 條規則,其中 21 條有 lint 或驗證器在擋(78%),分層是 SVG 17/18、Project 2/2、工程規則 2/7。重點不在那個比率,在剩下那 6 條——它們長得一模一樣。順便回答一個今天留下的問題:為什麼 src/ 裡沒有一行寫死的路徑,而 index.html 裡有。
本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit
5aa3705。專案仍在開發中,量體數字會變動。文中的 HTTP 狀態與 CI 執行結果皆為 2026-08-07 重跑;部署狀態可能隨時改變,請以你自己重跑的輸出為準。
可玩網址:https://save-the-dog-web.vercel.app/|原始碼:https://github.com/HarryFan/save-the-dog-web
如果你卡在語法
深入原理