这是我参与「第四届青训营 」笔记创作活动的的第5天
一 前言
对于文章目录的生成问题,我们可以将文章里的标题标签依次获取到,生成类似'树'一样的数据结构,再对其进行深度优先遍历,便可以得到如下图所示的目录结构。
(本项目基于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)
注意事项
- 目录的生成,应该在文章数据已经获取到并且渲染在页面上之后再进行,属于是异步任务。
- 即使文章已经被渲染在页面上,但是文章里有图片,需要花时间请求,如果在此之前生成好了目录,
scrollTop就没有计算上图片的高度,可能就会出现标题区域判断错误。 - 我的解决方案时,将目录生成函数放在一个定时器内,时间一般为0.5秒。使得文章内的图片有0.5秒的时间请求资源。
- 解决方案缺陷也很明显,当请求超过0.5秒,网络速度过低,依旧会导致误差,究其原因还是无法准确地获取最后一张图片完成请求的时间,友友们有解决方案可在评论区留言。