日期:2021年8月11日标签:Others

自动生成文章目录 #

本篇文章讲述为博客文章添加自动生成目录的功能。

showdown主页图片

讲述功能之前,我先说下我的博客文章从编写到发布展现给读者的大致过程。我的文章都是在本地用markdown语法编写的,待文章写完后,就直接从网站管理后台上传至服务器,这时读者就可以在我的网站看到我刚发布的文章了。当读者点击某一篇文章时,浏览器会发送一个请求,请求这篇文章获得文章的信息(包括markdown内容),请求成功后,markdown会被转换成html格式(这个转换我用的是Showdown.js库完成的),有了html代码,就可以直接在页面上展示了。

自动生成目录的功能,就是分析博客文章转换后的html代码,得到各个部分的标题,用标题组成目录。

大家可以点击我博客网站的每一篇文章,查看自动生成的目录的效果。

代码实现 #

功能代码实现如下。

interface CatalogNode {
    title: string; // 目录名称
    tag: string; // 标题元素的tag,"h1", "h2", "h3", "h4", "h5", "h6",表示该标题是几级标题
    childs: CatalogNode[]; // 该目录下的子目录
    id: string; // 标题元素的id属性值,用于跳转到文章指定标题位置
};

/**
 * 生成目录
 * @param htmlText html内容文本
 */
const generateCatalog = (htmlText: string) => {
    const tags = ["h1", "h2", "h3", "h4", "h5", "h6"];
    const temEle = document.createElement("div");
    temEle.innerHTML = htmlText;

    const cmpContainer = [];
    const catalogNodes: CatalogNode[] = [];
    const existsId: string[] = [];
    for (let i = 0; i < temEle.childNodes.length; i++) {
        const ele = temEle.childNodes[i] as HTMLElement;
        if (ele.tagName && tags.includes(ele.tagName.toLowerCase())) {
            const index1 = tags.indexOf(ele.tagName.toLowerCase());
            while (cmpContainer.length > 0) {
                const index2 = tags.indexOf(cmpContainer[cmpContainer.length - 1].tag);
                if (index1 > index2) {
                    break;
                }
                cmpContainer.pop();
            }
            let idStr = ele.id;
            if (!idStr) {
                idStr = ele.innerText.replaceAll(" ", "-");
                // 检测已添加的id
                let newIdStr = idStr;
                let IdSuffix = 0;
                while(existsId.indexOf(newIdStr) !== -1) {
                    IdSuffix++;
                    newIdStr = `${idStr}_ ${IdSuffix}`;
                }
                idStr = newIdStr;
                existsId.push(idStr);
                ele.setAttribute("id", idStr); // 使用文章名称作为element的属性
            }
            if (cmpContainer.length > 0) {
                const node: CatalogNode = {
                    title: ele.innerText,
                    tag: ele.tagName.toLowerCase(),
                    childs: [],
                    id: idStr
                };
                cmpContainer[cmpContainer.length - 1].childs.push(node);
                cmpContainer.push(node);
            } else {
                const rootNode: CatalogNode = {
                    title: ele.innerText,
                    tag: ele.tagName.toLowerCase(),
                    childs: [],
                    id: idStr
                };
                catalogNodes.push(rootNode);
                cmpContainer.push(rootNode);
            }
        }
    }
    return {catalogNodes, htmlText: temEle.innerHTML};
};

generateCatalog函数接受博客文章的原html代码字符串参数,返回catalogNodeshtmlTextcatalogNodes表示所有的目录信息(html源码中标题信息),其中childs表示一个目录下的子目录。返回的htmlText(后面称newHtmlText)是与catalogNodes相对应的新的博客文章的html代码,newHtmlText中所有的标题都添加了指定id属性值,这个id与catalogNodes中对应的标题的id字段值相同,这样就可以通过在url后接hash字段跳转到指定的标题位置。

接下来,将newHtmlText作为文章的渲染代码,通过catalogNodes生成目录。

下面的代码是react代码,接受catalogNodes,渲染目录列表。

const renderList = (catalogNodes: CatalogNode[]) => {
    if (catalogNodes.length === 0) {
        return <></>;
    }
    return (
        <ul className="list-wrap">
            {
                catalogNodes.map((node) => (
                    <React.Fragment key={node.id}>
                        <li className="list">
                            <a href={`#${node.id}`}>{node.title}</a>
                        </li>
                        {renderList(node.childs)}
                    </React.Fragment>
                ))
            }
        </ul>
    );
}

<a href={`#${node.id}`}>{node.title}</a>这句代码将目录的href设置为文章的对应标题的id值,这样可以通过点击目录跳转到指定目录位置。

(完)

目录