likes
comments
collection
share

和我一起一步步写个页面通用的toc目录组件

作者站长头像
站长
· 阅读数 20

和我一起一步步写个页面通用的toc目录组件

大家好呀,最近在开发我的个人博客模版InkBlogger - vue3+ts+vite时,为了更方便浏览长篇的博客,我参照一直在用的云笔记平台wolai的目录样式,自己构思了一款轻量级的目录组件,它包含以下功能:

  • 自动检测网页html,生成目录树
  • 鼠标移入,即hover样式
  • 监听浏览进度,自动切换在看标题
  • 点击后平滑跳转至所选内容
  • 响应式设计,即屏幕较小时自动移出屏幕
  • ... 持续更新中,需要可关注我的全球同性交友号😀

此外,他还提供了应对使用诸如element-plusel-scrollbar 这样的替代性滚动组件,而导致原生滚动事件无法监听的解决方案。

话不多说,步骤娓娓道,请君倾耳听👂


数据获取

标题元素

使用document.querySelectorAll,我们检测所有可能的h* 类标签

// toc组件 - 标题元素集合
const headElems = ref<NodeListOf<HTMLElement> | any>();

// 元素获取,这里为了防止页面文章内容外其它标题元素干扰,可在选择器前加入限制类名
headElems.value = Array.from(document.querySelectorAll(".post-body h2,h3,h4"));

因为我使用的Vue3的setup钩子,他的代码主要在create周期运行,而博客文章由于所占内存可能较大,一般会采用异步导入 - import('filename') 的模式,因此我们将标题获取这一步放在onMounted钩子中进行,实测无缝出现,不影响页面浏览体验哈

// toc组件 - 标题元素集合
const headElems = ref<NodeListOf<HTMLElement> | any>();

