博客目录的高亮跟随
2025 年 07 月 01 日 • 关于本站
介绍了一个基于Markdown内容自动生成目录树的组件

🌟 开发目标

构建一个根据Markdown内容自动生成目录树的组件,支持以下特性:

  1. 自动提取标题 (# ~ ######) 并构建层级结构
  2. 点击目录项跳转到正文对应位置
  3. 滚动正文时高亮当前目录项
  4. 支持多级目录(嵌套结构)

🧱 组件结构

目录主组件

  • 功能:
    • 接收 markdown 字符串作为 prop
    • 解析 markdown 为目录树结构
    • 监听滚动事件,判断当前可视标题,激活对应目录项
    • 渲染目录树并传递给子组件 TocItem
    • 实现点击跳转功能
  • 核心逻辑:
    • tocTree(markdown):构建层级结构
    • scrollHandler():滚动监听并处理高亮
    • scrollToHeading(key):点击跳转逻辑
    • 生命周期:onMounted 中绑定 DOM 和监听滚动事件

TocItem 组件(递归组件)

  • 功能:
    • 接收单个目录项 item,并递归渲染其 children
    • 支持点击事件回传
    • 渲染当前是否为激活状态
  • 交互设计:
    • 通过 clickToc 自定义事件触发跳转

💦 关键函数

scrollHandler

页面滚动时动态高亮目录项

🎯 目标

在用户滚动页面时,自动高亮当前视口中最接近顶部的标题对应的目录项。

🧠 实现核心逻辑

typescript
复制代码
const scrollHandler = useDebounceFn(() => { // 获取每个标题的位置 const rects = heads.map(elementNode => elementNode.getBoundingClientRect()) for (let i = 0; i < rects.length; i++) { const rect = rects[i] const element = heads[i] // 当前标题出现在视口内(顶部区域) if (rect.top > 0 && rect.top <= 300) { tocs.forEach(elementNode => { elementNode.classList.remove('active') }) tocMap.get(element.textContent as string)?.classList.toggle('active') break } // 当前标题已经滚出,但下一个还未出现(末尾情况) if (rect.top < 0 && rects[i + 1] && rects[i + 1].top > document.documentElement.clientHeight) { tocs.forEach(elementNode => { elementNode.classList.remove('active') }) tocMap.get(element.textContent as string)?.classList.toggle('active') break } } }, 10)

🔍 分步骤解释

步骤 描述
1️⃣ 获取所有标题的 getBoundingClientRect(),用于判断它们在视口中的位置
2️⃣ 清除所有目录项的 .active状态
3️⃣ 遍历每个标题的位置,找到第一个在顶部区域的标题
4️⃣ 通过 tocMap找到对应目录项,并添加 .active高亮
5️⃣ 若全部标题已滚出视口(最底部情况),则定位最近一个标题高亮

📌 细节亮点

  • 使用 useDebounceFn 降低频率防止性能问题。
  • rect.top <= 300 是经验阈值:当标题刚进入顶部区域即视为“当前”标题。
  • 依赖 headMaptocMap 建立标题与目录项之间的映射关系。

scrollToHeading

点击目录项时平滑跳转页面位置

🎯 目标

点击目录项时,将页面滚动至对应的 Markdown 标题位置。

🧠 实现核心逻辑

typescript
复制代码
const scrollToHeading = (key: string) => { const elementById = headMap.get(key) as HTMLElement window.scrollTo({ top: elementById.offsetTop - 65, //为了预留导航栏或固定头部的高度空间 behavior: 'smooth' }) }

🔍 分步骤解释

步骤 描述
1️⃣ 获取目录项点击时传入的 key(标题文本)
2️⃣ 通过 headMap找到对应的标题 DOM 元素
3️⃣ 使用 window.scrollTo 平滑滚动到该元素的垂直位置
4️⃣ -65是为了预留导航栏或固定头部的高度空间

📌 细节亮点

  • offsetTop 是标题元素相对 document.body 的垂直距离。
  • behavior: 'smooth' 实现平滑滚动体验。
  • 也支持 scrollIntoView(),但 scrollTo({ top }) 控制更灵活,适合配合自定义偏移。

✅ 总结对比

函数名 作用 触发时机 实现方式 注意点
scrollHandler 实时检测当前滚动位置对应的标题 页面滚动时 遍历所有标题的位置,找出最靠近顶部的 性能受限于标题数量,需 debounce 优化
scrollToHeading 点击目录跳转标题位置 点击目录项时 使用 scrollTo({ top })精准控制位置 要考虑固定头部偏移量

📑 简易Demo

vue
复制代码
<script setup lang="ts"> const props = defineProps<{ markdown : string }>() interface TocNode { level : number text : string id : string children : TocNode[] } // 生成 TOC 树结构 const generateTocTree = (markdown : string) : TocNode[] => { const lines = markdown.split('\n') const stack : TocNode[] = [] const toc : TocNode[] = [] for (const line of lines) { const match = line.match(/^(#{1,6})\s+(.*)/) if (match) { const level = match[1].length const text = match[2].trim() const id = text.toLowerCase().replace(/\s+/g, '-') const node : TocNode = { level, text, id, children: [] } while (stack.length && stack[stack.length - 1].level >= level) { stack.pop() } if (stack.length === 0) { toc.push(node) } else { stack[stack.length - 1].children.push(node) } stack.push(node) } } return toc } const tocTree = ref<TocNode[]>([]) tocTree.value = generateTocTree(props.markdown) const headMap = new Map<string, HTMLElement>() const tocMap = new Map<string, HTMLElement>() const heads = [] as HTMLElement[] const tocs = [] as HTMLElement[] // 收集 DOM 元素 const collectDom = () => { headMap.clear() tocMap.clear() document.querySelectorAll('.markdown-body :is(h1,h2,h3,h4,h5,h6)').forEach(el => { headMap.set(el.textContent || '', el as HTMLElement) heads.push(el as HTMLElement) }) document.querySelectorAll('.toc-a').forEach(el => { tocMap.set(el.textContent || '', el as HTMLElement) tocs.push(el as HTMLElement) }) } const scrollHandler = useDebounceFn(() => { // 获取每个标题的位置 const rects = heads.map(elementNode => elementNode.getBoundingClientRect()) for (let i = 0; i < rects.length; i++) { const rect = rects[i] const element = heads[i] // 当前标题出现在视口内(顶部区域) if (rect.top > 0 && rect.top <= 300) { tocs.forEach(elementNode => { elementNode.classList.remove('active') }) tocMap.get(element.textContent as string)?.classList.toggle('active') break } // 当前标题已经滚出,但下一个还未出现(末尾情况) if (rect.top < 0 && rects[i + 1] && rects[i + 1].top > document.documentElement.clientHeight) { tocs.forEach(elementNode => { elementNode.classList.remove('active') }) tocMap.get(element.textContent as string)?.classList.toggle('active') break } } }, 10) const scrollToHeading = (text : string) => { const target = headMap.get(text) if (target) { window.scrollTo({ top: target.offsetTop - 65, behavior: 'smooth' }) } } onMounted(() => { collectDom() window.addEventListener('scroll', scrollHandler, true) }) onUnmounted(() => { window.removeEventListener('scroll', scrollHandler, true) }) </script> <template> <nav class="toc"> <ul> <article-toc-item v-for="(item, index) in tocTree" :key="index" :item="item" @click-toc="scrollToHeading" /> </ul> </nav> </template> <style scoped> </style>
vue
复制代码
<script setup lang="ts"> const props = defineProps<{ item : { text : string id : string level : number children : any[] } }>() const emit = defineEmits<{ (e : 'click-toc', text : string) : void }>() const handleClick = () => { emit('click-toc', props.item.text) } </script> <template> <li> <a class="toc-a" @click="handleClick"> {{ item.text }} </a> <ul v-if="item.children.length"> <article-toc-item v-for="(child, index) in item.children" :key="index" :item="child" @click-toc="emit('click-toc', $event)" /> </ul> </li> </template> <style scoped> a { cursor: pointer; padding: 4px 0; display: block; } a.active { font-weight: bold; color: #409eff; } </style>

📖 参考文献

目录的自动高亮【渡一教育】_哔哩哔哩_bilibili

上一篇
关于本站
个人博客系统 v1.0 发布说明
博客目录的改进

留言 (0)

昵称(必填)
邮箱(必填)
网址(选填)