从零 Vibe Coding 出一个博客:技术复盘

文章约 8 分钟阅读
科技AI

为什么从零开始

市面上的博客框架——Hexo、Hugo、Jekyll——用起来都差不多:选个主题,改改配置,写 Markdown,部署。但当你想做一些”不在主题预设范围内”的事情时,就会发现你在和主题的模板系统搏斗,而不是在写代码。

我想要一个类似 flomo 的笔记流式博客:左侧有热力图和标签筛选,右侧是时间线式的卡片,支持随想、文章、转载三种内容类型,暗色主题优先,还要有记录统计面板与多种浏览/检索方式。这些需求用现成主题很难做到”刚好对”,于是决定从零开始——用 Vibe Coding 的方式。

技术栈

Astro 7 是核心依赖。它在构建时把 Markdown 渲染成静态 HTML,首页零 JavaScript 运行时开销。内容层(Content Layer)用 glob loader 加载文章,Zod schema 校验 frontmatter——写错字段会在构建时报错,而不是上线后才发现。文章渲染统一走 render() API,纯静态输出。

Tailwind CSS 4 + @tailwindcss/typography 解决样式问题。CSS-first 配置(@theme 定义字体、@custom-variant 声明暗色 class 策略)与手写的 CSS 变量主题共存,互不干扰。Typography 插件让 Markdown 渲染出来的 HTML 自动获得排版样式,不用手写每个 h1pcode 的样式。

Shiki 处理代码高亮。配置 { dark: 'github-dark', light: 'github-light' } 双主题后,构建时会同时输出两套颜色变量,前端通过 CSS 变量切换。

部署方案采用双仓库架构:源码仓库 rd-zzzz/Blog(私有)存放博客源文件,Pages 仓库 rd-zzzz/rd-zzzz.github.io(公开)只存放构建后的静态文件。每次 push 到 main 分支,GitHub Actions 自动 npm ciastro check(类型门禁)→ astro build → 推送静态文件到 Pages 仓库,整个流程零人工干预。

架构设计

src/
├── content.config.ts       # 内容集合定义(glob loader + Zod schema)
├── content/posts/          # Markdown 文章(40 篇)
├── pages/
│   ├── index.astro         # 首页时间线
│   ├── about.astro         # 关于页(全站访问量)
│   ├── posts/[slug].astro  # 文章详情页(含旧 URL 重定向)
│   └── rss.xml.ts          # RSS
├── components/
│   ├── Sidebar.astro       # 侧边栏主组件(数据处理 + 交互逻辑)
│   ├── Heatmap.astro       # 12 周热力图
│   ├── StatPanel.astro     # 统计面板(月/年/总三视图)
│   ├── FilterDropdown.astro # 筛选下拉菜单
│   ├── TagList.astro       # 标签列表
│   ├── DateScrubber.astro  # 右侧日期导航条
│   ├── SEO.astro           # OG / Twitter meta
│   └── ThemeToggle.astro   # 暗色/亮色切换
├── layouts/
│   ├── HomeLayout.astro    # 首页布局
│   └── BaseLayout.astro    # 文章详情布局
└── styles/
    ├── global.css          # CSS 变量主题 + Tailwind 4 配置
    └── sidebar.css         # 侧边栏样式

侧边栏由 8 个职责单一的组件构成,每个组件通过 props 传递数据,职责清晰。

三种内容类型

frontmatter 中的 type 字段区分 note(随想)、article(文章)、repost(转载)。这个设计决定了首页的渲染策略:

随想在首页直接渲染全文——它们通常很短,不需要按需加载。文章和转载只显示标题和摘要,点击”展开全文”后通过 AJAX 从详情页抓取内容注入。这个策略让首页 HTML 保持在约 200 KB。

CSS 变量驱动主题

所有颜色定义为 CSS 变量(--bg--card-bg--text-emphasize 等),暗色为默认值,亮色通过 html:not(.dark) 覆盖。主题切换只需在 <html> 上 toggle dark class,偏好存储在 localStorage,多标签页通过 storage 事件同步。Tailwind 的 dark: 变体用 @custom-variant 声明为同一 class 策略,两套系统指向同一个开关。

