跳转到内容
关系图谱

content-toc:circuit 走线目录组件

1,843 字 7 分钟

属性 1
series
VaultKit 包指南

VitePress 默认的右侧大纲够用但寡淡:一根竖线加一段高亮。Nuxt UI 的 ContentToc 有个漂亮得多的 highlightVariant: circuit 变体——目录左侧是一条随缩进弯折的「电路板走线」,高亮段像电流一样随滚动在走线上流动。@vaultkit/vitepress-plugin-content-toc 把这套视觉完整复刻到了 VitePress,而且在换行处理上比原版更稳。本站每篇文章右侧的「页面导航」就是它,滚动本页即可看到走线上的高亮段移动。

本文通读包的全部导出:ContentToc 组件的每个 prop、collectTocHeadings 收集器的每个选项、唯一的 CSS 变量,以及 SVG 蒙版蛇形路径与 IntersectionObserver 滚动监听这两套核心机制的实现细节。

为什么需要它

想给 VitePress 换一个好看的目录,通常有两条路:改默认主题的 VPDocAsideOutline(侵入式、跟版本走),或者引一个大而全的 UI 框架(为一个目录拖进整个 Nuxt UI 不现实)。这个包把 circuit 目录做成了零依赖的独立组件——仅 peer 依赖 vue,不碰 VitePress 内部 API,数据从渲染后的 DOM 现取。

它对 Nuxt UI 原版还有一处实质改进:原版假定每行固定 1.75rem 行高,超长标题只能强制省略号,否则走线错位;本包改用实测 DOM 几何(ResizeObserver 跟随)生成路径与高亮段——标题换行、字号变化都不会错位。

安装

sh
pnpm add -D @vaultkit/vitepress-plugin-content-toc

若使用 @vaultkit/vitepress-theme-vault 主题则开箱即带,默认大纲的隐藏也已处理好。

快速上手

宿主在 onContentUpdated 时收集标题(现取最终渲染 DOM),传给组件:

vue
<!-- .vitepress/theme/components/Aside.vue -->
<script setup lang="ts">
import type { TocHeading } from '@vaultkit/vitepress-plugin-content-toc'
import { collectTocHeadings, ContentToc } from '@vaultkit/vitepress-plugin-content-toc'
import { onContentUpdated } from 'vitepress'
import { shallowRef } from 'vue'

const headings = shallowRef<TocHeading[]>([])
onContentUpdated(() => {
  headings.value = collectTocHeadings({ levels: 'deep' })
})
</script>

<template>
  <ContentToc v-if="headings.length" :headings="headings" title="页面导航" />
</template>

挂到 VitePress 默认主题的 aside-outline-before 插槽即可原位替换默认大纲——但注意默认的 .VPDocAsideOutline 要由宿主自行用 CSS 隐藏,本包刻意不做全局样式侵入。

全部导出一览

  • ContentToc——目录组件(默认导出
  • collectTocHeadings(options?)——DOM 标题收集器,返回 TocHeading[]
  • 类型:TocHeading{ id, text, level })与 CollectTocHeadingsOptions

组件没有事件、没有插槽、没有 v-model、没有 defineExpose——条目就是原生 <a href="#id"> 锚点链接,跳转交给浏览器与 VitePress 的路由拦截,API 面刻意收到最小。

collectTocHeadings 逐项详解

container

  • 类型:string
  • 默认值:'.vp-doc'

标题容器选择器,语义同 mindmap 包的 collectHeadings:以最终 DOM 为准,任何 Markdown 管线(wikilink、自定义容器)产出的标题都算数。非 VitePress 页面改成自己的正文容器:

ts
const headings = collectTocHeadings({ container: '#article-body' })

levels

  • 类型:number | [number, number] | 'deep'
  • 默认值:[2, 3]

参与收集的标题层级范围,语义完全对齐 VitePress 的 outline.level,迁移零成本:

ts
collectTocHeadings({ levels: [2, 4] }) // h2 到 h4
collectTocHeadings({ levels: 3 }) // 只要 h3
collectTocHeadings({ levels: 'deep' }) // h2 到 h6,等价 [2, 6]

返回值与 0 基缩进级

返回扁平数组 TocHeading[],每项 { id, text, level }

  • id——标题锚点;无 id 的标题不收集(没有锚点的条目点了也跳不过去)
  • text——标题文本,自动剥离 VitePress 的 .header-anchor 装饰节点(含零宽空格)
  • level——0 基缩进级:以页面实际出现的最小标题层级为基准。比如正文只有 h3/h4,那 h3 就是 0 级、h4 是 1 级

