对于一些技术类的文章,文章目录是比较需要的,于是在Aria v1.8.4加入了这一个功能。


前言

只考虑在前端对文章内容解析以构建目录,之前参考的文章是这一篇JS生成文章目录 - lxjwlt's blog
这篇文章的代码还是比较不错的,但是考虑到诸多原因,例如子目录自动开合、滚动监听等功能以及我的需求,还是感觉比较繁杂。于是我最终还是选择了使用tocbot这一项目来构建文章目录。

资源引入

<script src="https://cdnjs.cloudflare.com/ajax/libs/tocbot/4.5.0/tocbot.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tocbot/4.5.0/tocbot.css">

注意到在这个教程里,tocbot.min.js的版本必须>=4.4.4。下文会有解释

构建目录

标题锚点

需要注意的是,要使文章目录点击标题跳转生效,在文章中的h1,h2,h3,h4,h5,h6的标题必须要有id这一属性,也就是要提供一个锚点。例如:

<h1 id="TOC-build-toc">构建目录</h1>
<h2 id="TOC-test">测试</h2>
<!-- ... -->

但是既然是在typecho这个平台上写文章,那自然大部分人会优先使用Markdown语法进行书写。

# 标题1
## 标题2
### 标题3

然而对于typecho默认的Markdown解析器HyperDown,我在其Parser.php中并未找到给标题添加id这一属性的代码。
也就是说,给标题增加id属性这一工作了得自己来完成。
我在此采用的是前端的方法,后端当然也可以。


一些问题的考虑:

  • 标题id中最好不要有符号或其他特殊字符
  • 给重复的id增加一个索引号以区别

出于这些考虑,就有了如下增加id的代码

var headerEl = 'h1,h2,h3,h4',  //headers 
    content = '.post-content',//文章容器
    idArr = {};  //标题数组以确定是否增加索引id
//add #id

