仿掘金项目,文章目录生成📜 | 青训营笔记

585 阅读3分钟

这是我参与「第四届青训营 」笔记创作活动的的第5天

一 前言

对于文章目录的生成问题,我们可以将文章里的标题标签依次获取到,生成类似'树'一样的数据结构,再对其进行深度优先遍历,便可以得到如下图所示的目录结构。

Snipaste_2022-08-17_13-12-15.png

(本项目基于Vue2开发,相关代码呈现的语法为Vue)

仿掘金项目github地址 (github.com/IamTrust/ju…)

二 实现过程

2.1 数据源设置

data() {
    return {
        titles: [],     // 目录列表
        titlesLen: null,// 目录列表长度(可能有些文章没有目录)
        currentTitle: 0,// 目录高亮部分样式标记
    }
},

2.2 生成目录结构

首先要获取到文章的DOM结构,将里边的所有标签获取并放入 elements 数组,对 elements 数组去重之后,我们需要知道这篇文章里有哪些标题标签(常见的 h1 h2 h3 标签),整理后放入 levels 数组

之后遍历 elements 数组,对比遍历到的数组元素是不是标题标签,如是,在其上面添加相关属性,如标题等级标题名称标题的父节点标题的子节点等等。

之后便可以push进 titles 数组,如果不是第一个标题,在此之前还需要将其与 titles 数组中最后一个元素进行对比,情况又分为:遇到子标题遇到父标题遇到平级标题

一切操作完毕,我们便可将 titles 数组返回出去到页面上循环使用。

// 获取文章目录结构(形参为文章的DOM节点)
getTitles(article) {
    let titles = [];                      // 存放文章目录结构
    let levels = ["h1", "h2", "h3"];      // 标题标签

    let articleElement = article;
    if (!articleElement) return titles;

    let elements = Array.from(articleElement.querySelectorAll("*"));// 获取文章的中所用标签
    let tagNames = new Set(elements.map((el) => el.tagName.toLowerCase()));// 标签去重

    for (let i = levels.length - 1; i >= 0; i--) {    // 将不存在的标题标签移除
        if (!tagNames.has(levels[i])) levels.splice(i, 1);
    }

    let titleId = 0;
    for (let i = 0; i < elements.length; i++) {
        const element = elements[i];
        let tagName = element.tagName.toLowerCase();
        let level = levels.indexOf(tagName);  // 标题标签在 levels 中的索引值就代表标签的等级
        if (level == -1) continue;// 不是标题标签不做处理,像 <p> <img> 等等


        let id = tagName + "-" + element.innerText + "-" + i;
        let node = {
            id,
            level,
            parent: null,
            children: [],
            rawName: element.innerText,
            scrollTop: element.offsetTop,  // 文章页面滚动时,根据 scrollTop 高度判断当前为哪个标题
            currentTitleId: titleId++
        };

        if (titles.length > 0) {
            let lastNode = titles.at(-1); // 取 titles 数组最后一个元素

            // 遇到子标题(设置为子节点)
            if (lastNode.level < node.level) {
                node.parent = lastNode;
                lastNode.children.push(node);
            }
            // 遇到上一级标题(找到他的父节点)
            else if (lastNode.level > node.level) {
                let parent = lastNode.parent;
                while (parent) {
                    if (parent.level < node.level) {
                        parent.children.push(node);
                        node.parent = parent;
                        break;
                    }
                    parent = parent.parent;
                }
            }
            // 遇到平级(设置为兄弟节点)
            else if (lastNode.parent) {
                node.parent = lastNode.parent;
                lastNode.parent.children.push(node);
            }
        }

        node.isVisible = node.parent == null;
        titles.push(node);
    }
    this.titlesLen = titles.length;
    return titles;
},

2.3 设置文章页面滚动监听

设置滚动监听相对简单,只需要每次滚动时重新计算当前位于哪个标题的区域,通过(item.scrollTop <= window.scrollY)来判断,来滚动目录。

 // 设置文章的滚动事件,目录响应式滚动
    setStickyBox(){
        if(!this.timer){    //节流设置,防止滚轮快速滚动,造成多余的计算
            this.timer = true;
            let that = this;
            let num = 0;
            setTimeout(function(){
                for (let item of that.titles)  
                    //通过 scrollTop 来查看当前文章页面处于哪个标题区域
                    if (item.scrollTop <= window.scrollY)  num++;
                // currentTitle 用于动态绑定高亮'样式'
                that.currentTitle = num >= that.titlesLen ? that.titlesLen-1 : num;
                //对应的目录也进行响应式的滚动
                that.$refs.stickyContentBox.scrollTo({ top: num * 44 - 20, behavior: "smooth" });
                that.timer = false;
            },500)
        }
    },
// 创建滚动监听
created() {
        window.addEventListener("scroll", this.setStickyBox)
}

2.4 点击目录标题,页面滚动到对应区域

通过点击目录里的标题,让文章页面滚动到对应的标题区域。在目录里的每个标题上绑定鼠标点击事件,并且传递标题标题对应的 scrollTop,使用 window.scrollTo进行页面滚动。

 // 滚动到指定的位置
scrollToView(scrollTop) {
    window.scrollTo({ top: scrollTop, behavior: "smooth" });
},

<!-- 文章目录 -->
<div class="sticky-block-box activeBox" v-if="titlesLen">
    <div class="sticky-title">目录</div>
    <!-- 目录主体 -->
    <div class="sticky-content" ref="stickyContentBox">
        <ul>
            <!-- 根据当前页面标题区域,动态绑定'高亮'样式 -->
            <li :class="[currentTitle == title.currentTitleId ? 'first' : '']" v-for="title in titles" :key="title.id">
                 <!-- 设置鼠标点击事件 -->
                <div @click="scrollToView(title.scrollTop - 40)">{{ title.rawName }}</div>
            </li>
        </ul>
    </div>
</div>

至此,一个目录的基本功能都已经实现,如果像实现子标题折叠,文章当前阅读进度,请参考:如何使用 Vue3 实现文章目录功能 - 之一Yo - 博客园 (cnblogs.com)

注意事项

  1. 目录的生成,应该在文章数据已经获取到并且渲染在页面上之后再进行,属于是异步任务。
  2. 即使文章已经被渲染在页面上,但是文章里有图片,需要花时间请求,如果在此之前生成好了目录,scrollTop就没有计算上图片的高度,可能就会出现标题区域判断错误。
  3. 我的解决方案时,将目录生成函数放在一个定时器内,时间一般为0.5秒。使得文章内的图片有0.5秒的时间请求资源。
  4. 解决方案缺陷也很明显,当请求超过0.5秒,网络速度过低,依旧会导致误差,究其原因还是无法准确地获取最后一张图片完成请求的时间,友友们有解决方案可在评论区留言。

参考文章

如何使用 Vue3 实现文章目录功能 - 之一Yo - 博客园 (cnblogs.com)