写博客这件事,最大的敌人不是没时间,而是工具太重。装插件、配数据库、升级挂了要修——最后你会发现自己一直在折腾博客,而不是写文章。
我最终选了 VitePress:Markdown 即内容,构建产物是纯静态文件,丢到任意对象存储就能跑。这篇文章记录搭建过程里的关键决策。
为什么选 VitePress
对比过几个方案后的判断:
| 方案 | 优点 | 为什么没选 |
|---|---|---|
| WordPress | 生态成熟、后台完善 | 需要 PHP + MySQL,要维护、要打补丁 |
| Hexo / Hugo | 纯静态、主题多 | 主题质量参差,中文排版要自己调 |
| 自建 Nuxt | 完全可控 | 写代码的时间超过写文章 |
| VitePress | Vite 秒级热更新、默认主题完成度高、Vue 组件可直接用在 Markdown 里 | — |
决定性的一点:它是 Vite 生态的,热更新和构建速度对写作体验有实质影响,而且想加功能时,直接在 Markdown 里写 Vue 组件就行。
适合谁
如果你主要写技术文章、能接受用 Git 管理内容、不需要评论系统,VitePress 几乎是最省心的选择。
目录约定
先定好目录,后面所有数据都靠它自动收集:
docs/
├─ .vitepress/
│ ├─ config.mts # 站点配置
│ └─ theme/
│ ├─ index.ts # 主题入口(注册全局组件)
│ ├─ Layout.vue # 扩展默认布局
│ ├─ posts.data.ts # 文章数据加载器
│ ├─ components/ # 列表/归档/标签组件
│ └─ styles/blog.css
├─ posts/ # 所有文章,一个文件一篇
│ └─ hello.md
├─ index.md # 首页
├─ archive.md # 归档
├─ tags.md # 标签
└─ about.md # 关于约定只有一条:文章放 posts/,文件名随便起。新增文章不需要改任何配置,构建时自动收录。
自动收集文章
核心是 createContentLoader,它在构建时扫描指定目录,把 frontmatter 抽成数组:
// docs/.vitepress/theme/posts.data.ts
import { createContentLoader } from 'vitepress'
export default createContentLoader('posts/**/*.md', {
includeSrc: true, // 需要统计字数
excerpt: '<!-- more -->', // 摘要分隔符
transform(raw) {
return raw
.filter(({ frontmatter }) => Boolean(frontmatter.date))
.map(({ url, frontmatter, src }) => ({
title: frontmatter.title,
url,
date: String(frontmatter.date),
tags: frontmatter.tags ?? [],
words: countWords(src ?? '')
}))
.sort((a, b) => +new Date(b.date) - +new Date(a.date))
}
})在组件里直接 import { data } from '../posts.data' 就能拿到结果,不需要手写任何索引文件。
一个真实的坑
如果项目 package.json 里没有 "type": "module",数据加载器会被当成 CJS 打包,而 vitepress 是纯 ESM,构建会报:
"vitepress" resolved to an ESM file. ESM file cannot be loaded by `require`.两种解法:加 "type": "module",或把文件改为 .data.mts。我选了前者。
文章 frontmatter 约定:
---
title: 文章标题
date: 2026-07-30
category: 工具
tags: [VitePress, 静态站点]
description: 一句话摘要,会显示在列表卡片上
---归档与标签
两个页面都只是"把数据换个方式分组",逻辑很短。
归档页:按年份分组
<script setup lang="ts">
import { computed } from 'vue'
import { data } from '../posts.data'
const groups = computed(() => {
const map = new Map<number, typeof data>()
for (const post of data) {
const list = map.get(post.year)
if (list) list.push(post)
else map.set(post.year, [post])
}
return [...map.entries()].sort((a, b) => b[0] - a[0])
})
</script>标签页:按标签聚合
同理,只是把 key 从年份换成标签,并顺手做一次计数排序:
[...map.entries()].sort(
(a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0], 'zh-Hans-CN')
)让 Markdown 直接用这些组件
在主题入口全局注册,页面里就能直接写标签:
// docs/.vitepress/theme/index.ts
export default {
extends: DefaultTheme,
Layout,
enhanceApp({ app }) {
app.component('ArchiveList', ArchiveList)
app.component('TagList', TagList)
}
}<!-- docs/archive.md -->
# 归档
<ArchiveList />首页自动列出最新文章
不用改首页内容,通过布局插槽注入即可:
<!-- Layout.vue -->
<template>
<Layout>
<template #home-features-after>
<HomePosts /> <!-- 内部取 data.slice(0, 6) -->
</template>
</Layout>
</template>部署
构建产物在 docs/.vitepress/dist/,纯静态:
npm run docs:build
npm run docs:preview # 本地验证产物三个要注意的点:
- 子路径部署要配
base:部署到https://xxx.io/ai-docs时,config.mts里加base: '/ai-docs/' cleanUrls谨慎开启:开启后 URL 无.html后缀,但需要服务端配合重写规则;直接放对象存储建议保持默认falsesitemap.hostname要写真实域名,否则生成的站点地图链接都是错的
小结
整套东西加起来不到 300 行代码,换来的是:写文章 = 新建一个 Markdown 文件。
- 目录约定代替配置:
posts/下新增文件即发布 createContentLoader代替手工索引:列表、归档、标签全自动- 布局插槽 + 全局组件:首页、文章页的定制都不侵入默认主题
工具的意义是让人忘记工具的存在。目前这套我挺满意的。