给博客增加一个微小的目录生成器

文章目录

前言

目录对于一篇文章来说其实是蛮重要的,比如全文内容预览或者检查文章结构、标题包含关系等,然而 Ghost 的 Markdown 并不直接支持 TOC 生成语法,因为不想通过加载别的文件来完成这个小功能,就自己尝试实现了一个比较简单的 TOC Generator

需求分析

操作流程

稍微思考一下,最基本的操作流程还是比较简单的:

  1. 选取出需要生成目录的 heading
  2. 给这些 heading 设置锚点
  3. 生成目录

当然只要开始写就会发现其实这是一个越来越大的坑

拓展需求
  • 支持点击收放
    有些情况下目录区块可能略长,为了不影响体验可以对目录块增加一个对点击事件的响应,进行收放操作

  • 支持占位符 [TOC]
    为了方便使用我打算把代码直接加到 Ghost 的文章模板代码里,但是有些文章其实是不需要目录的,所以就通过判断是否有占位符 [TOC] 来确定是否执行生成目录的代码

基本框架

生成目录的操作相对其他部分来说会复杂一些,就先不管了,首先确定一下操作的基本框架代码,对 Ghost 来说,文章主体内容是在 body > div.site-wrapper > main > article > section.post-content 里,因为 post-content 是一个单独的 class,就直接用它来当做文章内容的选择器了:

var TOC = '<div class="toc">Table Of Contents';

