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

博客 动态
0 268
羽尘
羽尘 2022-03-14 13:56:36
悬赏:0 积分 收藏

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

前言

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

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

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

目录组件

实现过程

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

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

<template>    <div  v-if="Object.keys(titles).length > 0">        <div >            <div>                <span                    ><font-awesome-icon                        :icon="['fas', 'bars-staggered']"                                        /></span>                <span>目录</span>            </div>            <span >{{ progress }}</span>        </div>        <div >            <div                v-for="title in titles"                :key="title.id"                @click="scrollToView(title.scrollTop)"                :                :                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: #;    color: white;    &:hover {        background-color: #0c82e9;        color: white;    }}</style>
posted @ 2022-03-14 10:59 之一Yo 阅读(59) 评论(0) 编辑 收藏 举报
回帖
    羽尘

    羽尘 (王者 段位)

    2335 积分 (2)粉丝 (11)源码

     

    温馨提示

    亦奇源码

    最新会员