字号系统也用类似思路:正文相关元素用 rem 单位,随根字号等比缩放;UI 元素(按钮、标签、导航条)保持 px 不变。这样用户调大字号时,正文变大但界面不会”肿”。

核心实现

首页数据流

构建时,getCollection('posts') 获取所有文章,过滤 draft: true,按 pinned 优先 + date 降序排序。随想类型调用 render() 预渲染为 HTML 直接嵌入;文章和转载的正文容器留空,等待客户端 AJAX 填充。

运行时,页面加载完成后执行两个初始化:检测随想卡片的 scrollHeight,超过阈值(约 9 行高度)的自动折叠并添加渐变遮罩,字体加载完成与滚动时都会重新测量;初始化分页,每页 10 条,超出部分隐藏。

筛选逻辑采用统一状态模型:搜索、标签、类型、日期四类条件共享单一状态源,任何条件变化都对全部条件求交集后重算显示。这个设计避免了多套筛选逻辑各自覆盖 display 导致的”选了标签再搜索就丢状态”类问题。

AJAX 按需加载

用户点击”展开全文”时,脚本先查 contentCache(一个 Map<string, string>),有缓存直接用;没有则 fetch('/posts/{slug}/')DOMParser 解析 → 提取正文容器 → DOMPurify 净化 → 存入缓存。DOMPurify 通过动态 import() 按需加载,首页初始不下载。内容按 1500 字符分段,注入第一段,后续段落通过”加载更多”按钮逐步追加;加载失败会保留重试入口,再次点击即可重新拉取。

详情页正文在构建时还经过 rehype-sanitize 净化,首页抓取后再过一遍 DOMPurify,两端各有一层防护。

日期导航条(DateScrubber)

这是项目的核心浏览功能,解决”文章多了之后想找某天的笔记只能不停滚动”的问题。

导航条在构建时由首页从所有文章的 date 字段统计生成。前端通过 rebuild() 在页面加载和筛选事件时动态创建刻度标记,用 position: absolute 按固定间距定位在 track 内部,因此无论有多少文章,标记都等距排列、互不重叠。

涟漪效果的实现:光标悬浮在导航条区域时,找到最近的刻度,以该刻度为中心,左右各 3 个刻度按距离衰减设置 data-factor 属性(0–100)。CSS 通过 data-factor 属性选择器控制宽度和颜色:最近的刻度宽度翻倍(8px → 16px),最远的衰减到 1.2 倍;颜色从灰色插值到品牌绿,插值色取自 CSS 变量,亮暗主题各一套。tooltip 用单个 position: fixed 元素,悬浮时定位到光标左侧,通过 findNearest 函数匹配最近的刻度——这样光标在两个刻度之间滑动时 tooltip 不会中断。

点击刻度后,脚本计算目标文章的精确位置(加上顶栏高度偏移),然后检查文章是否还在分页折叠状态。如果是,先展开足够的分页让目标文章可见,再平滑滚动过去。导航条还会跟随主页滚动同步高亮当前位置(rAF 节流,避免滚动时全量重排),滚动高亮与分页展开状态始终保持一致。

统计面板与年视图动效(StatPanel)

记录统计是一个全屏遮罩模态面板,内含三个视图切换:

  • 月视图:12 个月份卡片 + 日历热力图,展示每月的笔记密度。
  • 年视图:三张独立柱状图(笔记数 blue / 字数 green / 活跃天数 red),各 12 列按月份对齐,下方附月度热力图网格。
  • 总视图:若干关键指标卡片(总笔记数、总标签数、记录跨度天数等)。

年视图有两处动效设计:

入场动效——切换到「年」视图时,三个柱状图的数据从 0 平滑上涨到各自的当前值。柱高由 CSS 变量 --pct 驱动(无 JS 时也显示正确高度),切换时先瞬时归零并强制回流,再用 requestAnimationFrame 统一从 0 涨到目标高度,并按列错峰(transitionDelay)让三图同步”列状”上扬。

