Skip to content

本站工程暗礁:VitePress 主题、构建部署与设计令牌上踩过的坑

搭站过程见 从零搭建个人博客;测试怎么抓 bug 见 E2E 实战
这篇只记 工程层:主题、构建、部署、令牌——那些「看起来能跑、线上已经悄悄错了」的点。

本站是 VitePress 1.x 静态博客,Nginx 直接指向 docs/.vitepress/dist/,部署命令就是仓库里的 npm run docs:build。没有 CI,没有单独 staging。这个选择换来简单,也放大了下面每一类坑的杀伤力。


一、先记住三条总纲

  1. docs:build 等于上线,不是本地自检。
  2. 源码在 docs/,线上在 dist/——永远改源码再 build,不要「热修」dist。
  3. 列表、RSS、搜索都靠约定(frontmatter、loader、sidebar),缺一步不会总有红字报错。

后面每条暗礁,都可以映射回这三条。


二、部署:直播目录就是 dist

2.1 emptyOutDir 会先清空线上

Vite 构建默认清空输出目录。本站 outDir 就是 Nginx root,所以:

  • build 进行中的几秒,站点可能是空的或半成品
  • 任何 只放在 dist 里、不在 docs/public/ 的文件,下次构建消失
  • 构建失败可能留下残缺产物——比「旧站还能访问」更糟

对策:

  • 静态资源放 docs/public/(例如 logo.svgog.png),构建时复制到站点根
  • docs:build 当发布窗口,改完再跑,不要当「看看能不能编译」的频繁操作
  • E2E 故意不在测试里跑 build:playwright.config.ts 起的是 dev server,避免测一次官网空白一次

2.2 cleanUrls 不是 VitePress 单方面的事

配置里开了:

ts
cleanUrls: true

链接都是 /notes/foo,磁盘上仍是 foo.html。没有 Nginx 这一段,全站干净 URL 会 404:

nginx
try_files $uri $uri/ $uri.html /index.html;

$uri.html 是承重墙。只配 $uri / $uri/ 时,本地 docs:dev 好好的,线上全挂——这类问题最难查,因为「构建产物在,路径也对,就是服务器不认」。

2.3 443 不一定是你的 Nginx

这台机器上 DERP(Docker)占了 443。真正的 HTTPS server 听在 4443,外层用 stream + ssl_preread 按 SNI 转发:

  • njcodex.com / www.njcodex.com127.0.0.1:4443
  • 其它名字 → DERP

新域名若不进那张 map,证书和 vhost 都配了对,流量仍进错后端。
细节在 搭建指南 的部署节;这里只强调:端口表和 SNI map 是架构的一部分,不是运维附录。


三、主题:插槽决定「你以为注册了,其实没有」

3.1 定制 404 必须走 not-found 插槽

错误直觉:

ts
export default {
  extends: DefaultTheme,
  Layout() { /* 包一层 DefaultTheme.Layout */ },
  NotFound  // 顶层导出,看起来官方
}

默认主题的 VPContent 内部已经 import 了自己的 NotFound,只通过插槽露出替换点。你包了 Layout 之后,顶层 NotFound 不会被用到,页面静默显示英文 PAGE NOT FOUND,没有报错。

正确:

ts
Layout() {
  return h(DefaultTheme.Layout, null, {
    'not-found': () => h(NotFound)
  })
}

这是本站 E2E 抓到的真 bug;完整叙述见 测试文 bug 2。工程结论是:

扩展默认主题时,优先查插槽表,不要假设「主题导出字段 = 会渲染」。

3.2 阅读进度条:写在首页 ≠ 全站有

进度条组件一度只出现在 index.md 里,但滚动逻辑让人误以为「全站都有」。文章页没有挂载节点,长文滚动时顶部什么都没有。

正确挂法是主题布局插槽,保证任意 doc 页都在:

ts
'layout-top': () => h(ReadingProgress)

实现上滚动监听用了 requestAnimationFrame 节流(见 ReadingProgress.vue),避免 scroll 事件里疯狂读布局。

3.3 文末组件同理:doc-after

上下篇、相关文章放在 doc-after,由组件内部判断「当前是不是 notes 文章」。这样不必每篇 Markdown 粘一段 Vue,也不会污染 about / tags。

模式总结:

需求插槽 / 位置
全站顶栏装饰layout-top
替换 404not-found
正文后doc-after
首页专用动效只放 index.md,并在 onUnmounted 清理

四、首页动效:装饰也要当资源管

粒子 + 打字机只在首页。两个硬约束写进了代码注释,也写进测试:

  1. prefers-reduced-motion: reduce 时直接不启动
  2. onUnmounted 必须停 rAF、卸监听

VitePress 是 SPA:从首页点进文章,首页组件卸载,若动画循环还在跑,就是泄漏 + 后台耗电。
E2E 里还有「模拟本身生效」的守卫测试——防止 emulateMedia 没生效时,降级用例假绿。

工程上的一句话:

装饰性 canvas 的生命周期,和业务组件同等对待。


五、设计令牌:一个 CSS 变量会落在三种底上

