如何实现markdown文章标题导航

在segmentfault上阅读文章时有一个非常好的阅读体验:在文章右边有文章的提纲索引,可以快速了解文章的内容。点击对应的标题,可以对文章内容快速定位。同时处于阅读中段落的标题会实时高亮提示(这个功能在百度百科词条上也有),整个效果如下图:

sf

为了改善各位老铁的阅读体验,我决定我的博客也得加上这个功能。当然这个功能已经上线了,各们老铁可以在我博客随便找篇markdown文章感受下(因为我博客支持3种文章类型:markdown文章,普通文章和富文本文章,目前此功能只在markdown文章实现了)。今天这篇文章主要是复盘一下实现过程。

如何找出markdown文章的所有标题

既然要实现标题实时高亮,第一件事当然是把标题都给找出来,不然高亮谁去。这里讲两种找出markdown文章的所有标题方法:

  • 正则匹配
  • 利用markdown处理库Marked

正则匹配大法(此方法有bug,建议跳过)

熟悉Markdown语法的老铁都知道,Markdown中的标题都是以“#”这个符号开头的。所以要从md茫茫字符中找出标题的第一个正则可能会像这样:

/(#+)[^\n]*?\n/g

解释一下这个正则:以至少一个#开始,紧接非换行符外任意个字符进行惰性匹配,然后是一个换行符。看起来,的确是这么回事。先用最简单的mk测试一下:

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

这种md内容的确可以通过测试,但下面这种有code的情况下,就会有问题

# 标题1

``js
// ## 这是JS注释
console.log('hello')
``
## 标题2
## 标题3
### 标题4

在这个例子中,“## 这里JS注释”也被当成一个md标题了。那么如何避免这种情况呢?

由于md中,代码块种类繁多,且存在形式多种多样。这会当标题正则变得难以下手。这时候我们就不要硬碰硬了,要想到曲线救国:能不能先把md中代码块给去掉。因为代码块中肯定不包含标题,所以去掉对我们的结果没有任何影响。下面是去掉代码块的逻辑

var mkStr
    .replace(/```/g, function () {
      return '\f'
    })
    .replace(/\f[^\f]*?\f/g, function (match) {
      return ''
    })

解释一下:用\f这个字符对“`”进行占位,然后再把占位块一起用空串替换。为什么要先占位来绕一下呢。原因还是代码块可能的情况太多,先用 \f 这个非常特殊的字符占位,就方便我们把代码块连根拔起了。

// 提取标题完整逻辑 
var nav = []

mkContent
  .replace(/```/g, function () {
    return '\f'
  })
  .replace(/\f[^\f]*?\f/g, function (match) {
    return ''
  })
  .replace(/\r|\n+/g, function (match) { 
    return '\n'
  })
  .replace(/(#+)[^\n]*?(?:\n)/g, function (match, m1, m2) {
    var title = match
      .replace('\n', '') // 去掉行尾换行符
      .replace(/^#+/, '') // 去掉 #
      .replace(/\([^)]*?\)/, '') // 去掉标题中可能存在的链接
    nav.push(title)
  })

Marked库大法

在我折腾许多久,突然想到了专业的md处理库marked。其实我博客也是使用Marked把md文章转换成html进行显示的。果然,在mk的 github issues 中找到了办法

var nav = []
const marked = require('marked')
const renderer = new marked.Renderer()
renderer.heading = function (text, level) {
  return nav.push(text)
}
marked(mkBStr, { renderer: renderer })

如何对标题进行层级展示

现在md的标题已经可以解析出来了。第二个问题就是如何把标题们按层级展示。我们知道,md标题前面的 # 越多,标题越小,md标题可以是1~6个 # 号。也就是说,最多可能有5个层级缩进。我们先看一个2级缩进的UI效果:

2级缩进

再看一个3级缩进UI效果:

3级缩进

层级展示的难点有:

  • 层级数不定
  • 兄弟层级间情况无对称性可言

但仔细观察不难发现,如何把一级标题作为根,那么所有的md标题就形成了一种树。作为前端同学,最熟悉的树当然是dom树了。我们把前面的标题数组转换成树。实现思想是:先抽象标题为一个节点对象,包含4个属性:

{
  title: '标题', // 标题内容
  level: 1, // 标题层级,即 # 的个数
  index: 1, // 标题在文章中出现的索引
  children: [
    TitleNode // 子标题
  ] 
}
# 标题1 

``js
// ## 这是JS注释
console.log('hello')
``
## 标题2
## 标题3
### 标题4

上面md内容的完整标题树是这样:

[
  {
    "title": " 标题2",
    "level": 2,
    "children": [],
    "index": 0
  },
  {
    "title": " 标题3",
    "level": 2,
    "index": 1,
    "children": [
      {
        "title": " 标题4",
        "level": 3,
        "children": [],
        "index": 2
      }
    ],
  }
]

完整实现代码如下:

```js
function getMKTitles (mkContent) {
  var nav = []
  var navLevel = []

  mkContent
    .replace(/```/g, function () {
      return '\f'
    })
    .replace(/\f[^\f]*?\f/g, function (match) {
      return ''
    })
    .replace(/\r|\n+/g, function (match) {
      return '\n'
    })
    .replace(/(#+)[^\n]*?(?:\n)/g, function (match, m1, m2) {
      var title = match.replace('\n', '')
      var level = m1.length
      nav.push({
        title: title.replace(/^#+/, '').replace(/\([^)]*?\)/, ''),
        level: level,
        children: []
      })
      if (navLevel.indexOf(level) === -1 && level !== 1) { // =1 为标题
        navLevel.push(level)
      }
    })

  // 去掉title
  if (nav[0].level === 1) {
    nav.shift()
  }

  var index = 0
  nav = nav.map(item => {
    item.index = index++
    return item
  })

  navLevel = navLevel.sort()

  var retNavs = []
  var toAppendNavList

  navLevel.forEach(level => {
    toAppendNavList = find(nav, {
      level: level
    })

    if (retNavs.length === 0) {
      retNavs = retNavs.concat(toAppendNavList)
    } else {
      toAppendNavList.forEach(toAppendNav => {
        toAppendNav = Object.assign(toAppendNav)
        let parentNavIndex = getParentIndex(nav, toAppendNav.index)

        return appendToParentNav(retNavs, parentNavIndex, toAppendNav)
      })
    }
  })

  // 同一级的Level不一样的时候,需要做顺序调整
  retNavs = retNavs.map(navItem => {
    var children = navItem.children || []
    if (children.length === 0) {
      return navItem
    }
    navItem.children = indexSort(navItem.children)
    return navItem
  })

  return {
    nav: retNavs, // 所有mk标题
    navLevel: navLevel, // 所有标题层级
    length: nav.length // 标题长度
  }
}

function find (arr, condition) {
  return arr.filter(item => {
    for (var key in condition) {
      if (condition.hasOwnProperty(key) && condition[key] !== item[key]) {
        return false
      }
    }
    return true
  })
}

function findIndex (arr, condition) {
  let ret = -1
  arr.forEach((item, index) => {
    for (var key in condition) {
      if (condition.hasOwnProperty(key) && condition[key] !== item[key]) { // 不进行深比较
        return false
      }
    }
    ret = index
  })
  return ret
}

function getParentIndex (nav, endIndex) {
  for (var i = endIndex - 1; i >= 0; i--) {
    if (nav[endIndex].level > nav[i].level) {
      return nav[i].index
    }
  }
}

function appendToParentNav (nav, parentIndex, newNav) {
  // 先第一级里面找,找不到再去children中去找
  let index = findIndex(nav, {
    index: parentIndex
  })

  if (index === -1) {
    let subNav

    for (var i = 0; i < nav.length; i++) {
      subNav = nav[i]
      subNav.children.length && appendToParentNav(subNav.children, parentIndex, newNav)
    }
  } else {
    nav[index].children = nav[index].children.concat(newNav)
  }
}

function indexSort (navList) {
  // 有子元素,检查level是否相同
  var needSort = find(navList, {
    level: navList[0].level
  }).length !== navList.length

  if (needSort === false) return navList

  return navList.map(nav => {
    return nav.index
  }).sort().map(index => {
    var nav = find(navList, {
      index: index
    })

    if ((nav.children || []).length > 1) {
      nav.children = indexSort(nav.children)
    }

    return nav[0]
  })
}

export default getMKTitles

标题树的UI展示

得到标题树后,就是进行UI展示了。进行UI展示相信不会难到大家了。这里介绍用vue递归组件的一种实现实现:

<template>
  <ul class="nav-list">
    <li v-for="(nav, index) in list" :key="index">
      <a :href="nav.index | anchor" :class="{active: highlightIndex === nav.index}">{{nav.title}}</a>
      <markdown-titles :list="nav.children" v-if="nav.children.length > 0"/>
    </li>
  </ul>
</template>
<script>
export default {
  name: "markdown-titles",
  props: {
    list: {
      type: Array,
      required: true
    }
  }
}
</script>
<style lang="less">
  .nav-list {
    margin-left: 15px;
    list-style: square;
    padding-left: 0;

    a {
      display: block;
      color: #333;

      &:link, &:visited {
        text-decoration: none;
      }

      &:hover {
        text-decoration: underline;
      }

      &.active {
        color: #FF4400;
        font-weight: 600;
      }
    }
  }
</style>

如何点击标题跳转到相应的内容

这个实现原理还是比较简单:利用浏览器的锚点功能。A 标签的href指向一个id,点击此 A 标签时,浏览器就会跳到对应的锚点处。

<a href="#titleAnchor-12"> 减小编译输出的文件体积</a>
<h2 id="titleAnchor-12">减小编译输出的文件体积</h2>

此于如何给md标题加上锚点id,一样可以使用Marked库

markdownHtml: (state) => {
  const renderer = new marked.Renderer()
  let index = -1
  renderer.heading = function (text, level) {
    return `<h${level} id="titleAnchor-${index++}">${text}</h${level}>`
  }
  return marked(state.markdown, { renderer: renderer })
}

如何让标题实时高亮

到目前为此,就剩最后一个问题还没解决了:如何进行阅读时标题高亮。高亮的第一个问题就是:如何判断那一个文章段落处于阅读中。我的思路时:绑定 scroll 事件,在事件回调中遍历所有的md标题,找出距显示器屏幕上方的y值大于0且最小的标题,就是我们正在阅读的段落的标题。

scrollHandler() {
  const idPrefix = 'titleAnchor-'
  const distance = 20
  let list = []
  for (var i = 0; i < this.mkTitlesLen; i++) {
    let dom = document.getElementById(`${idPrefix}${i}`)
    let domTitle = document.querySelector(`a[href="#titleAnchor-${i}"]`)
    list.push({
      y: dom.getBoundingClientRect().top + 10, // 利用dom.getBoundingClientRect().top可以拿到元素相对于显示器的动态y轴坐标
      index: i,
      domTitle
    })
  }

  let readingVO = list.filter(item => item.y > distance).sort((a, b) => {
    return a.y - b.y
  })[0] // 对所有的y值为正标的题,按y值升序排序。第一个标题就是当前处于阅读中的段落的标题。也即要高亮的标题
}

找到阅读段落的标题后,通过索引就可以找到要高亮的标题。高亮的实现就不多说了,无非加个 active 的样式 class。

非md文章的标题导航

这里之所以要提一下非md的标题导航,是因为是文章就有段落有标题。虽然md是一种很好的展示内容的文体形式,但就像前面所说的,还有富文本这种更丰富的内容展示形式。要对这种文章做标题导航,原理也差不多。只是找出标题的实现有些不一样,其它都是相同的。好了,关于标题导航就复盘到这了,希望看官们有所收获。如何有更好的实现思路,也欢迎与我交流。

留言列表

    发表评论: