主题
本站工程暗礁:VitePress 主题、构建部署与设计令牌上踩过的坑
搭站过程见 从零搭建个人博客;测试怎么抓 bug 见 E2E 实战。
这篇只记 工程层:主题、构建、部署、令牌——那些「看起来能跑、线上已经悄悄错了」的点。
本站是 VitePress 1.x 静态博客,Nginx 直接指向 docs/.vitepress/dist/,部署命令就是仓库里的 npm run docs:build。没有 CI,没有单独 staging。这个选择换来简单,也放大了下面每一类坑的杀伤力。
一、先记住三条总纲
docs:build等于上线,不是本地自检。- 源码在
docs/,线上在dist/——永远改源码再 build,不要「热修」dist。 - 列表、RSS、搜索都靠约定(frontmatter、loader、sidebar),缺一步不会总有红字报错。
后面每条暗礁,都可以映射回这三条。
二、部署:直播目录就是 dist
2.1 emptyOutDir 会先清空线上
Vite 构建默认清空输出目录。本站 outDir 就是 Nginx root,所以:
- build 进行中的几秒,站点可能是空的或半成品
- 任何 只放在 dist 里、不在
docs/public/的文件,下次构建消失 - 构建失败可能留下残缺产物——比「旧站还能访问」更糟
对策:
- 静态资源放
docs/public/(例如logo.svg、og.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.com→127.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 |
| 替换 404 | not-found |
| 正文后 | doc-after |
| 首页专用动效 | 只放 index.md,并在 onUnmounted 清理 |
四、首页动效:装饰也要当资源管
粒子 + 打字机只在首页。两个硬约束写进了代码注释,也写进测试:
prefers-reduced-motion: reduce时直接不启动onUnmounted必须停 rAF、卸监听
VitePress 是 SPA:从首页点进文章,首页组件卸载,若动画循环还在跑,就是泄漏 + 后台耗电。
E2E 里还有「模拟本身生效」的守卫测试——防止 emulateMedia 没生效时,降级用例假绿。
工程上的一句话:
装饰性 canvas 的生命周期,和业务组件同等对待。
五、设计令牌:一个 CSS 变量会落在三种底上
品牌色不是「logo 橙拿来填 brand-1」这么简单。
--vp-c-brand-1 在本站至少出现在:
| 场景 | 背景 |
|---|---|
| 正文链接 | 白底 |
.tag chip | 浅橙 soft 底 |
行内 code | VitePress 默认灰底 |
手算只对了白底:#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.rss 由 buildEnd 写入 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 格式 |
| RSS | buildEnd + feed | 死链检查;draft 过滤 |
| 阅读进度 | layout-top | 只挂首页 |
| 404 | not-found 插槽 | 顶层 NotFound 无效 |
| 品牌色 | CSS 变量 + 按钮单独 token | 多背景对比度 |
| 无障碍 | axe @ playwright | 手算只覆盖白底 |
| 干净 URL | cleanUrls + Nginx | 缺 $uri.html |
| 上线 | build 进 Nginx root | 清空窗口;失败残局 |
十、收尾
个人站的工程难度通常不在「选静态生成还是 SSR」,而在:
- 部署模型是否把构建产物直接暴露成生产
- 主题扩展点是否静默失败
- 设计 token 是否在真实 DOM 组合下仍合法
- 内容管线有没有单源与自动化验收
本站选择简单部署,就用测试和文档把尖角包起来。你若 fork 这套结构,优先抄的不是粒子动画,而是:
- 列表单源
- 主题插槽用法
- a11y 回归
- 「dist 不可手改」写进所有协作者(含 AI)的默认说明