$(content).children(headerEl).each(function () {
    //去除空格以及多余标点
    var headerId = $(this).text().replace(/[\s|\~|`|\!|\@|\#|\$|\%|\^|\&|\*|\(|\)|\_|\+|\=|\||\|\[|\]|\{|\}|\;|\:|\"|\'|\,|\<|\.|\>|\/|\?|\:|\,|\。]/g, '');

    headerId = headerId.toLowerCase();
    if (idArr[headerId]) {
        //id已经存在
        $(this).attr('id', headerId + '-' + idArr[headerId]);
        idArr[headerId]++;
    }
    else {
        //id未存在
        idArr[headerId] = 1;
        $(this).attr('id', headerId);
    }
});

这段理解起来比较容易,在这里就不解释了。区别重复id的方法当然不止这一种,我只是选择了比较快捷简便的一种方法。

初始化

官方给出的比较简单的初始化方法:

tocbot.init({
  // 构建目录的容器
  tocSelector: '.js-toc',
  // 文章容器
  contentSelector: '.js-toc-content',
  // 需要解析的标题
  headingSelector: 'h1, h2, h3',
});

而根据我的需求,我的初始化方法如下:

var headerEl = 'h1,h2,h3,h4',  //headers 
    content = '.post-content',//文章容器
    idArr = {};  //标题数组以确定是否增加索引id
tocbot.init({
    tocSelector: '.toc',
    contentSelector: content,
    headingSelector: headerEl,
    //positionFixedSelector: '.toc',
    //positionFixedClass: 'is-position-fixed',
    //fixedSidebarOffset: 'auto',
    scrollSmooth: true,
    scrollSmoothOffset: -80,
    headingsOffset: -500
});

关于positionFixedSelector/positionFixedClass/fixedSidebarOffset/scrollSmooth/scrollSmoothOffset/headingsOffset这几个参数我会在下文进行解析。

PJAX回调

这里通过jquery-pjax进行举例:

$(document).on('pjax:send',function(){
  //destroy()方法
  if ($('.toc').length) tocbot.destroy();
})

$(document).on('pjax:complete',function(){
  //再调用一次toc.init()方法
  //例如
  toc.init(tocOptions);
})

到此,基本目录已经构建完成了(如果不嫌比较糙的话。。。)。

进阶

目录滚动跟随

与此相关的参数有positionFixedSelector/positionFixedClass/fixedSidebarOffset

positionFixedSelector - Element to add the positionFixedClass to.
positionFixedClass - Fixed position class to add to make sidebar fixed after scrolling down past the fixedSidebarOffset.
fixedSidebarOffset - fixedSidebarOffset can be any number but by default is set to auto which sets the fixedSidebarOffset to the sidebar element's offsetTop from the top of the document on init.

要实现滚动跟随的话,你可以如下配置,例如:

positionFixedSelector: '.toc',
positionFixedClass: 'is-position-fixed',
fixedSidebarOffset: 'auto',

第三项一般设置为auto即可,在目录不可见时,tocbot会为.toc的元素加上is-position-fixed这一class

.is-position-fixed {
  position:fixed !important;
  top:0
}

当然is-position-fixed的css你可以自行配置,tocbot默认的如上。
也就是说,当目录不可见的时候,目录会变为fixed,跟着屏幕进行滚动。
但是我采用的是sticky方法,所以我上面三项配置都注释掉了。
其实并不推荐使用sticky,按照MDN的说法

“这是一个实验性的 API,请尽量不要在生产环境中使用它。”

然而我比较喜欢偷懒....

跳转偏移

对于跳转到某一标题,有一个比较常见的问题:跳转到对应标题后,浮动的导航栏(或者是别的元素)可能会挡住你的标题(#21)

标题被挡住

对于这个问题,有两种方法解决:

  1. 使用scrollEndCallback配置项手动编写跳转后偏移的代码
  2. 使用scrollSmoothOffset这一配置参数

之前我采用的是第一种方法,但是感觉效果并不好,原因是跳转之后你的滚动条会再次滚一次来达到偏移。在tocbot>=4.4.4版本之后,项目的作者为其加上了scrollSmoothOffset
这个参数的作用就是在跳转同时进行一定的偏移,也就是说直接跳转到偏移之后的位置,而不是跳转到标题之后再进行偏移(滚动条只滚动一次),体验个人感觉比第一种方法好。
这一参数对应了scroll-smooth的offset参数,但是tocbot项目的README中似乎并未加上这一参数的说明。
注意要将smoothScroll这一项配置为true

滚动监听

偏移已经完成了,但是又碰上了一个新的问题:

在滚动到对应的标题的时候,目录会监听当前的位置对目录中的标题进行高亮/突出显示。但是如果两个标题靠太近,或者你对跳转时做了偏移处理,此时目录突出显示的标题也许并不会与文章中的标题对应上


例如下图,本应该高亮显示主体部分解析的标题(已做了偏移),但是却突出显示了error 部分这一标题。
2019-03-10 00-10-35 的屏幕截图.png


此时,headingsOffset就派上了用场。
2019-03-10 00-16-54 的屏幕截图.png


但是这个值应该如何设置呢?在tocbot项目的build-html.js中的165-174行左右可以看到:

if (heading.offsetTop > top + options.headingsOffset + 10) {
    // Don't allow negative index value.
    var index = (i === 0) ? i : i - 1
    topHeader = headings[index]
    return true
} else if (i === headings.length - 1) {
    // This allows scrolling for the last heading on the page.
    topHeader = headings[headings.length - 1]
    return true
}

也就是说,满足heading.offsetTop > top + options.headingsOffset + 10这一条件,高亮的标题才会被更新。那么headingOffset在这种需求下应该设为负值。而这个值具体设置为多少,可以自己尝试。

代码参考

if ($('.toc').length > 0) {
    var headerEl = 'h1,h2,h3,h4',  //headers 
        content = '.post-content',//文章容器
        idArr = {};  //标题数组以确定是否增加索引id
    //add #id

    $(content).children(headerEl).each(function () {
        //去除空格以及多余标点
        var headerId = $(this).text().replace(/[\s|\~|`|\!|\@|\#|\$|\%|\^|\&|\*|\(|\)|\_|\+|\=|\||\|\[|\]|\{|\}|\;|\:|\"|\'|\,|\<|\.|\>|\/|\?|\:|\,|\。]/g, '');

        headerId = headerId.toLowerCase();
        if (idArr[headerId]) {
            //id已经存在
            $(this).attr('id', headerId + '-' + idArr[headerId]);
            idArr[headerId]++;
        }
        else {
            //id未存在
            idArr[headerId] = 1;
            $(this).attr('id', headerId);
        }
    });

    tocbot.init({
        // Where to render the table of contents.
        tocSelector: '.toc',
        // Where to grab the headings to build the table of contents.
        contentSelector: content,
        // Which headings to grab inside of the contentSelector element.
        headingSelector: headerEl,
        //positionFixedSelector: '.toc',
        //positionFixedClass: 'is-position-fixed',
        scrollSmooth: true,
        scrollSmoothOffset: -80,
        headingsOffset: -500
    });
}

相关链接

  1. JS生成文章目录 - lxjwlt's blog
  2. 为文章添加一个目录