升上 VuePress v2 RC 五個月後,決定再遷移到 Hugo 的紀錄
前言
2025 年 11 月才剛從 VuePress v1.8 升上 v2.0.0-rc.26,本以為至少能安穩用個幾年。結果升完五個月就遇到一堆問題,最後在 2026 年 4 月決定再搬一次,這次換到 Hugo。
這篇記錄從評估、選型、遷移、踩雷到最後部署上線的完整過程,順便也拿這篇當 component 測試文,把 Hugo 版型的 code block、tip、warning、表格、SVG、taxonomy 一次全部跑過。
為什麼升 v2 才 5 個月就要再搬?
升級前以為是一次「大幅刷新但之後穩定」的投資。升完才真正理解現狀:
- VuePress v2 本身自 2022 就卡在 RC,到 2026 仍然沒有 stable。這個「卡 RC 3+ 年」的背景是在升上去之後才有切身感受
- 插件生態沒跟上:v1 時期依賴的若干插件在 v2 沒有對應版本,有些甚至上游直接放棄維護
- RC 階段的 theme API 仍會 breaking change:升 v2 時已經改了一次 theme,完全不想再因為某次 RC 升級又全部改一次
- Dev server 啟動 30 秒:寫文章的摩擦大到開了就忘記要寫什麼
與其繼續賭 v2 什麼時候脫 RC,不如直接換到生態穩定、建置極快的工具。於是評估了 Astro、Next.js、Eleventy、Hugo,最後選 Hugo:
| 候選 | 建置速度 | 生態穩定度 | Runtime 依賴 | 主要缺點 |
|---|---|---|---|---|
| Hugo | 極快(幾百毫秒) | 十年以上穩定 | Go binary | 模板語法較陌生 |
| Astro | 中等 | 快速演進中 | Node.js | 還在 breaking changes |
| Next.js | 慢(依頁面數成長) | 成熟但偏 App | Node.js | 對靜態部落格 overkill |
| Eleventy | 快 | 小眾但穩定 | Node.js | template 選擇多到雜亂 |
Hugo 的勝出原因很簡單:Go 寫的單一執行檔、零 Node.js、零 npm、template 語法雖然怪但至少不會自己 breaking change。
架構對比
VuePress 時代整個堆疊是「Node.js 生態的所有東西」;Hugo 時代收斂成一顆 30MB 執行檔加上 Go template。執行時端也更乾淨:以前送到瀏覽器的還有 Vue 的 hydration runtime,現在純粹是 HTML + 少量 vanilla JS。
遷移計畫
整個遷移拆成 3 個 phase:
- Phase 1:建立 Hugo 骨架、搬 53 篇文章、1:1 對齊原本的視覺
- Phase 2:手機版 bug 修正(hamburger menu、iOS Safari 圓環)
- Phase 3:清理舊 VuePress、部署、prod smoke test
先讓內容可讀,再補視覺
每個 phase 都獨立可 commit、可回朔。Phase 1 做完其實 UI 還很陽春,但文章已經全部能正確 render,那時就可以先發版(或先切 dev server 給自己看),再慢慢補版型細節。這個順序讓心理壓力大幅下降,隨時可以停。關鍵實作
文章搬移
53 篇分成兩批來源:11 篇 VuePress 時代的新文章放在 blog/<slug>/README.md,42 篇 WordPress archive 放在 blog/wordpress/<slug>/index.md。全部統一搬到 content/posts/<slug>/index.md(Hugo 的 page bundle):
# 11 篇 VuePress 時代文章
for dir in blog/*/; do
slug=$(basename "$dir")
[ "$slug" = "wordpress" ] && continue
[ "$slug" = ".vuepress" ] && continue
mkdir -p "content/posts/$slug"
cp -r "$dir"* "content/posts/$slug/"
mv "content/posts/$slug/README.md" "content/posts/$slug/index.md"
done
# 42 篇 WordPress archive
cp -r blog/wordpress/*/ content/posts/
Archive 的 frontmatter 只有 title 和 date,統一補上 categories: [wordpress] 方便未來篩選與搜尋。新文章的 frontmatter 長這樣:
---
title: Discord 完全隱藏封鎖的訊息
date: 2026-01-01
description: 使用 CSS injection 完全隱藏 Discord 中被封鎖或忽略的訊息提示
categories:
- vuepress
tags:
- discord
- css
- dev-notes
---
Hugo 原生支援這些欄位,不需要轉換。
Prism.js build-time bundle
客戶端 syntax highlighting 用 Prism.js,但不想 runtime 打 CDN(會慢、會有失敗風險、也會洩漏使用者資訊給第三方)。Hugo 的 resources.GetRemote 正好可以在 build 時把所有 Prism 片段抓下來、concat、minify、fingerprint,最後輸出帶 SRI 的 <script> tag。
Hugo template 長這樣:
{{ $urls := slice
"https://cdn.jsdelivr.net/npm/prismjs@1.29.0/prism.min.js"
"https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-markup-templating.min.js"
"https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-bash.min.js"
}}
{{ $parts := slice }}
{{ range $urls }}
{{ with try (resources.GetRemote .) }}
{{ with .Err }}{{ errorf "fetch failed: %s" . }}{{ end }}
{{ with .Value }}{{ $parts = $parts | append . }}{{ end }}
{{ end }}
{{ end }}
{{ $bundle := $parts | resources.Concat "js/prism.js" | minify | fingerprint }}
<script defer src="{{ $bundle.RelPermalink }}"
integrity="{{ $bundle.Data.Integrity }}"></script>
結果:0 個 runtime CDN fetch,且 SRI 防止中間人竄改 bundle。Prism 的 language badge、複製按鈕、行號三個 plugin 全部跟著這包一起送。最終 JS bundle 約 65 KB(minified),相比每次打 21 個 CDN request 好非常多。
SCSS 在 libsass 的地雷
libsass 對 &-suffix 的展開跟 dart-sass 不一致。下面這種寫法會重複 ancestor:
.vp-navbar {
.vp-nav-dropdown {
&.open &-menu {
display: flex;
}
}
}
libsass 編譯出來:
.vp-navbar .vp-nav-dropdown.open .vp-navbar .vp-nav-dropdown-menu {
display: flex;
}
注意 .vp-navbar 被重複了。DOM 上不可能 match 這個 selector,行為上就是整條規則徹底失效。
libsass 與 dart-sass 行為不同
這個 bug 只在 libsass 出現。Hugo 0.153+ 已把 libsass 標為 deprecated,未來強制改用 dart-sass 之後這段展開會變正確。但現在(2026-04)仍在用 libsass,所以同樣的 SCSS 在舊版 Hugo 上是壞的、新版會正常 — 過渡期最好主動避開&-suffix after 後代選擇器。正確寫法是把 class name 寫完整:
.vp-navbar {
.vp-nav-dropdown {
&.open .vp-nav-dropdown-menu { // 明寫 class 名,不要用 &-menu
display: flex;
}
}
}
iOS Safari ≤ 16 的 SVG 地雷
Back-to-top 按鈕用 SVG 圓環表示捲動進度,原本幾何屬性 cx / cy / r 寫在 SCSS(CSS SVG Geometry Module):
.back-to-top-bar {
cx: 23;
cy: 23;
r: 21;
}
iOS Safari ≤ 16 不支援這個 module,舊版 iOS 看到的 cx / cy / r 全部是 0,圓圈畫不出來。修法是把這三個值移回 HTML attribute:
<circle class="back-to-top-bar" cx="23" cy="23" r="21" />
Tip
CSS SVG Geometry 是相對新的規格(2021-2022 才開始被各家瀏覽器支援)。處理 SVG 幾何屬性時,如果考慮移動裝置使用者,建議全部寫 HTML attribute,不要用 CSS。(順帶一提,這篇文章中的三張示意圖全部是 inline SVG,r / cx / cy 都寫在 HTML attribute — 現在讀到這裡的你如果看得到上面兩張流程圖,代表這個 bug 沒有 regression。)
Taxonomy 統一:lowercase-dash
原本 VuePress 時代 tag / category 混用三種 style:
- Title Case:
Dev Notes - All Caps:
CSS - lowercase-dash:
dev-notes
不同頁面顯示還不一致(sidebar 小寫、post-meta 原樣、URL 又是另一種)。Hugo 遷移時統一改成 lowercase-dash,且 template 內完全沒有 humanize / title-case 轉換:
tags:
- dev-notes # 不是 Dev Notes
- css # 不是 CSS
- ci-cd # 不是 CI/CD
Frontmatter 值 = URL slug = 顯示字串,三位一體,永遠一致。這樣也避免了「我到底要用哪一版」的心智負擔。
踩雷紀錄
遷移過程中掉過的其他坑:
hugo server --buildFuture一定要加,否則未來日期的草稿文章會被跳過[markup.highlight].codeFences = false要手動加,否則 Hugo 的 Chroma highlighter 會攔截所有 fenced code block,輪不到 Prism 上[markup.goldmark.renderer].unsafe = true要加,否則舊 archive 裡的 raw HTML(<center>、<font>)全部變成 escaped 字串- 移除
[[menu.main]]後要同步改 header partial 不要再 iteratesite.Menus.main,否則會出現一個空的 nav bar - Hugo 0.141+ 把
resources.GetRemote的.Err行為改了,要用trywrapper 包起來 hugo --minify產出的 HTML 會合併 class 屬性,任何依賴 class 順序的 selector 都要測試一次
成果
從「想寫文章 → 看到 preview」的時間:
| 指標 | VuePress 2 RC | Hugo | 差距 |
|---|---|---|---|
| Dev server 啟動 | ≈ 30 秒 | ≈ 200 毫秒 | 150x |
| 全量 build | ≈ 45 秒 | ≈ 800 毫秒 | 56x |
| 依賴數 (npm install) | ≈ 900 packages | 0 packages | — |
| 客戶端 JS (初版) | ≈ 180 KB | 1.5 KB + 65 KB Prism | 2.7x 小 |
| Runtime 依賴 | Node.js ≥ 20 | 無 | — |
| Lighthouse (mobile) | 70-80 | 95+ | — |
最實用的其實不是速度本身,而是「開了就能寫」。以前要等 30 秒 dev server 暖機,中間就會手滑去滑手機,然後完全忘了要寫什麼。
部落格演進
從 2008 年還在用 WordPress、2019 年搬到 VuePress v1、2025 年 11 月才剛升 v2 RC、五個月後再搬到 Hugo — 算下來 18 年、三次大遷移,最近兩次還剛好擠在半年之內。每次換 stack 都會寫一篇「為什麼要換」的文章,幾年後回頭看都會苦笑。這次記錄下來的東西未來 Hugo 也會過時,那時再看應該還是會苦笑 — 但至少中間幾年可以專心寫文章,不用跟工具鏈打架。
結語
技術選型沒有永遠的答案,只有當下最合適的取捨。選 Hugo 不代表它最好,而是它最符合「我想寫文章的時候不要有摩擦」這個目標。
同樣地,半年前選擇升 v2 RC 也不是錯誤決定 — 那時的資訊就是那樣,當下的選擇也合理。只是升完後才看清 VuePress v2 這個 RC 背後的長期狀況,判斷變了,就再搬一次。這種事沒辦法完全預防,只能讓「再搬一次」的成本越低越好,而這又回到同一個結論:選生態穩定的工具,下次再搬的機會就會小。
給也在考慮遷移的人
別一口氣重寫太多。先以「內容可讀 + 基本版面可看」為第一里程碑發版,再慢慢補視覺細節。這次我是先砍到最原始版型跑通,再一點一點把 VuePress 的 dark mode glow、gradient title、圖標 meta 加回來。中間任何時候都可以停下來 commit、都可以回朔,壓力小很多。本文同時作為 Hugo 遷移後的 component 測試文,內容涵蓋多語言 code block(含複製按鈕、行號、語言 badge)、tip / warning callout、inline SVG、表格、blockquote、dark mode 切換、Prism 語法標記。如果你看到這篇文章每個元件都正確顯示,代表整個系統在工作。