品牌色不是「logo 橙拿来填 brand-1」这么简单。

--vp-c-brand-1 在本站至少出现在:

场景背景
正文链接白底
.tag chip浅橙 soft 底
行内 codeVitePress 默认灰底

手算只对了白底:#B0542A 对白约 5:1,过 AA;对 chip / code 底落到 4.46 / 4.16,axe 挂。

现行浅色文字链:#9C4A24#E8835A / #D4694A 只做渐变和装饰,白字压上去只有 2.68 / 3.54。实心按钮单独设 --vp-button-brand-*,暗色反转成「浅橙底 + 深字」。

校验命令:

bash
npx playwright test tests/e2e/a11y.spec.ts

不要只信任对比度计算器上的一对色值——算的是 token,用户看到的是渲染后的组合。上游暂不修的问题(shiki 注释色、侧边栏 nested-interactive)在 a11y.spec.ts 里显式排除并写了原因,升级 VitePress 后要复查。


六、内容管线:单源,还是漏一篇

6.1 posts.data.mts 是列表的唯一真相

首页和 /notes/import 构建期 loader,禁止在两个 md 里手维护两份列表。历史上手写列表漏过 blog-setup-guide,索引只显示 4 篇——所以 E2E 用「磁盘上的 notes 文件数 === 渲染的 .post-item 数」守住。

loader 还要处理 YAML 日期:

ts
// date: 2026-07-19 会被解析成 Date
// String(date).slice(0, 10) → "Sat Jul 18"(错格式 + 时区退一天)
// 必须 toISOString().slice(0, 10) 或等价写法

6.2 Sidebar 不会自动出现

config.mts 里的 sidebar 硬编码。新笔记只建 md、不改 sidebar,则:

  • 首页列表有(loader 扫到了)
  • 左侧栏没有
  • 你以为「导航坏了」,其实是漏注册

发文 checklist 应固定包含这一项;也可以收成 skill,见 自己写 Skill

6.3 RSS 在 buildEnd,死链检查看不见它

feed.rssbuildEnd 写入 dist。Markdown 里写 [RSS](/feed.rss) 时,构建期死链检查会报错——文件还不存在。本站:

ts
ignoreDeadLinks: [/^\/feed\.rss$/]

HOSTNAME 只在 rss.ts 导出一处,sitemap / alternate link / og 共用,避免域名改三处漏一处。

6.4 404 是客户端的

构建出的 404.html 几乎是空壳 #app。对 HTML 做 grep 证明不了中文 404 文案;必须浏览器里打开不存在路径再断言。这也是为什么 http 层契约测试和 UI 测试要分开。


七、中文与默认主题的缝

VitePress 默认 UI 字符串是英文。本站在 themeConfig 里覆盖了搜索 modal、outline「目录」、暗色切换文案等。

新开一个 VitePress 自带 UI 面,就要想到翻译——否则中文正文配一排 English chrome,像半成品。
同样,中文字体栈在 custom.css 里显式接了 PingFang SC / Microsoft YaHei 等:Inter 不含中文,不写回退时中西混排基线会飘。


八、建议写进项目说明书的清单

本仓库的 CLAUDE.md(给 coding agent 的说明)就是从这些坑反推的。人读也一样有用:

text
□ 改内容 → docs/notes + config sidebar,不改 dist
□ 改样式 → theme/custom.css;首页私有动效才进 index.md
□ 验证 UI → playwright(dev server);验证 RSS/og → 先 docs:build 再 artifacts
□ 验证对比度 → a11y.spec,不要只靠色卡
□ 新域名 / 新端口 → Nginx vhost + SNI map
□ cleanUrls 异常 → 先查 try_files 是否含 $uri.html

如果你用 Claude Code 维护本站,把「发文 / 改主题 / 查 a11y」收成 skill,比每次重新描述更稳——写法见上一篇。


九、和「功能清单」的对照

能力实现要点曾踩的坑
文章列表posts.data.mts手写列表漏文;Date 格式
RSSbuildEnd + feed死链检查;draft 过滤
阅读进度layout-top只挂首页
404not-found 插槽顶层 NotFound 无效
品牌色CSS 变量 + 按钮单独 token多背景对比度
无障碍axe @ playwright手算只覆盖白底
干净 URLcleanUrls + Nginx$uri.html
上线build 进 Nginx root清空窗口;失败残局

十、收尾

个人站的工程难度通常不在「选静态生成还是 SSR」,而在:

  • 部署模型是否把构建产物直接暴露成生产
  • 主题扩展点是否静默失败
  • 设计 token 是否在真实 DOM 组合下仍合法
  • 内容管线有没有单源与自动化验收

本站选择简单部署,就用测试和文档把尖角包起来。你若 fork 这套结构,优先抄的不是粒子动画,而是:

  1. 列表单源
  2. 主题插槽用法
  3. a11y 回归
  4. 「dist 不可手改」写进所有协作者(含 AI)的默认说明

相关阅读:搭建指南 · E2E 实战 · 自己写 Skill

用代码记录成长 · RSS 订阅 · 标签