用VuePress搭建技术博客的实战经验与常见问题解决
项目初期的技术选型
去年底接了个内部文档系统的需求:给一个中等规模的前端团队建一套组件库文档站,要支持版本切换、API 自动提取、搜索、主题定制,还得能嵌入 React/Vue 示例代码块。本来想直接上 Docusaurus,但翻了两天文档,发现它对 Vue 生态的本地开发体验有点拧巴——比如热更新慢、自定义 Markdown 渲染器得绕三道弯,而且我们已经有现成的一套 Vue 组件(包括 demo 框架和 props 表格生成逻辑),硬塞进 Docusaurus 的插件体系里,我预感会掉坑里。
最后还是选了 VuePress 2(v2.0.0-rc.16 那版)。不是因为它多先进,而是——它就是个 Vue 应用,跑在 vite 上,我改个 layout 组件,保存,立刻看到效果;写个自定义 plugin,就几行 defineClientAppSetup 调用;连 vite.config.ts 都能直接复用项目里的 alias 和 resolve 配置。说白了:省心,不造轮子,能快速把已有的 Vue 工具链搬进去。
最大的坑:性能问题
上线前压测发现,文档页加载特别慢,尤其是带大量 demo 的页面,首屏渲染卡顿明显。打开 DevTools,发现两个关键线索:
- VuePress 默认会为每个
.md文件生成完整 HTML + 客户端 hydrate,而我们的 API 文档页平均有 40+ 个 demo 实例,每个都挂载了独立 Vue 实例,光是createApp就干了四十多次; - demo 容器用了
v-html动态渲染组件字符串,但没做任何缓存或懒执行,滚动到哪就执行到哪,用户还没看到,脚本先跑完了。
开始没想到这么严重。以为只是 CSS 加载慢,折腾了半天加了 code-splitting、prefetch hint,毫无改善。后来在 clientAppSetup.ts 里打 log,才发现 demo 初始化函数在页面挂载时就被全量执行了,哪怕它还在屏幕外 3 屏远的位置。
最终的解决方案
核心思路就一条:**不让 demo 在初始 render 时执行,等它真正进入视口再启动**。但 VuePress 的 EnhanceApp 和 transformPageData 都没法直接劫持 demo 渲染时机,最后是在自定义 Markdown 插件里动的手脚。
我们在 plugins/demo-loader.ts 中重写了 demo 块的解析逻辑,把所有
</code> 块替换成一个占位组件,并带上原始代码的 base64 编码:</p>
javascript
// plugins/demo-loader.ts
import { defineUserConfig } from 'vuepress/cli'
import { defaultTheme } from 'vuepress/theme-default'
export const demoLoaderPlugin = () => ({
name: 'demo-loader',
extendsMarkdown: (md) => {
md.use((token, _state, _silent) => {
if (token.type === 'fence' && token.info.trim() === 'vue') {
const code = token.content
const id = demo-${Math.random().toString(36).substr(2, 9)}
token.type = 'html_block'
token.tag = 'div'
token.content = <DemoPlaceholder :id="${JSON.stringify(id)}" :code="${JSON.stringify(btoa(code))}" />
}
})
}
})
<p>然后在 <code>components/DemoPlaceholder.vue</code> 里做懒加载:</p>
vue
import { ref, onMounted, onUnmounted } from 'vue'
import { createApp } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'
const props = defineProps()
const container = ref(null)
const target = ref(null)
const loaded = ref(false)
onMounted(() => {
if (!container.value) return
useIntersectionObserver(
container,
([{ isIntersecting }]) => {
if (isIntersecting && !loaded.value) {
loaded.value = true
const decoded = atob(props.code)
const app = createApp({
template: decoded,
// 这里注入我们已有的全局组件(如 Button、Icon)
components: { /* ... */ }
})
if (target.value) app.mount(target.value)
}
},
{ threshold: 0.1 }
)
})
onUnmounted(() => {
// 卸载时手动 unmount,避免内存泄漏
if (target.value?.__vue_app__) {
target.value.__vue_app__.unmount()
}
})
```
这个方案亲测有效:首页首屏渲染时间从 3.2s 降到 0.8s,滚动到 demo 区域才初始化,CPU 占用也平稳多了。唯一小遗憾是:如果用户快速滚动,偶尔会出现 skeleton 闪一下再加载成功,不过不影响主流程,也没收到反馈,就暂时搁置了。
其他顺手的改造
还有几个“不痛不痒但真香”的点:
- 版本切换路由统一前缀:默认是
/v2/xxx这种,我们改成/docs/v2/xxx,方便 Nginx 反向代理到不同静态目录,不用改构建脚本; - 搜索只索引当前版本:改了
@vuepress/plugin-search的getPages钩子,过滤掉非当前版本路径的页面; - API 表格自动补全:写了个简单的 remark 插件,扫描
/** @api */注释,生成 props 表格,比手写 markdown 省事太多。
回顾与反思
整体来说,VuePress 2 在这个项目里扛住了压力。它不像 Next.js 那样自带 SSR 和数据层抽象,也不像 VitePress 那样轻量极简,但它给了足够的控制权,又不至于让你从零搭框架。缺点也有:插件生态不如 Docusaurus 成熟,官方文档对高级定制讲得比较散,很多细节得翻源码或者看 issue。
比如上面那个 demo 懒加载,VuePress 官方其实提供了 clientAppEnhance,但它的执行时机在所有页面 mount 之后,没法干预单个 block 的渲染时机——这事儿得自己动手,别指望开箱即用。
另外,VuePress 的主题系统虽然灵活,但文档里没明确说哪些 hooks 是稳定 API,哪些是内部实现。我们曾依赖过 resolvePageComponent,结果 v2.0.0-rc.17 里这个方法名被悄悄改了,CI 直接报错,排查了俩小时才发现是 breaking change 没写进 release note……这种事儿,只能靠自己加单元测试兜底。
总的来说,VuePress 不是万能胶水,但它是块好砖:你清楚地知道它在哪、怎么砌、哪块松了可以自己拧紧。如果你也在做 Vue 技术文档,且已有 Vue 工具链沉淀,它大概率不会让你后悔。
以上是我踩坑后的总结,希望对你有帮助。有更优的 demo 懒加载实现方式,或者对 VuePress 主题深度定制的经验,欢迎评论区交流。

暂无评论