最后这点是与 Nuxt UI 树形层级语义对齐的关键:circuit 走线只区分「0 级」与「嵌套级」两列,以实际最小层级为锚,才不会因为文章从 h3 起头就整条走线缩在嵌套列。

ContentToc Props 逐项详解

headings(必填)

  • 类型:TocHeading[]

扁平标题表,通常由 collectTocHeadings 产出。也可以手工构造——只要 id 在页面上真实存在(IntersectionObserver 要观察它们)。headings 变化时组件重置高亮、nextTick 后重新观察与测量,路由切换场景开箱即用。

title

  • 类型:string
  • 默认值:''

大纲标题(如「页面导航」/ On this page)。为空时不渲染标题行,同时 <nav>aria-label 也随之省略:

vue
<ContentToc :headings="headings" title="页面导航" />

indent

  • 类型:number
  • 默认值:12

每级缩进宽度(px)。嵌套条目的 padding-inline-startlevel × indent。层级很深(levels: 'deep')时可适当收窄避免过度右移:

vue
<ContentToc :headings="headings" :indent="10" />

CSS 变量

只有一个:

变量默认值说明
--ctoc-active-colorvar(--vp-c-brand-1)高亮段与激活条目文字色
css
:root {
  --ctoc-active-color: #10b981;
}

其余颜色(轨道 --vp-c-divider、条目文字 --vp-c-text-2 等)直接消费 VitePress 语义变量,明暗自适应。

设计机制

circuit 走线:一个 SVG 蒙版的魔法

左侧那条「电路走线」不是画出来的线,而是一个被蒙版裁剪的指示器容器

  1. 指示器是 12px 宽的绝对定位列,内含两层——灰色轨道(铺满整列)+ 品牌色高亮段(高度与位移随滚动过渡)
  2. 容器整体套一个 SVG mask-image:按条目缩进生成蛇形路径——0 级条目走 x = 0.5 的左列,嵌套条目走 x = 10.5 的右列,层级切换处以 6px 斜线过渡
  3. 轨道与高亮段都被蒙版裁成走线形状——高亮段其实只是一个普通矩形在做 translateY + height 过渡(0.2s ease-out),但透过蒙版看就是「电流沿电路流动」

路径的 y 坐标全部来自实测:每个条目链接相对列表容器的 top / heightgetBoundingClientRect 现量,ResizeObserver 跟随容器尺寸变化重测。这就是标题换行也不错位的原因——蒙版路径跟着真实行高走,而不是假定每行 28px。

滚动监听:可见即高亮,滚空保留

高亮逻辑对照 Nuxt UI 的 useScrollspy:IntersectionObserver 观察每个标题元素,视口内所有可见标题同时高亮,高亮段覆盖首个到末个可见条目(取实测坐标的 min/max,与可见顺序无关)。当所有标题都滚出视口(正处在长段落中间),保留上一批高亮不清空——目录永远指着「你刚经过的地方」,不会突然熄灭。

无障碍与降级

  • <nav> 语义标签 + aria-label(取 title
  • 指示器纯装饰,aria-hidden="true",读屏器只感知条目链接列表
  • prefers-reduced-motion: reduce 下高亮段与条目颜色过渡全部禁用,尊重系统减动效偏好

高级与边界

Important

本包以 .vue 源文件发布,消费方必须配置,否则 SSR 构建报错:

ts
// .vitepress/config.ts
export default defineConfig({
  vite: { ssr: { noExternal: ['@vaultkit/vitepress-plugin-content-toc'] } },
})
  • SSR 安全headings 由宿主在 onContentUpdated 收集,首帧为空数组(与服务端一致),几何测量仅在客户端发生——零水合差异
  • 替换默认大纲:记得由宿主隐藏 .VPDocAsideOutline(如 display: none),否则出现双目录
  • peer 依赖vue ^3.5.0,运行时零依赖

常见问题

Q:目录渲染了但走线没出现? 走线依赖客户端测量,且要求条目至少一项。若样式覆盖把条目高度改成了 0 或容器 display: none,测量结果为空则不渲染指示器。

Q:某些标题没进目录? 两个筛子:levels 范围之外的层级,与没有锚点 id 的标题。后者常见于手写 HTML 标题——补上 id 属性即可。

Q:能像 VitePress 一样只高亮一个当前标题吗? 不能,这是刻意的语义差异:circuit 变体的表达是「当前视口覆盖的区间」,不是「单点位置」。要单点高亮请继续用默认大纲。

相关链接