// toc操作都得等dom渲染完
onMounted(() => {
  headElems.value = Array.from(document.querySelectorAll(".post-body h2,h3,h4"));
  ...
}

文档实时滚动高度

明眼人一看,就知道我们要请出dombom的api了,比如监听window - scroll事件,实时获取。

但我的博客在一开始为了滚动栏的好康,使用了element-plusel-scrollbar 这一组件(根组件App.vue中),这使得我们在嵌套的子组件内无法监听原生的滚动事件

// 监听浏览器滚动事件
 window.addEventListener("scroll", function () {
   scrollTop.value = window.pageYOffset;
 });

但替代组件也提供了滚动事件的响应接口,于是乎,我们使用vue的依赖注入provideinject,将滚动高度包裹在响应式对象中向下传递,这样代码量很小

// 父组件
    <el-scrollbar  @scroll="onScroll">

  let curScrollTop = ref<number>(0);
  // provide() can only be used inside setup().
  provide("scrollTop", curScrollTop);
  
  // 监听滚动事件,
  function onScroll({ scrollTop }: { scrollTop: number }): void {
    curScrollTop.value = scrollTop;
  }
  
// 子组件
  // 获取文档滚动实时高度
  let scrollTop: Ref<number>;
  // 不可用原生 window api 则使用 inject 获取
  scrollTop = inject("scrollTop") as Ref<number>;


方法

点击滚动

为了最基础的功能 - 点击目录项跳转,我们需要使用dom元素原生的滚动方法scrollIntoView,它接受一个对象参数,以配置滚动行为

// smooth就是滚动过去,而不是直接跳转,center就是滚动到视口的中点
element.scrollIntoView({ behavior: 'smooth', block: 'center' })

届时,我们会将该方法赋给目录项的点击事件

监听滚动

既然有了实时的滚动高度,我们就可以watch它,来判断当前浏览的是哪一个章节

不过要注意的是,为了保持作用域正确,因为之前数据是mounted之后获取的,我们也在mounted之后监听

watch(scrollTop, (newVal) => {...})

啊,等等,在开始监听之前,我们要想想滚动高度和页面当前浏览的标题之间的关系

目录响应滚动思路

  • 滚动高度scrollTop + 标题与屏幕上部的距离ClientRect = 标题相对文档顶部的距离relativeHeight
  • 当文档开始滚动,标题滚动到我们设置的在屏幕中的某个高度,即监测点point时,我们就判定当前已浏览到此章节,并为目录中对应项附上样式
    // 滚动高度 + 视口高度/2 = 监测点
    // 因为之前设置的是center,点击后将滚动到屏幕中点
    // 所以这里检测点高度这样设置,同理,你滚动到屏幕哪,你就加多少
    const point = Math.floor(
          scrollTop + document.documentElement.clientHeight / 2
        );
    
    
  • 接着我们记录下所有标题相对文档的高度relativeHeightArr,然后监听窗口滚动,当监测点高度 = 数组中某个高度时,就是有个标题滚到那了,得更新目录样式了
  • 啊,想到这里,我觉得自己已经可以了,然后写完发现有时候滚得到,有时候滚不到,突然想到之前调试滚动时,每次滚动高度和之前的差值是不一样的,不信你看看
    watch(scrollTop, (newVal, oldVal) => {
        console.log("滚动响应差", newVal - oldVal);
    

和我一起一步步写个页面通用的toc目录组件

看到了吧,滚动事件的触发,是根据你单次滚动的力度来的,越用力越快,差值越高,那么我们记录的标题高度可能直接被忽略了,也就检测不到

  • 于是乎,我们就要把每次检测一点升级成每次检测一段,没更新一次滚动高度,我们就去数组里查,看他在哪个区间,并返回该区间的左值,也就是我们要的标题序号,这样就能保证目录里始终有被选中的标题啦,而且阅读标题以下,即其所在章节,下一个标题之前,标题选中状态也不会变哦。
  • 如果点击目录,直接命中数组中元素,我们就直接返回它的序号
  • 是不是想到什么,没错,这就是面试常考的 数组插入位置,算法是有用滴!

算法:数组插入位置

我知道你会,但是为了节约你的时间,我就直接贴上来了哈

因为querySelectorAll是顺序遍历,我们的数组relativeHeightArr天然有序,就不用处理啦,使用二分查找是因为效率最高

/**
 * 二分查找,没找到就返回插入位置左侧的索引
 * @param {number[]} nums
 * @param {number} target
 * @return {number}
 */
export function searchInsert(nums: number[], target: number): number {
  let left = 0;
  let right = nums.length - 1;

  while (left <= right) {
    let mid = Math.floor((left + right) / 2);
    if (nums[mid] === target) {
      return mid;
    } else if (nums[mid] > target) {
      right = mid - 1;
    } else {
      left = mid + 1;
    }
  }

  return right;
}

闭包记录前标题

因为我们的滚动行为是不可预测的,而点亮当前标题之前得取消上一个标题,所以我们得使用闭包在监测函数外面记录一下上一个标题的序号,然后在监测函数里使用和改变它

  // 上一个被点亮的toc
  let lastIndex: number;

Script全貌

叭叭这么多,就不再卖关子了哈哈,所有细节都在代码里了!

import { inject, onMounted, Ref, ref, watch } from "vue";
import { searchInsert } from "../../utils/index";

// toc组件 - 标题元素集合
const headElems = ref<NodeListOf<HTMLElement> | any>();

// 获取文档滚动实时高度
let scrollTop: Ref<number>;
// 监听浏览器滚动事件
// window.addEventListener("scroll", function () {
//   scrollTop.value = window.pageYOffset;
// });
// 不可用原生 window api 则使用 inject 获取
scrollTop = inject("scrollTop") as Ref<number>;

// toc操作都得等dom渲染完
onMounted(() => {
  // querySelectorAll 返回的是类型NodeList
  // NodeList是类数组,不具有某些数组方法如 map,为了非要用map我转成数组,别学我,有自带的.foreach
  headElems.value = Array.from(
    document.querySelectorAll(".post-body h2,h3,h4")
  );
  // console.log(headElems.value instanceof Array); // false,惊了,是类数组

  // 元素相对文档高度  = elem.getBoundingClientRect() + 当前页面滚动
  // 初始时页面滚动为0
  const relativeHeightArr = headElems.value.map(
    (ele: HTMLElement, index: number) => {
      return Math.floor(ele.getBoundingClientRect().top);
    }
  );

  // 上一个被点亮的toc
  let lastIndex: number;

  watch(scrollTop, (newVal) => {
    // watch的回调参数会自动解包
    // 滚动高度 + 视口高度/2 = 监测点
    const point = Math.floor(
      newVal + document.documentElement.clientHeight / 2
    );
    
    // 使用二分查找判断包含监测点的标题序号
    const curIndex = searchInsert(relativeHeightArr, point);
    
    // 判断亮点是否切换
    if (lastIndex !== curIndex) {
      // 取消上一个点亮
      // 这里标题的id,可以在模板里给加上,easy的
      document
        .querySelector(`#toc-${lastIndex}`)
        ?.classList.toggle("toc-choosen");

      // 点亮当前
      document
        .querySelector(`#toc-${curIndex}`)
        ?.classList.toggle("toc-choosen");

      // 更新前标题
      lastIndex = curIndex;
    }
  });
});

html结构

  <!-- toc组件 -->
  <div class="toc remove">
    <ul>
      <!-- 为了防止标题内容一致,给每个标题加上唯一的id -->
      <!-- 为了设置各级标题的不同样式,添加了类,h1标签类为item-1,h2标签类为item-2 -->
      <li
        v-for="(item, index) in headElems"
        :id="`toc-${index}`"
        :class="`item-${item.tagName.charAt(1)}`"
        @click="item.scrollIntoView({ behavior: 'smooth', block: 'center' })"
      >
        {{ item.innerText }}
      </li>
    </ul>
  </div>

呐,html就是酱紫简单


外观与动效

我用的是sass哈,这样嵌套写很方便

.toc {
  position: fixed;
  transition: all 0.3s ease;
  top: 200px;
  left: 20px;
  border-left: 3px solid #f0e7e7;
  cursor: pointer;
  color: rgba(3, 21, 34, 0.644);

  ul {
    li {
      box-sizing: border-box;
      list-style: none;
      width: 200px;
      overflow: hidden;
      white-space: nowrap;
      text-overflow: ellipsis;
      background: transparent;
      transition: all 0.5s ease;
      padding: 2px 0px;
      border-left: 3px solid transparent;
      transform: translateX(-3px);
    }

    li:hover {
      background-color: #ffebeb;
      border-left: 3px solid #cf5659;
    }

    .toc-choosen {
      background-color: rgba(27, 31, 35, 0.1);
      border-left: 3px solid #cf5659;
      color: #476582;
    }

    .item-2 {
      font-weight: 600;
      padding-left: 13px;
    }
    .item-3 {
      padding-left: 23px;
      opacity: 0.95;
    }
    .item-4 {
      padding-left: 33px;
      opacity: 0.9;
    }
  }
}

// 目录消失
@media (max-width: 1245px) {
  .remove {
    transform: translateX(-250px);
  }
}


总结

谢谢你耐心看到最后,其实最难也就监听滚动那,不过看完会发现其实也很简单对吧,哈哈

这个组件我当前只是在自己的项目里用用,但是我打算把他做大做强!做成一个浏览器插件,让每一篇文章得到懂他的伴侣!

计划特性:

  • 选择器接口 - 不同页面为了排除无关标题元素干扰,可由用户提供CSS选择器,限制查找的区域
  • 移入 - 现在可以根据媒体查询,让目录在空间不够的情况下移出屏幕,但是还没有点击移入,可做!
  • 折叠 - 参照wolai,长文目录多且嵌套深,可在标题前加三角,使点击折叠目录
  • 拖拽移动 - 现在目录固定出现在屏幕左侧,github的md阅读是右侧空间更大,所以为了应对这种情况,目录需要可拖拽自行固定
  • 性能 - 频繁触发,所以节流防抖
  • ... 由你补充!

未来如果有补充,我会继续更新文章哒,喜欢就点个关注吧,哈哈

转载自:https://juejin.cn/post/7201062402955378748
评论
请登录