🌟 开发目标

构建一个根据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