# TOC (title of content) - 将 HTML 中的标题生成目录

# 1. 介绍

根据 HTML 中的所有标题生成文章目录:

  1. 点击文章目录中的链接,页面可以滚动到对应标题的位置
  2. 页面滚动时,文章目录也需要同步:
    • 页面视口最上面的标题对应的链接 要 高亮显示
    • 链接过多时,要让高亮链接滚动到容器的中间

# 2. 点击目录,页面滚动

页面:

<div class="page">
  <h1>Vue 组件化编程</h1>
  
  <h2 id="id_1-0">1. 模块与组件、模块化与组件化</h2>
  <h3 id="id_1-1">1.1. 模块</h3>
  <h3 id="id_1-2">1.2. 组件</h3>
  <h3 id="id_1-3">1.3. 模块化</h3>
  <h3 id="id_1-4">1.4. 组件化</h3>
  
  <h2 id="id_2-0">2. 非单文件组件</h2>
  <h3 id="id_2-1">2.1. 基本使用</h3>
  <h3 id="id_2-2">2.2. 注意点</h3>
  <h3 id="id_2-3">2.3. 组件的嵌套</h3>
  <h3 id="id_2-4">2.4. VueComponent</h3>
  <h3 id="id_2-5">2.5. VueComponent 与 Vue 的关系</h3>
  
  <h2 id="id_3-0">3. 单文件组件</h2>
</div>

抽取对象:

const headerNodeList = document.querySelectorAll('.page > h2, .page > h3');

let headers = Array.from(headerNodeList).map((element) => {
  const { id, textContent: title, nodeName } = element;
  return { 
    id, 
    title,
    level: Number(nodeName.substring(1))
  }
});

// --
headers = [
  { id: 'id_1-0', title: '1. 模块与组件、模块化与组件化', level: 2 },
  { id: 'id_1-1', title: '1.1. 模块', level: 3 },
  { id: 'id_1-2', title: '1.2. 组件', level: 3 },
  { id: 'id_1-3', title: '1.3. 模块化', level: 3 },
  { id: 'id_1-4', title: '1.4. 组件化', level: 3 },

  { id: 'id_2-0', title: '2. 非单文件组件', level: 2 },
  { id: 'id_2-1', title: '2.1. 基本使用', level: 3 },
  { id: 'id_2-2', title: '2.2. 注意点', level: 3 },
  { id: 'id_2-3', title: '2.3. 组件的嵌套', level: 3 },
  { id: 'id_2-4', title: '2.4. VueComponent', level: 3 },
  { id: 'id_2-5', title: '2.5. VueComponent 与 Vue 的关系', level: 3 },

  { id: 'id_3-0', title: '3. 单文件组件', level: 2 },
]

目录:

<!-- 根据 headers 构造目录 -->
<div class="toc">
  <a href="#id_1-0" class="toc-item level2 active">1. 模块与组件、模块化与组件化</a>
  <a href="#id_1-1" class="toc-item level3">1.1. 模块</a>
  <a href="#id_1-2" class="toc-item level3">1.2. 组件</a>
  <a href="#id_1-3" class="toc-item level3">1.3. 模块化</a>
  <a href="#id_1-4" class="toc-item level3">1.4. 组件化</a>
  <a href="#id_2-0" class="toc-item level2">2. 非单文件组件</a>
  <a href="#id_2-1" class="toc-item level3">2.1. 基本使用</a>
  <a href="#id_2-2" class="toc-item level3">2.2. 注意点</a>
  <a href="#id_2-3" class="toc-item level3">2.3. 组件的嵌套</a>
  <a href="#id_2-4" class="toc-item level3">2.4. VueComponent</a>
  <a href="#id_2-5" class="toc-item level3">2.5. VueComponent 与 Vue 的关系</a>
  <a href="#id_3-0" class="toc-item level2">3. 单文件组件</a>
</div>
<style>
  .level3 { margin-left: 2em; }
  .level4 { margin-left: 4em; }
  .level5 { margin-left: 6em; }
</style>
<script>
   // 点击 link 后,
   //    当前 link 元素,添加 active 样式类,
   //    其他 link 元素,删除 active 样式类
   document.querySelector('.toc').addEventListener('click', (event) => {
     const { target } = event;
     
     if (!target.classList.contains('toc-item')) {
       return;
     }

      const tocLinks = target.parentElement.children();

      for (let i = 0, len = tocLinks.length; i < len; i++) {
         tocLinks[i].classList.remove('active');
      }

      target.classList.add('active');
   });
</script>

# 3. 页面滚动,目录同步

document.addEventListener('scroll', () => {
  // 找到视口顶部的 header 元素(<h2>/<h3>/<hn>)
  const firstHeader = getFirstHeaderInViewport();

  // 找到对应的目录链接
  const tocLink = getTocLinkByHeaderId(firstHeader.id);
  
  // 让目录中对应的链接高亮
  highlightTocLink(tocLink);

  // 目录中的高亮链接滚动到容器的中间
  scrollToCenterOfParent(tocLink);
});


function getFirstHeaderInViewport() {
   const headerElements = document.querySelectorAll('.page > h2, .page > h3');
   let topHeaderElement = null;

   for (let i = 0, len = headerElements.length; i < len; i++) {
      const headerElement = headerElements[i];

      const topInViewport = headerElement.getBoundingClientRect().top;

      if (topInViewport > 0 && topInViewport < 40) {
         topHeaderElement = headerElement;
         break;
      }
   }

   return topHeaderElement;
}

function getTocLinkByHeaderId(id) {
  return document.querySelector(`.toc > [href="#${id}"]`);
}

function highlightTocLink(tocLink) {
   const tocLinks = tocLink.parentElement.children();
   
   for (let i = 0, len = tocLinks.length; i < len; i++) {
      tocLinks[i].classList.remove('active');
   }
   
   tocLink.classList.add('active');
}

function scrollToCenterOfParent(target) {
   const parent = target.parentElement;

   // 元素的高度
   const parentHeight = parent.clientHeight;

   // 滚动条在 y 轴移动的距离
   const parentScrollTop = parent.scrollTop;

   // 元素 与 父元素顶部 的距离
   const targetOffsetTop = target.offsetTop;

   // tocItem 垂直居中需要 toc 滚动的高度
   const parentScrollY = targetOffsetTop - parentHeight / 2;

   parent.scrollTo(0, parentScrollY);
}
本章目录