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


    前言

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

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

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

    实现过程

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

    一个树的节点,应该具有的属性包括:父节点的指针 parent、子节点的指针列表 children,因为一个节点代表一个标题,所以还要包含:标题的 ID号 id(用于 v-forkey),标题名 name(添加了标题的序号)、原始标题名 rawName 和标题的可见性 isVisible,当我们点击标题时,应该滚动到标题的位置,所以还要有 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: #;
        color: white;
    
        &:hover {
            background-color: #0c82e9;
            color: white;
        }
    }
    </style>
    
  • 相关阅读:
    AWS 高管外流,竟是 MongoDB “撬墙角”?
    推荐 5 个不错的 React Native UI 库
    JDK21更新特性详解
    代码随想录 Day27 贪心02中 LeetCode T55跳跃游戏
    TypeScript系列之类型 string
    03 gp 集群搭建
    产品软文怎么写?掌握这几个技巧你也能写
    始祖双碳新闻 | 2022年8月10日碳中和行业早知道
    在Ubuntu上安装并运行DeepProbLog的简单过程
    AlibabaCloud微服务:Linux 部署 Nacos 服务治理
  • 原文地址:https://www.cnblogs.com/zhiyiYo/p/16003255.html