如何使用 Vue3 实现文章目录功能

虚幻大学 xuhss 469℃ 0评论

Python微信订餐小程序课程视频

https://edu.csdn.net/course/detail/36074

Python实战量化交易理财系统

https://edu.csdn.net/course/detail/35475

前言

这一段时间一直在做一个博客项目 Kila Kila Blog,找了一圈发现没有特别满足自己需求的目录组件,所以决定自己动手,完成一个满足以下预期目标的目录组件:

  • 自动高亮选中当前正在阅读的章节
  • 自动展开当前正在阅读的章节的子标题,并隐藏其他章节的子标题
  • 显示阅读进度

完成后的目录组件如下图左侧所示:

14834aa6e47b311d78cc02d8ed79d5e2 - 如何使用 Vue3 实现文章目录功能

实现过程

由于标题之间有父子的关系,所以我们应该用树数据结构来解决这个问题。我们遍历文章容器中的所有标签,如果遇到 ### 这类标签,就创建一个节点,将其放到列表中,之后使用 v-for 指令来生成目录就行了。下面分析一下每个节点需要有哪些属性。

一个树的节点,应该具有的属性包括:父节点的指针 parent、子节点的指针列表 children,因为一个节点代表一个标题,所以还要包含:标题的 ID号 id(用于 v-forkey),标题名 name(添加了标题的序号)、原始标题名 rawName,当我们点击标题时,应该滚动到标题的位置,所以还要有 scrollTop 属性。在我们遍历文章容器中的所有标签时,需要判断当前遇到的标签和上一个标签之间的父子关系,所以要有一个 level 属性代表每一个节点的等级。下面是具体实现代码:

复制<template>
    <div class="catalog-card" v-if="Object.keys(titles).length > 0">
        <div class="catalog-card-header">
            <div>
                <span
 ><font-awesome-icon
 :icon="['fas', 'bars-staggered']"
 class="catalog-icon"
 />span>
                <span>目录span>
            div>
            <span class="progress">{{ progress }}span>
        div>

        <div class="catalog-content">
            <div
 v-for="title in titles"
 :key="title.id"
 @click="scrollToView(title.scrollTop)"
 :class="[
 'catalog-item',
 currentTitle.id == title.id ? 'active' : 'not-active',
 ]"
 :style="{ marginLeft: title.level * 20 + 'px' }"
 v-show="title.isVisible"
 :title="title.rawName"
 >
                {{ title.name }}
            div>
        div>
    div>
template>

<script>
import { reactive, ref } from "vue";

export default {
 name: "KilaKilaCatalog",
 setup(props) {
 let titles = reactive(getTitles());
 let currentTitle = reactive({});
 let progress = ref(0);

 // 获取目录的标题
 function getTitles() {
 let titles = [];
 let levels = ["h1", "h2", "h3"];

 let articleElement = document.querySelector(props.container);
 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 serialNumbers = levels.map(() => 0);
 for (let i = 0; i < elements.length; i++) {
 const element = elements[i];
 let tagName = element.tagName.toLowerCase();
 let level = levels.indexOf(tagName);
 if (level == -1) continue;

 let id = tagName + "-" + element.innerText + "-" + i;
 let node = {
 id,
 level,
 parent: null,
 children: [],
 rawName: element.innerText,
 scrollTop: element.offsetTop,
 };

 if (titles.length > 0) {
 let lastNode = titles.at(-1);

 // 遇到子标题
 if (lastNode.level < node.level) {
 node.parent = lastNode;
 lastNode.children.push(node);
 }
 // 遇到上一级标题
 else if (lastNode.level > node.level) {
 serialNumbers.fill(0, level + 1);
 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);
 }
 }

 serialNumbers[level] += 1;
 let serialNumber = serialNumbers.slice(0, level + 1).join(".");

 node.isVisible = node.parent == null;
 node.name = serialNumber + ". " + element.innerText;
 titles.push(node);
 }

 return titles;
 }

 // 监听滚动事件并更新样式
 window.addEventListener("scroll", function () {
 progress.value =
 parseInt(
 (window.scrollY / document.documentElement.scrollHeight) *
 100
 ) + "%";

 let visibleTitles = [];

 for (let i = titles.length - 1; i >= 0; i--) {
 const title = titles[i];
 if (title.scrollTop <= window.scrollY) {
 if (currentTitle.id === title.id) return;

 Object.assign(currentTitle, title);

 // 展开节点
 setChildrenVisible(title, true);
 visibleTitles.push(title);

 // 展开父节点
 let parent = title.parent;
 while (parent) {
 setChildrenVisible(parent, true);
 visibleTitles.push(parent);
 parent = parent.parent;
 }

 // 折叠其余节点
 for (const t of titles) {
 if (!visibleTitles.includes(t)) {
 setChildrenVisible(t, false);
 }
 }

 return;
 }
 }
 });

 // 设置子节点的可见性
 function setChildrenVisible(title, isVisible) {
 for (const child of title.children) {
 child.isVisible = isVisible;
 }
 }

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

 return { titles, currentTitle, progress, scrollToView };
 },
 props: {
 container: {
 type: String,
 default: ".post-body .article-content",
 },
 },
};
script>

<style lang="less" scoped>
.catalog-card {
 background: white;
 border-radius: 8px;
 box-shadow: 0 3px 8px 6px rgba(7, 17, 27, 0.05);
 padding: 20px 24px;
 width: 100%;
 margin-top: 25px;
 box-sizing: border-box;
}

.catalog-card-header {
 text-align: left !important;
 margin-bottom: 15px;
 display: flex;
 justify-content: space-between;
 align-items: center;
}

.catalog-icon {
 font-size: 18px;
 margin-right: 10px;
 color: dodgerblue;
}

.catalog-card-header div > span {
 font-size: 17px;
 color: #4c4948;
}

.progress {
 color: #a9a9a9;
 font-style: italic;
 font-size: 140%;
}

.catalog-content {
 max-height: calc(100vh - 120px);
 overflow: auto;
 margin-right: -24px;
 padding-right: 20px;
}

.catalog-item {
 color: #666261;
 margin: 5px 0;
 line-height: 28px;
 cursor: pointer;
 transition: all 0.2s ease-in-out;
 font-size: 14px;
 padding: 2px 6px;
 display: -webkit-box;
 overflow: hidden;
 text-overflow: ellipsis;
 -webkit-line-clamp: 1;
 -webkit-box-orient: vertical;

 &:hover {
 color: #1892ff;
 }
}

.active {
 background-color: #1892ff;
 color: white;

 &:hover {
 background-color: #0c82e9;
 color: white;
 }
}
style>

转载请注明:xuhss » 如何使用 Vue3 实现文章目录功能

喜欢 (1)

您必须 登录 才能发表评论!