悬浮高亮 + tooltip——光标悬浮在某月柱子上时,白色半透明高亮块会包裹住该图表内对应的整月列;同时弹出 tooltip 显示该月数据(仅显示被悬浮图表自身的指标,零值也会高亮并显示 0)。高亮按光标 x 坐标命中月份列,因此鼠标沿月份横向滑动时高亮与数据持续刷新。

随机漫步

侧边栏底部有一个「随机漫步」按钮,是浏览长博客的轻松入口:点击后,脚本先重置所有筛选(类型/标签/搜索回到全部、分页复位),再从全站所有笔记中随机抽取一篇,复用 DateScrubber 的滚动逻辑平滑滚动到目标、并只展开目标所在的分页(不会一次性展开全部隐藏文章)。

为保证在任意环境下都可用,滚动逻辑会自动判断实际滚动容器:桌面端 .main-content 自身滚动,移动端整页由 window 滚动,两者行为一致。点击瞬间按钮文案变为”漫步中…”作为反馈。

Shiki 双主题

Shiki 配置为 { dark: 'github-dark', light: 'github-light' } 后,构建时在每个 <pre class="astro-code"> 上同时输出两套颜色:亮色主题的 colorbackground-color 作为内联样式,暗色主题的 --shiki-dark--shiki-dark-bg 作为 CSS 变量。

通过一组带 !important 的 CSS 覆盖规则,在 html.dark 下用 CSS 变量覆盖内联样式的颜色,实现暗/亮主题切换:

html.dark .astro-code {
  background-color: var(--shiki-dark-bg) !important;
}
html.dark .astro-code .line,
html.dark .astro-code .line span {
  color: var(--shiki-dark) !important;
}

管理后台

admin/ 目录下有一个纯前端的管理后台,基于 File System Access API 操作本地 src/content/posts/ 目录下的 Markdown 文件。支持创建、编辑、删除文章,上传图片,编辑器为本地化的 Vditor(离线可用)。编辑保存会保留 frontmatter 中未知的自定义字段与原始顺序,含引号/换行的标题和描述会被正确转义,不会产出破坏构建的无效 YAML。

后台配套一个本地 Python API 服务器(静态文件服务 + git 推送)。推送前会校验请求来源(防止任意网页跨站触发提交)、检测本地是否落后远程,避免产生无法推送的提交。这个后台只在本地使用,不部署到线上,所以放在 .gitignore 中排除。

工程细节

几个容易被忽略但影响长期维护的决策:文章 URL 使用不带 .md 后缀的规范路径,旧链接自动生成重定向页,外链与 RSS 订阅不会失效;访问量统计由 Cloudflare Workers + KV 提供,IP + 日期去重,管理端点用 Bearer token 鉴权、token 经 wrangler secret 注入不入库;CI 在部署前跑 astro check 类型门禁,客户端脚本的类型错误无法悄悄上线;自托管字体(Inter + Noto Sans SC + Noto Serif SC)保证离线与隐私。

Vibe Coding 工作流

用量 1

整个项目全程 AI 辅助完成。工作流是:

需求描述 → AI 生成初始代码 → 本地预览 → 发现问题 → 截图/描述给 AI → AI 修复 → 再预览 → 再反馈。这是一个快速循环:人负责在每一步判断”对不对”,AI 负责把判断落地成代码。

这个过程中,人的角色从”写代码”变成了”审代码”和”定方向”。AI 负责实现细节,人负责产品判断——这个折叠阈值合不合适、这个动画时长是否自然、这个颜色该多亮。

真正消耗时间的不是写代码,而是”判断什么是正确的”。这个动画该持续多久?这个交互该在哪里停止?这个导航条该撑满整个屏幕还是紧凑居中?这些判断仍然是人的工作。

写代码的方式在变,但”知道自己要什么”这件事没变。