為什麼不一次改完:一次 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範圍驗證面
ANuxt 3.21、Vuetify → UnoCSS、Magazine 改版、四個新功能UI 全面重寫
BNuxt 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 一次:

bash
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 個檔案

區段檔案數內容
設定層3nuxt.config.tsplugins/vuetify.ts(刪檔)、app.vue
根層1error.vue
Layouts3default.vuepostPageLayout.vuesinglePageLayout.vue
Pages5index.vueabout.vuedonate.vuepost/[...slug].vuetag/index.vue
Components8Header、Footer、PostCard、PostHeader、TagMenu、PageTocMenu、BlogArchiveMenu、SocialMedia
Content Prose2ProsePre.vueProseImg.vue

剩下的檔案 ── errorLayoutWrapper、其他 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、加 .npmrcshamefully-hoist=true 給 Nuxt 的 auto-import 用1)、CI workflow 加 pnpm/action-setupsetup-nodecache: '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 的觸發是這樣寫的:

yaml
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 只觸發一次部署。

這代表幾件事:

  1. Feature branch 期間做什麼都安全。 force-push、rebase、改歷史、整段 commit 重排 ── 都不會影響線上站
  2. 驗收要在 feature branch 跑完。 不能依賴「先推上去看 production 怎樣」的 workflow,因為一推就是上線。本地 pnpm generate && pnpm dlx serve dist 必須完整跑過所有頁面
  3. A0–A9 的 commit 訊息可以隨意。 WIP、fix-fix-fix、try this 都行 ── 反正 A10 合併回去那一刻,整段歷史是 PR 上的事
  4. 如果 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 才不會爛?

三個決策湊出來的答案是:

  1. 驗證面要夠窄 ── Phase A / B 切開,每個 phase 出問題的可能來源被收斂在一個維度
  2. 盤點要夠徹底 ── grep-first 列出所有依賴點,進場跟出場用同一組工具,不留印象漏網
  3. 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

  1. pnpm 的 shamefully-hoist=true 設定 ── Nuxt 對 transitive dependency auto-import 的需求需要這個 flag:https://pnpm.io/npmrc#shamefully-hoist
  2. peaceiris/actions-gh-pages ── GitHub Pages 跨 repo 部署常用 action:https://github.com/peaceiris/actions-gh-pages
  3. GitHub Actions on.push 觸發條件官方文件:https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#push