ARTICLE
Vibe CodingVitePressPlaywright

Vibe 出一个博客:从渲染排障到可验证界面

这个博客不是先画完 Figma,再照着稿子实现的。它从一句“代码块没有高亮”开始,经过几十次很小的反馈逐步长出来:桌面端太像手机、表格太重、字号太小、组件 hover 会位移、About 信息太散、动画做得难看。

iBUILD MODE
没有完整设计稿。每一轮从一个具体的不顺眼开始,以浏览器里的可验证结果结束。

这听起来很像纯粹凭感觉改页面,但真正让它没有失控的,是一条反复执行的工程闭环:

text
观察真实页面
  -> 把不适感改写成可检查的问题
  -> 修改最小范围的代码
  -> 构建
  -> 在桌面与移动视口验证
  -> 保留、继续收敛,或者直接删除

这篇只记录这条链路里真正有技术含量的部分。

从渲染结果开始,而不是从源码猜

第一次排查发生在文章最底部。代码块看起来没有高亮,单看 Markdown 和配置无法判断问题究竟在哪一层:

  1. Markdown 是否被识别为 fenced code block;
  2. VitePress 是否调用了 Shiki;
  3. Shiki 是否输出了 token span;
  4. 自定义 CSS 是否把 token 颜色覆盖掉了;
  5. 问题是否只发生在页面底部或移动端横向滚动容器中。

所以检查不是停在 npm run build,而是直接打开实际页面,滚到底部,再看 DOM 和计算样式:

bash
playwright-cli open http://192.168.1.163:5173/blog/articles/hello-world
playwright-cli resize 1440 1000
playwright-cli snapshot
playwright-cli eval "document.querySelectorAll('.shiki span').length"
playwright-cli close

最终的职责划分很明确:VitePress 在构建阶段调用 Shiki,主题只负责代码容器、语言标签、滚动和留白,不在运行时再引入一套高亮器。

ts
export default defineConfig({
  markdown: {
    lineNumbers: true,
  },
})

这里最重要的不是某个配置项,而是先确认“语义有没有生成”,再调整“视觉有没有表达出来”。否则很容易一边改 Shiki,一边其实只是 CSS 把颜色盖掉了。

先建立内容模型,再做首页

站点被拆成四个稳定入口:

ts
export const routes = {
  "/": "最近更新",
  "/articles": "普通文章",
  "/skills-agents": "Skill 与 Agent 实践",
  "/about": "个人信息与项目",
} as const

文章列表没有维护第二份手写数据,而是直接让 Vite 读取 Markdown 模块:

js
const postModules = import.meta.glob("/blog/**/*.md", {
  eager: true,
})

const posts = Object.values(postModules)
  .map((mod) => mod.__pageData || {})
  .filter((page) => page.relativePath?.startsWith(sourceFolder))
  .map((page) => ({
    url: "/" + page.relativePath.replace(/\.md$/, ""),
    title: page.frontmatter.title || "",
    description: page.frontmatter.description || "",
    date: page.frontmatter.date || "",
    tags: page.frontmatter.tags || [],
  }))

这让 frontmatter 成为唯一内容源。首页和两个文章索引只负责筛选、排序与展示,不复制标题、日期和标签。blog-posts.json 只作为机器可读索引,不参与页面运行时渲染。

桌面端不是移动端的放大版

第一版在宽屏上仍然是单列、窄间距和小字号,视觉上像一张被拉宽的手机截图。修复方式不是继续加 max-width,而是让两个断点承担不同的信息结构。

移动端优先保证顺序与触控:

  • 导航占满宽度;
  • 项目和文章使用单列;
  • 表格只在自身容器内横向滚动;
  • 标题、标签和箭头不能挤出父容器。

桌面端则提高并行信息密度:

  • 首页使用主视觉与文章网格双栏;
  • 文章索引的标题与简介并排;
  • About 的身份信息、经历和项目列表形成不同扫描层级;
  • 列表 hover 只改变背景和颜色,不改变位置。
