博客目录的高亮跟随
2025 年 07 月 01 日 • 关于本站
介绍了一个基于Markdown内容自动生成目录树的组件
🌟 开发目标
构建一个根据Markdown
内容自动生成目录树的组件,支持以下特性:
- 自动提取标题 (
#
~######
) 并构建层级结构 - 点击目录项跳转到正文对应位置
- 滚动正文时高亮当前目录项
- 支持多级目录(嵌套结构)
🧱 组件结构
目录主组件
- 功能:
- 接收 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
是经验阈值:当标题刚进入顶部区域即视为“当前”标题。- 依赖
headMap
和tocMap
建立标题与目录项之间的映射关系。
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>
留言 (0)