外观
一文搞懂 VitePress 自定义主题:插槽、暗黑模式与全文搜索
为什么不建议从零写一套 VitePress 主题
很多团队第一次搭建文档站时,都会在两个方向之间犹豫:要么完全使用默认主题,后期发现品牌区、公告条、页脚备案信息无处安放;要么直接复制整套默认主题源码改造,结果 VitePress 升级后冲突不断。
更稳妥的方式是继承默认主题并做增量扩展。VitePress 默认主题已经内置响应式布局、侧边栏、上一页/下一页、本地搜索和暗黑模式切换。我们只需要通过 .vitepress/theme/index 导出一个主题对象,包装默认的 Layout,再借助命名插槽注入自定义内容。这样既保留官方能力,又能控制升级成本。
本文用一个可直接落地的最小文档站,串起三个高频需求:
- 使用
Layout插槽扩展全站导航、文档头部和页脚; - 正确接管暗黑模式,避免首屏闪烁与样式丢失;
- 配置本地全文搜索,并说明中文分词、排除目录和热更新边界。
VitePress 自定义主题的整体结构
默认主题的扩展点可以分成三层:配置层负责导航、侧边栏和搜索选项;主题层负责注册布局、全局组件与样式;插槽层负责在固定位置插入 UI。理解这三层的边界,能避免把业务逻辑写进错误位置。
建议的目录结构如下:
text
.docs/
├─ .vitepress/
│ ├─ config.ts
│ └─ theme/
│ ├─ index.ts
│ ├─ custom.css
│ └─ components/
│ ├─ SiteBanner.vue
│ └─ SiteFooter.vue
└─ guide/
└─ index.md方案对比:继承默认主题还是完整自定义
| 方案 | 优点 | 代价 | 适用场景 |
|---|---|---|---|
| 直接使用默认主题 | 零主题代码,升级最轻松 | 品牌定制能力有限 | 内部文档、开源项目 |
| 继承并包装 Layout | 保留搜索、导航、暗色模式,插槽扩展简单 | 需要理解插槽命名 | 大多数团队文档站 |
| 完整自定义主题 | UI 自由度最高 | 要自行实现路由、目录、搜索入口等能力 | 营销官网与文档深度融合 |
本文选择第二种。它的关键不是覆盖默认主题,而是把默认 Layout 当作组件继续使用。
步骤一:包装默认 Layout 并注册主题
先创建主题入口。下面示例同时引入自定义样式、全局组件,并通过插槽渲染公告条和页脚。
typescript
// .vitepress/theme/index.ts
import DefaultTheme from 'vitepress/theme'
import type { Theme } from 'vitepress'
import SiteBanner from './components/SiteBanner.vue'
import SiteFooter from './components/SiteFooter.vue'
import './custom.css'
export default {
extends: DefaultTheme,
enhanceApp({ app }) {
// 注册可在 Markdown 中直接使用的全局组件
app.component('SiteBanner', SiteBanner)
},
Layout() {
return h(
DefaultTheme.Layout,
null,
{
// 导航栏上方的全站公告
'layout-top': () => h(SiteBanner),
// 文档内容底部的自定义页脚
'doc-footer-before': () => h(SiteFooter)
}
)
}
} satisfies Theme上面的 h 需要从 Vue 引入:
typescript
import { h } from 'vue'如果你更喜欢 SFC 写法,也可以新建 CustomLayout.vue,在模板中使用 <Layout> 与具名插槽,再在 index.ts 中把它作为 Layout 导出。插槽名称要以当前 VitePress 版本文档为准,常见的有 nav-bar-content-before、aside-outline-before、doc-before、doc-after、layout-bottom 等。
步骤二:用插槽扩展公告条与页脚
公告条通常需要可关闭,并把关闭状态保存在本地。注意不要在服务端渲染阶段直接访问 localStorage,否则构建时可能报 localStorage is not defined。
vue
<!-- .vitepress/theme/components/SiteBanner.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const STORAGE_KEY = 'docs-banner-hidden-v1'
const visible = ref(false)
onMounted(() => {
visible.value = localStorage.getItem(STORAGE_KEY) !== '1'
})
function closeBanner() {
visible.value = false
localStorage.setItem(STORAGE_KEY, '1')
}
</script>
<template>
<div v-if="visible" class="site-banner">
<span>新版搜索已支持中文标题与正文检索,立即按 Ctrl/⌘ + K 体验。</span>
<button type="button
<!-- 内容间隙推广位:正文之后内联渲染,不遮挡阅读;变现开关关闭时零渲染零占位 -->
<MonetizeSlot position="inline" domain="frontend" />
<!-- 免责声明:文字与 data/site.config.yaml 的 disclaimer 保持完全一致,不得删改 -->
::: warning 免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。
:::