html
<div
  class="grid gap-12 py-10
         lg:min-h-175
         lg:grid-cols-[minmax(330px,0.8fr)_minmax(0,1.55fr)]
         lg:gap-20 lg:py-20"
>
  <HomeHero />
  <section><!-- latest posts --></section>
</div>

响应式不是把同一套尺寸缩放,而是在不同宽度下重新决定哪些信息应该并排、哪些必须排队。

Tailwind 接管组件,CSS 只处理生成内容

项目使用 Tailwind CSS v4,通过 Vite 插件接入:

ts
import tailwindcss from "@tailwindcss/vite"

export default defineConfig({
  vite: {
    plugins: [tailwindcss()],
    resolve: {
      alias: {
        "@": fileURLToPath(new URL("./theme", import.meta.url)),
      },
    },
  },
})

颜色、字体和容器宽度集中在 @theme。Vue 组件主要使用工具类,style.css 只保留两类无法靠模板直接控制的内容:全局基础规则,以及 VitePress 生成的 Markdown、Shiki、表格后代选择器。

组件目录也在迭代中被重新分层:

text
.vitepress/theme/components/
├── pages/
│   ├── HomePage.vue
│   ├── AboutPage.vue
│   └── PostIndex.vue
└── ui/
    ├── HomeHero.vue
    ├── CodeBlock.vue
    ├── ImageBox.vue
    ├── Kbd.vue
    ├── Note.vue
    ├── Quote.vue
    └── Tag.vue

页面组件负责路由级组合,小组件负责局部表达。Markdown 需要使用的组件在 index.ts 全局注册,HomeHero 这类页面私有组件只做局部 import,不扩大主题 API。

Vibe 的关键动作是删除

这个过程中最耗时间的尝试,是首页的一段“蓝色月牙沿弧线移动,最后变成橙色落日”的动画。它经历了关键帧、运动路径、形状遮罩、中轴对称和连续插值,技术上都能运行,但视觉上仍然抢内容、显得刻意。

最后的处理不是继续调参数,而是删除元素、关键帧和相关工具类。

同样被删掉或收回的还有:

  • 与导航 hover 边框重叠的页眉装饰;
  • hover 时让文章卡片发生位移的效果;
  • 用两个 Tabs 隐藏两段本来应该同时读到的说明;
  • 过重的表格边框和卡片阴影;
  • 只为解释功能而存在的页面文案。

Vibe coding 很容易不断往页面上加东西。真正困难的是承认某个已经写完、甚至已经验证通过的实现仍然不应该留下。

每一轮都必须落到可验证条件

“看起来更好”不能直接作为完成条件。每次调整至少检查四件事:

CheckMethodFailure signal
构建npm run buildVue、Markdown 或 Tailwind 编译失败
桌面布局1440 × 1000内容仍像移动单列,间距失衡
移动布局390 × 844横向溢出、文字遮挡、表格撑宽页面
交互稳定hover / click / computed style位移、错误颜色、状态没有切换

一个常用的溢出断言只有一行:

js
document.documentElement.scrollWidth > document.documentElement.clientWidth

检查 hover 时,则同时记录背景色和元素坐标。背景应该变化,top 不应该变化:

js
const item = page.locator("a").filter({ hasText: "SwanLab" })

const before = await item.evaluate((element) => ({
  background: getComputedStyle(element).backgroundColor,
  top: element.getBoundingClientRect().top,
}))

await item.hover()
await page.waitForTimeout(200)

const after = await item.evaluate((element) => ({
  background: getComputedStyle(element).backgroundColor,
  top: element.getBoundingClientRect().top,
}))

最终交付循环保持得很短:

bash
npm run build
playwright-cli open http://192.168.1.163:5173/
playwright-cli resize 1440 1000
playwright-cli resize 390 844
playwright-cli close
"
用感觉提出下一步,用浏览器决定这一步是否成立。
— Build log

这个博客的“vibe”不是某一种渐变、字体或动画,而是一个持续收敛的过程:把模糊的不满意变成具体问题,把具体问题变成最小修改,再让真实渲染结果决定保留还是删除。