var elements = $(".post-content").find(":header");  
$.each(elements, function(key, content) {
    content.id = content.innerText;
}
TOC += 'Generated HTML Code Of TOC'

TOC += '</div>';

$(".post-content>p:first").before(TOC);

其中的 $.each() 里进行的操作是设置锚点,也就是把一个元素的内容作为它的 id 值,这样就可以通过 #id 的方式直接定位到对应位置了

目录生成

结构当然是使用 HTML 的 <ul><li>,这部分的具体实现其实考虑了一段时间,中间有一些方式是有些问题的,这里就不讲了,最后的决定是使用栈来判断当前状态,然后对应确定所需增加的代码段内容,详细实现方式在下面展开讲

目录区块结构

一般来说,目录是由 6 个 HTML 的 heading 组成的,那么很自然地考虑是不是直接分六级,然后按照顺序一一填入,但是这样会带来一个问题:如果两个不相邻的 heading 直接作为父子元素的话,会导致它们中间的间隔较大,生成的目录也就会有些别扭

比如我平时写文章常用的就是 <h3><h5>,偶尔有需要时会使用 <h6>,主要原因还是字体大小产生的层级分别比较明显一些,看起来也会舒服点hhh

按照上面提到的方式生成目录的话,由 <h3> 作为开头的问题比较容易解决,只是把原本 <h1> 的位置改成它,但是中间间隔了没有用到的 <h4>,这个间隔要去掉的话就需要对实现的代码做一些更改了,而且要保证在不同情况下都能正常生成,如果要我拿这个方案实现的话……可能我的想法会是先遍历一遍所有 heading 然后提前设置好分级的数量再重新遍历一遍填入

但是很快我就想到一个看起来可能更好一些的想法,这个想法的实现有一个前提,那就是需要保证目录的结构是比较规范的,而对于这个“规范”,我的理解是这样的:

  • 第一个出现的 heading 是文中大小最大的
  • 文章所有 heading 的嵌套符合先大后小的关系

一种比较特殊的情况是 h1 > h2 > h5 > h1 > h3,也就是 h3 这个在第一组目录里面没有出现的大小出现在了后面,我的想法是把它仍然当做 h1 的子元素,也就是最后生成的时候它和前面的 h2 是同一层的,实际上我觉得这种结构的出现是不太应该的……整个文章的目录结构应该比较统一,比如下面这样都比较符合我所理解的规范:

h1 > h2 > h3 > h5 > h1 > h2 > h3  
h1 > h2 > h5 > h1 > h2  
h1 > h2 > h1 > h2 > h5  

简单来说,就是如果把一个最大标题包含的所有子标题作为一“组”标题来看的话,后出现的组就不应该有((比[前面组存在的标题]更高一级或几级) 而又 (不存在于前面组))的标题(表达无力还望见谅[捂脸])

上面这些情况虽然不是很规范但是还是可以生成的,不过下面这些情况就不能存在了:

h2 > h3 > h5 > h1 > h2  
h2 > h5 > h3  

这个直接越级会导致状态判断混乱的,实际的文章里也不应该出现这种结构吧

你可以在 Demo 页面 尝试设计标题的结构,如果不满足前面提到的“规范”条件的话,会有弹窗提醒,也许能帮助理解这个所谓的 目录结构规范 到底是什么意思

状态分析

讨论了这么久目录结构,确定好想法以后就可以考虑怎么写代码了,其实操作就是分三个状态分别进行处理:

  • 同级标题(eg. h3 > h3):直接增加一个元素
  • 进级标题(eg. h3 > h5):缩进一级作为子元素
  • 退级标题(eg. h5 > h3):闭合最近的一次缩进,并增加一个元素

当然,一次完整的缩进实际上就是增加一对新 <ul></ul> 标签,增加元素就是直接增加一对 <li></li>

要判断状态的话,很明显需要一个值用来存储之前的状态,并在操作之后更新至本次的状态,而它的初始值自然就是第一个 heading 的大小了,判断状态的话可以直接获取当前 heading 的 nodeName 然后通过字符串的 localeCompare() 方法进行对比:

// Warning: This Version Contains Bug
var TOC = '<div class="toc">Table Of Contents<ul>';

var elements = $(".post-content").children(":header");  
var currHeading = elements[0].nodeName;

$.each(elements, function(key, content) {
    var text = content.innerText;
    var link = '<a href="#' + text + '">' + text  + '</a>';
    content.id = text;

    switch (currHeading.localeCompare(content.nodeName)) {
        case 0:
            TOC += '</li><li>' + link;
            break;

        case 1:
            TOC += '</li></ul><li>' + link;
            currHeading = content.nodeName;
            break;

        case -1:
            TOC += '<ul><li>' + link;
            currHeading = content.nodeName;
            break;
    }
});

TOC += '</ul></div>';

$(".post-content>p:first").before(TOC);
Debug
多层退级

这个版本的代码有一个 bug,可以看到在标题退级的时候它是直接闭合最近一次的缩进,但是并不是退级就一定是只退一级,考虑一下这种情况:h1 > h3 > h5 > h1 > h3,它就在第二组直接退了两级回到 h1

那怎么解决这个问题呢,很容易发现缩进、闭合缩进的过程其实和栈操作的入栈、出栈非常像,也就自然会想到用栈来完成它的实现,在标题退级时重复闭合缩进,直到栈顶元素和当前元素相同,把 switch 的片段改成这样:

var records = new Array();  
switch (currHeading.localeCompare(content.nodeName)) {  
    case 0:
        TOC += '</li><li>' + link;
        break;

    case 1:
        currHeading = content.nodeName;
        while (records.includes(currHeading)) {
            TOC += '</li></ul>';
            records.pop();
        }
        TOC += '<li>' + link;
        break;

    case -1:
        TOC += '<ul><li>' + link;
        records.push(currHeading);
        currHeading = content.nodeName;
        break;
}
heading 过滤

其实并不是页面中的所有 heading 都应该被选择到,比如引用区块 <blockquote> 里的就应该过滤掉,这个用 jQuery 解决就很方便了,它直接提供了一个 filter 方法:

var elements =  
    $(".post-content").find(":header")
    .filter(":not(blockquote :header)");

至于不用 jQuery 怎么实现会在后面提及

测试页面

为了方便多做一些测试,直接写了一个测试页面,基本的代码就没必要贴了,只是根据选择的 heading 给区块增加一个对应大小的标题,非要说值得讲的东西,有以下两点

  1. 结构限制:点击生成目录的时候一般都不会考虑到结构规范的问题,所以要对增加元素的操作加个限制,原理和生成目录是一样的,也是用一个栈来判断状态,然后确定当前操作是否符合规范

  2. 随机字符串:为了更好区分各个区块,可以用一串定长的随机字符串作为标题元素的文本,直接用 JS 代码 Math.random().toString(36).substr(2, 11) 生成就可以了

实现拓展需求

响应点击事件

要实现对点击事件的响应并不难,这里主要的问题还是怎么样转变收和放两个状态,在写完 link-boxes 之后就一直觉得样式的更改应该尽可能用 CSS 而不是 JS 来完成,所以就通过两个不同的 class 的转换实现了这个需求,代码还是不放上来了,如果有兴趣的话欢迎直接到 Repo 上查看,这个实现主要关联到两个文件:assets/toc.csstoc-generator.js

觉得有必要提的一个是,为了保证用不用 jQuery 的版本效果都一样,在收放的时候就没有用 show(t)hide(t) 函数,而是用 CSS 的 transition 配合 class 更改完成的:

.toc-content {
    overflow: hidden;
    transition: all .7s;
}
.toc-off .toc-content {
    margin: 0;
    height: 0;
    opacity: 0;
}

toc-off 这个 class 改变的时候,toc-content 就会在 0.7s 内完成下面那些值的过渡,也就基本是出现和消失效果了,虽然这样的做法和 jQuery 的那两个函数还是有差距,但是我也想不到别的更好的实现方式,能用这几行代码解决掉收放效果的问题已经很满足了 hhh

[TOC] 占位符

考虑到不能对页面内容有太大影响,只对文章内容的第一个段落做判断,如果它的文本正好是 [TOC] 的话,就把它删掉然后在这个位置生成目录,这样如果没有写这个占位符的话就不会生成目录了,代码改变也比较简单:

if ((wrapper.length == 1) &&  
    ($(config.contentWrapper + ">:first-child").text() == '[TOC]')) {
    $(config.contentWrapper + ">:first-child").remove();
    // Other TOC Generator Code
}

原生 JS 实现

因为代码里面其实用到 jQuery 的部分并不多,感觉加载整个文件实在太浪费了,后来就尝试了一下用原生 JavaScript 重写

首先……2333 在做测试页面的时候并没有一开始就写这个版本的代码,所以就临时写了个加载 BootCDN 的 jQuery 文件的函数:

function loadJQuery() {  
    var ele = document.createElement("script");
    ele.type = 'text/javascript';
    ele.src = 'https://cdn.bootcss.com/jquery/3.2.1/jquery.min.js';
    document.getElementsByTagName("head")[0].appendChild(ele);
}

不直接用 HTML 加载是因为当时我在网上找一堆别人的页面测试的时候发现有些网站确实是没有加载 jQuery 的,那要强行给它们加上的话,当然是直接通过控制台添加最方便了hhh

好了这是题外话,重写的话其实工作量也不大,代码文件在 这里,略值得提及的可能也就这种:

$.each(elements, function(key, content) {});
$(config.contentWrapper + ">:first-child").before(TOC);

对应改成:

elements.forEach(function(content) {});  
document.querySelector(config.contentWrapper)  
.children[0].insertAdjacentHTML('beforebegin', TOC);

其他的更改都非常简单,只有一个 heading 的选择器有点麻烦,因为需要把 blockquote 里的 heading 过滤掉,前面有提到 jQuery 提供了 filter 方法,但是原生的 JS 是不支持的,而 querySelectorAll 选择器获得的 NodeList 又不能直接进行过滤,我查了蛮久资料,最后虽然实现了同样的功能,但是……代码有点乱,而且整个实现看起来是有点糟糕的 2333:

var wrapper = document.querySelectorAll(config.contentWrapper);

var elements = Array.prototype.filter.call(  
    wrapper[0].querySelectorAll("h1,h2,h3,h4,h5,h6"),
    function(ele) {
        var result = true;

        document.querySelectorAll("blockquote")
        .forEach(function(bq) {
            bq.querySelectorAll("h1,h2,h3,h4,h5,h6")
            .forEach(function(v) {
                if (ele == v) result = false;
            });
        });

        return result;
    }
);

本来如果是 querySelector 的话还会稍微简单一点,但是要保证不漏掉的话就得用 querySelectorAll 了,然后要分别对它的各个子元素进行操作,也就是需要调用 forEach 方法

还是稍微解释一下这段代码吧……先看个简单的例子:

a = [1, 2, 3];  
Array.prototype.filter.call(  
    a, function(e) {
        return e != 2;
    }
);

这段代码的执行结果是 [1, 3],所以作用就比较容易理解了,就是根据函数返回的布尔值真假决定第一个参数对应元素的取舍,再看回之前那份代码,wrapper[0].querySelectorAll("h1,h2,h3,h4,h5,h6") 选出了文章内所有的 heading,然后后面的函数选出 blockquote 内的所有 heading,如果发现有相同的元素则进行过滤

说这个实现比较糟糕是因为它对每一个 heading 元素进行对比过滤时,都会重新遍历一遍所有引用区块内的所有 heading,这个冗余很大了,但是我又没找到别的解决方案,所以就将就着用了,反正其实一般也感觉不到什么区别 至少功能是没问题了

部署到博客

意料之中的吔屎,不过还好也都是些小问题,没有耗费太多时间

加载问题

这个不能像 link-boxes 一样只给需要用到的文章直接加上代码,目录生成的需求还是比较频繁的,所以就到服务器去改模板文件 post.hbs 了,那要加载代码的话,对于这种不是单文件的东西直接插入代码肯定是不现实的,何况还有两张小图标,不过还好我有配置子域名可以拿来放一些自己的文件,所以就[捂脸]强行解决了,改好之后重启一下 Ghost 就行,顺便把 link-boxes 的文件也都放到子域名下方便加载了

jQuery 不能用

我本来是把带 jQuery 的版本作为主版本的,因为 Ghost 本身也有用到它,但是改完模板文件才发现这个文件的加载在另一个模板里,而那个模板是把 post.hbs 放在中间部分也就是加载 jQuery 之前的,这就导致我得把 JS 文件改成不带 jQuery 的版本了

还好两个版本都有写23333,要不然这个时候发现没法用就真的彻底吔屎了

改完之后也就把原生 JS 实现的版本换成主版本了,就像前面说的,为了一点点功能加载一个不算小的文件没有必要

最后

哇本来打算提取出来更新一个小版本的,没想到竟然也改了这么多代码hhh,最后放到博客上看到一切正常还是特别开心的,然后花了点时间把 Demo 页面 也放到了服务器上,jQuery 版本的 Demo 在 这里

和前面的那个链接卡片盒一样,代码原来是一起在一个 Repo 里的,后来为了方便维护还是单独拿出来了:TOC-Generator