為什麼不一次改完:一次 blog 大改版的三個工程決策
上一篇寫完設計診斷,這篇換工程的角度。
這次 blog 改版要扛的變更量大概長這樣:
- Nuxt 3.9 → 3.21 升級
- Vuetify 3 → UnoCSS(幾乎重寫前端)
- Nuxt Content v2 → v3
- Nuxt 3 → Nuxt 4
- 加 Dark mode、Pagefind 全文搜尋、RSS、Giscus 評論
- npm → pnpm(連 lockfile 跟 CI 都換)
工程本能是 ── branch 開下去直接打,壞了再修。
但這次刻意慢下來,把每一個決策拿出來想清楚。原因只有一個:部署的物理限制決定了我必須一次合併、一次部署,沒有「rolling 上線」這條路。 所以每個決策都在回答同一個問題 ── 什麼時候 merge 才不會有半成品上線?
這篇記三個拿來回答這個問題的決策。
決策一:切分界線 ── Phase A / Phase B 的分水嶺
最直覺、也最危險的想法是:反正都要升,一次升乾淨。
把上面那串攤開,誘惑就出來了 ── Vuetify 反正要拔、Nuxt 反正要升、Content v2 反正要換 v3 ── 不如一次重寫到底,少走一遍流程。
最後沒這樣做。拆成兩個 phase:
| Phase | 範圍 | 驗證面 |
|---|---|---|
| A | Nuxt 3.21、Vuetify → UnoCSS、Magazine 改版、四個新功能 | UI 全面重寫 |
| B | Nuxt 4 + Content v3 + zod schema | 資料層 API 改寫 |
分水嶺的判準:每個 phase 結束都必須能獨立 deploy 一版穩定產物。
理由是「驗證面」(verification surface) 的問題。
Vuetify 移除本身就是「幾乎重寫前端」── 22 個檔案要全部換掉(下一節會列),每一頁都要重看。這時候如果同時把 queryContent 改成 queryCollection、frontmatter 改走 zod schema,驗證面會疊起來:
- 文章列表壞掉,是 UnoCSS 的事還是 Content v3 的事?
- 某篇文章 prerender 失敗,是 layout 改寫的問題還是 zod schema 沒對上?
- Tag 頁 404,是 page component 重寫漏了還是 collection query 寫錯了?
每個 bug 都要先花時間「歸類」── 是 UI 層的還是資料層的。疊在一起做,debug 成本不是 1+1,是 1×1。
拆成兩個 phase,每個 phase 出問題時,問題的可能來源就被收斂在一個維度。Phase A 期間發生的事,一定跟 UI 層有關;Phase B 期間發生的事,一定跟資料層有關。
第一個概念:verification surface。重寫的時候,驗證面越窄、debug 成本越低。如果兩件大事的驗證面會疊到一起、不能拆,至少安排不同 phase。
這個原則的反面是 ── 如果兩件事的驗證面不會疊(例如「換 lockfile」跟「重寫前端」),那就該夾帶,下一節講。
決策二:Grep-first 不是 Vibe-first
決定 Phase A 要拔 Vuetify 之後,第一個會踩的坑是:到底要改幾個檔案?
「印象中應該不多吧 ── component 大概 5、6 個、layout 3 個、配置 2 個 ── 兩三天搞完?」
這就是憑印象估會死的地方。 Vuetify 不是只在你看得到的 component 裡。它在 layout、page、根 app.vue、error page、Prose 元件、v-typography 的 utility class、甚至設定層的 vite plugin。憑印象去拔,會在 build 過了三次以後突然發現「啊還有這個」、「啊那個 page 沒檢查」。
所以這次第一步是 grep,不是改 code。
一組 pattern 找出所有依賴點
用這組 regex 把全專案 sweep 一次:
grep -rEn "V[A-Z][a-zA-Z]+|\bv-[a-z]+\s*=|text-h[0-9]|text-caption|text-subtitle|text-body|vuetify" \
--include="*.vue" --include="*.ts" --include="*.js" . \
--exclude-dir=node_modules --exclude-dir=.nuxt --exclude-dir=dist
這組 pattern 抓的是:
V[A-Z]\w+── 所有 Vuetify component(VBtn、VCard、VChip…)v-[a-z]+\s*=── kebab-case 的 component prop 用法text-h[0-9]/text-caption/text-subtitle/text-body── Vuetify 的 typography utility- 字面
vuetify── plugin、config、import
跑完得到一份精確清單,22 個檔案:
| 區段 | 檔案數 | 內容 |
|---|---|---|
| 設定層 | 3 | nuxt.config.ts、plugins/vuetify.ts(刪檔)、app.vue |
| 根層 | 1 | error.vue |
| Layouts | 3 | default.vue、postPageLayout.vue、singlePageLayout.vue |
| Pages | 5 | index.vue、about.vue、donate.vue、post/[...slug].vue、tag/index.vue |
| Components | 8 | Header、Footer、PostCard、PostHeader、TagMenu、PageTocMenu、BlogArchiveMenu、SocialMedia |
| Content Prose | 2 | ProsePre.vue、ProseImg.vue |
剩下的檔案 ── errorLayout、Wrapper、其他 Prose 元件、pages/page/*、pages/tag/[tag]/* ── grep 證明它們沒有 Vuetify 依賴,這 phase 完全不用動。
這份清單拿來幹嘛?拿來規劃 commit 切分。 22 個檔案要分幾個 commit、什麼順序動,能在開工前就排好 ── 不是邊改邊發現「啊還有這個」。
進場用的 grep 就是驗收用的 grep
更關鍵的一招:最後驗收跑同一組 grep pattern,結果應為空。
這就是 entry audit = exit audit。
進場那組 pattern 列出「所有要拔的依賴點」。完工的時候用同一組 pattern 跑一次 ── 如果還有命中,就是有檔案漏改了;如果結果是空的,就證明整個 codebase 已經乾淨。
不需要靠人眼盤點、不需要 checklist 打勾、不需要記得「我有沒有改到那個 page」。Grep 是 ground truth,而且進場用的工具就是出場用的工具,沒有第二套標準。
順帶夾帶:pnpm 換 npm 為什麼放這 phase
上一節留了一個尾巴:「驗證面不會疊的事該夾帶」。pnpm 換 npm 就是這種事。
換套件管理器要做的:刪 package-lock.json、生 pnpm-lock.yaml、加 .npmrc(shamefully-hoist=true 給 Nuxt 的 auto-import 用1)、CI workflow 加 pnpm/action-setup、setup-node 加 cache: 'pnpm'。
這件事跟 UI 重寫的驗證面完全不重疊 ── pnpm 壞掉是 install 階段就壞,UnoCSS 壞掉是 dev/build 階段才壞,兩個錯誤訊號不會混在一起。
所以選擇是:夾帶在 Phase A 開頭做,不開獨立 PR。 反正:
- CI workflow 本來就要為了 Phase A 改一次(要加 pagefind build step)── 既然要碰 CI,順便把 setup 換掉
- Lockfile 本來就會因為 Vuetify 移除、UnoCSS 加入而大規模變動 ── 不如一次重生
- Node 版本本來就要從寫死 18 改成
.nvmrc固定 ── 一起做
反面教材是:不要把這種「全域基礎設施換掉」的事塞到平常的 feature PR 裡。 套件管理器、lockfile、Node 版本一動,整份 dependency tree 都會跟著變,PR diff 會 noisy 到沒人想 review。要動就找一個本來就要大改 CI 的時機點,一次動乾淨。
第二個概念:entry audit = exit audit。盤點靠工具不靠印象,進場用的工具就是出場驗收的工具。順帶概念:batching 全域變更到反正要動的 phase。
決策三:部署作為一次性事件 ── merge-as-deploy
Phase A 排好了、22 檔列好了,下一個問題:branch 怎麼開?什麼時候 push master?
這次 blog 是 SSG 部署到 GitHub Pages。.github/workflows/main.yml 的觸發是這樣寫的:
on:
push:
branches: [master]
這個設定對這次改版產生了一個物理限制 ── 任何 push 到 master 的 commit,都會觸發一次部署,把當下的 master 內容生成靜態檔、推上去。
換句話說,沒有「rolling 上線」這條路。沒辦法「先把 UnoCSS 設定推上去、再慢慢改 component」。沒辦法「先把 dark mode 推上去看看效果」。每一次 push master 都是一次完整的 deploy,全站讀者都會看到。
所以這次的 branch 策略只有一條規則:A0 到 A9 全部在 feat/magazine-redesign 做完、本地驗收完,A10 才合併回 master。
A10 是這次改版唯一一次 push master
整個 Phase A 只觸發一次部署。
這代表幾件事:
- Feature branch 期間做什麼都安全。 force-push、rebase、改歷史、整段 commit 重排 ── 都不會影響線上站
- 驗收要在 feature branch 跑完。 不能依賴「先推上去看 production 怎樣」的 workflow,因為一推就是上線。本地
pnpm generate && pnpm dlx serve dist必須完整跑過所有頁面 - A0–A9 的 commit 訊息可以隨意。 WIP、fix-fix-fix、try this 都行 ── 反正 A10 合併回去那一刻,整段歷史是 PR 上的事
- 如果 A10 合完發現有問題,唯一補救路是再 push 一次。 這是不能逃的成本,所以驗收要徹底
為什麼不用 staging 環境
GitHub Pages 沒有原生的 staging 概念。要做 staging 得開另一個 repo(或 branch)+ 另一條 workflow + 另一個 domain,整套基礎設施成本不低。
對個人 blog 而言,「本地 serve dist」就是 staging。Pagefind 索引、RSS prerender、SSG 路由全部都在本地產出後直接 serve 就能驗證 ── 跟 production 的差別只剩 CDN 跟 domain,這兩件事不會改變 build 結果。
所以這次直接放棄 staging,靠 feature branch 隔離 + 本地完整驗收。
這個原則適用任何 SSG + GitHub Pages 站
這不是這個專案特有的策略。任何「workflow 綁在 main push」的 SSG 站都適用:
- Astro / Hugo / Eleventy / Next.js export → GitHub Pages
- Nuxt SSG → Cloudflare Pages(如果走 git push trigger)
- Jekyll → 任何 static host
只要部署的觸發條件是「push 到某個 branch」,那個 branch 就自動具備了「每次推送 = 一次上線」的語意。改版的時候別跟這個語意對抗 ── 用 feature branch 隔離、最後一次合併。
第三個概念:merge-as-deploy。當 push = deploy,merge 就是部署事件本身。設計工作流的時候別忘了這個物理限制。
把問題再問一次
回到最開頭那個問題 ── 什麼時候 merge 才不會爛?
三個決策湊出來的答案是:
- 驗證面要夠窄 ── Phase A / B 切開,每個 phase 出問題的可能來源被收斂在一個維度
- 盤點要夠徹底 ── grep-first 列出所有依賴點,進場跟出場用同一組工具,不留印象漏網
- Branch 要夠隔離 ── 全程 feature branch、A10 一次合併,部署作為一次性事件
這三件事不是工程紀律,是物理限制下的算術。當你的部署機制只有「push master = 上線」這一條,當你要扛的變更量超過一次 review 能處理的容量,你就被逼著要把這三題答好。
回呼一下上一篇講的 type scale drama ── editorial 設計的張力來自字級倍率拉開,不是配色多。工程的重寫也一樣 ── 不是「全做 vs 少做」的二選一,而是找對分界線。 設計拉的是字級的分界線,工程拉的是 phase / commit / branch 的分界線。
下一篇是這次改版唯一一個讓我卡住超過幾小時的 bug ── Nuxt 3.21 升上去之後,/tag/隨筆 這種中文 URL prerender 直接 500,但 Nitro 完全不吐錯誤訊息,build log 看起來一切正常。最後抓到的兇手是 experimental.payloadExtraction 跟中文 URL 的隱性互動。獨立寫一篇 punch 比塞在這裡有力。
環境資訊
- Nuxt 3.21
- UnoCSS 0.x(取代 Vuetify 3.5)
- pnpm 9.x(取代 npm)
- Node 20(
.nvmrc固定) - GitHub Pages +
peaceiris/actions-gh-pages@v32 - 部署 trigger:
on.push.branches: [master]3
Reference
- pnpm 的
shamefully-hoist=true設定 ── Nuxt 對 transitive dependency auto-import 的需求需要這個 flag:https://pnpm.io/npmrc#shamefully-hoist ↩ peaceiris/actions-gh-pages── GitHub Pages 跨 repo 部署常用 action:https://github.com/peaceiris/actions-gh-pages ↩- GitHub Actions
on.push觸發條件官方文件:https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#push ↩