实现一个掘金Style的markdown目录

5,891 阅读7分钟

已经几乎很久没有更新文章了,由于工作和生活的原因,仿佛失去了之前在大学时候的样子,变得慵懒起来。刚踏入社会还是需要不停的鞭策自己,有很多东西要学,定期的写作对自己的提升是很大的。在写的过程你依旧在思考,你会想着把这东西变得更好展现到别人眼前。不会像写业务一样,完成了功能和需求很少从头去优化总结。刚好也是因为最近碰到的一个需求,看网上这方面资料很少,于是就有了这篇文章。

前言

随着前端的兴起,越来越多的人加入了这个大家庭,框架和轮子玲琅满目、遍地开花。这样带来了很多好处:减少了开发成本和时间,可能一个功能别人已经造好了,你只需要npm install一下就可以使用了,十分的快捷。但是带来的坏处也不可小嘘:随着项目的增大,轮子越来越多,多的难以掌控,项目的体积也由原来的10m > 20m > 100m...,这是一件很恐怖的事情。另一方面你频繁的使用别人写好的轮子,很少自己思考和实现,长此以往,你的代码能力自然就下降了。所以我经常约束自己的一句话:能不用尽量不用,开发中一个轮子的使用率超低,甚至只有一次,那坚决不用。

刚好一个需求就是:markdow文章编写和呈现。功能差不多类似掘金这种吧,今天给大家带来的是第一节,如何生成一个markdown 目录。

一、锚点设置

makdown中的#,##,##组成的标题经过marked等工具转化渲染到网页中会成变成h标签,所以当拿到文章详情页后可以从中抽离出所有的目录标签即h1,h2,h3....

const toc: string[] = data.content.match(/<[hH][1-6]>.*?<\/[hH][1-6]>/g) // 通过正则的方式

拿到这些标题之后就可以进行锚点的设置。在H5中关于锚点的做法很多,我们会采用下面这种做法进行设计:

①:设置一个锚点链接 <a href="#miao">去找喵星人</a>(注意:href属性的属性值最前面要加#)

②:在页面中需要的位置设置锚点<h1 id="miao"></h1>(注意:a标签中要写一个id属性,属性值要与①中的href的属性值一样,不加#)

通过正则匹配到文章中所有的h标签后,循环添加id属性并将div包裹


tocs.forEach((item: string, index: number) => {
 let _toc = `<div name='toc-title' id='${index}'>${item} </div>`
 data.content = data.content.replace(item, _toc)
})

二、目录转化

我们看到的文章目录一般都是以ul > li > a 标签形式存在的,所以拿到了文章所有的h标签后如何转化为ul或li这类的标签呢?


从控制台中中可以看出文章h标题都被抽离出来,接下来要做的就是将这些h标签转化为ul>li的形式。首先因该知道的一种数据结构--堆栈。 简而言之就是先进后出的数据格式,比如说有一个篮子我们依次往篮子里放鸡蛋,突然有一天这个篮子底部快漏了,为了保护鸡蛋我们要把鸡蛋从篮子里拿出来 ,从篮子最外层依次向内取出鸡蛋,这就是典型的先进后出的例子。我们h标签转化为ul>li其实也是一样的道理。

export default function toToc(data: string[]) {
  let levelStack: string[] = []
  let result:string = ''
  const addStartUL = () => { result += '<ul class="catalog-list">'; }
  const addEndUL = () => { result += '</ul>\n'; }
  const addLI = (index: number, itemText: string) => { result += '<li><a name="link" class="toc-link'+'-#'+ index + '" href="#' + index + '">' + itemText + "</a></li>\n"; }
  data.forEach(function (item: any, index: number) {
    let itemText: string = item.replace(/<[^>]+>/g, '')  // 匹配h标签的文字
    let itemLabel: string = item.match(/<\w+?>/)[0]  // 匹配h?标签<h?>
    let levelIndex: number = levelStack.indexOf(itemLabel) // 判断数组里有无<h?>
    // 没有找到相应<h?>标签,则将新增ul、li
    if (levelIndex === -1) {
      levelStack.unshift(itemLabel)
      addStartUL()
      addLI(index, itemText)
    }
    // 找到了相应<h?>标签,并且在栈顶的位置则直接将li放在此ul下
    else if (levelIndex === 0) {
      addLI(index, itemText)
    }
    // 找到了相应<h?>标签,但是不在栈顶位置,需要将之前的所有<h?>出栈并且打上闭合标签,最后新增li
    else {
      while (levelIndex--) {
        levelStack.shift()
        addEndUL()
      }
      addLI(index, itemText)
    }
  })
  // 如果栈中还有<h?>,全部出栈打上闭合标签
  while (levelStack.length) {
    levelStack.shift()
    addEndUL()
  }
  return result
}

至此所有的h标签都转换成了ui > li的形式并且增加了a链接锚点和之前文章中h标签id相互对应,文章就实现了目录和点击跳转。

三、目录优化

到这里我们的目标基本完成了一半,作为掘金的忠爱粉,当然是选择使用css进行优化一下,css贴起来总觉得像是在拉家常,这里就不详细介绍了,大致是这样的

.catalog-list {
  font-weight: 600;
  padding-left: 10px;
  position: relative;
  font-size: 15px;
  &:first-child::before {
      content: "";
      position: absolute;
      top: 10px;
      left: 12px;
      bottom: 0;
      width: 2px;
      background-color: #ebedef;
      opacity: .8;
    }
  }
  & > li > a {
    position: relative;
    padding-left: 16px;
    line-height: 20px;
    @include catalogRound(0, 6px);
  }
  ul, li {
    padding: 0;
    margin: 0;
    list-style: none;
  }
  ul > li > a {
    font-size: 14px;
    color: #333333;
    padding-left: 36px;
    font-weight: 500;
    position: relative;
    @include catalogRound(20px, 5px);
  }
  ul > ul > li > a {
    line-height: 20px;
    font-size: 14px;
    color: #333333;
    padding-left: 50px;
    font-weight: normal;
    @include catalogRound;
  }
  a {
    color: #000;
    display: block;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    padding: 4px 0 4px 12px;
    &:hover {
      background-color: #ebedef;
    }
  }
}

经过改装后的目录效果如下,貌似有几分相像了。至少不会太丑。后面主要介绍如何实现文章到目录的联动和目录到文章的联动。这是必不可少的一个功能

四、动态关联

1、目录到文章

目录如何控制文章显示的位置,可以思考一下🤔:锚点是通过a标签来实现的但是大量的a标签的点击事件是无法捕获的,尤其我们是通过转换出来的a标签,但观察发现锚点的hash值会在url上增加锚点位置,因此想到了一种解决方案可以通过监听url的变化来捕获点击的a标签是哪个。于是我们监听了route

@Watch('$route')
  private routechange(val: any) {
    const data = document.getElementsByClassName(`toc-link-${val.hash}`)[0] as Element
    this.linkLists.forEach((list:Element) => {
      data == list ? list.classList.add('active') : list.classList.remove('active') 
    })
  }
  

至此我们点击目录便可跳转到文章响应的位置,这里有一个小提示。由于页面可能有nav导航定位,往往我们跳转的文章会被导航栏遮住,因此需要改善一下,通过css属性设置margin-top = nav的高度,padding-top = -nav的高度。

2、文章到目录

目录到文章已经讲完了,滚动文章如何实现目录自动跳转呢?不妨也先大致理清楚思路:

  • 1、监听浏览器的滚动距离
  • 2、计算每个标题距浏览器顶部的高度
  • 3、匹配滚动距离在两标题之间的距离实现目录自动跳转

具体实现步骤:

在mounted()生命周期中监听鼠标的滚动

window.addEventListener('scroll', this.handleScroll, true)

获取所有的文章标题和目录

this.$nextTick(async () => {
  await this.getTitleHeight()
  await this.getCataloglist()
})
// 获取每个文章标题的距顶部的高度
  private async getTitleHeight() {
    let titlelist = Array.prototype.slice.call((this.$refs.article as Element).getElementsByClassName('toc-title'))
    titlelist.forEach((item,index) => {
      this.listHeight.push(item.offsetTop)
    })
    // 滚动的距离无法取到最后一个,因此在数组最后加上上一个两倍达到效果
    this.listHeight.push(2 * (titlelist[titlelist.length-1].offsetTop))
  }
// 获取目录的所有ul、a标签
  private async getCataloglist() {
    let catalogList = (this.$refs.catalog as Element).getElementsByClassName('catalog-list')
    this.linkLists = document.getElementsByName('link')
    this.target = Array.prototype.slice.call(catalogList)
  }

在handleScroll函数中监听文章滚动

private handleScroll() {
    const scrollY = window.pageYOffset
    this.fixed = scrollY > 230 ? true : false
    for (let i = 0; i < this.listHeight.length-1; i++) {
      let h1: number = this.listHeight[i]
      let h2: number = this.listHeight[i + 1]
      if (scrollY >= h1 && scrollY <= h2) {
        const data: Element = document.getElementsByClassName(`toc-link-#${i}`)[0] as Element // 获取文章滚动到目录的目标元素
        this.linkLists.forEach((list: Element) => {
          let top: number = 0 
          top = i > 7 ? -28 * (i-7) : 0  
          this.target[0].style.marginTop = `${top}px`
          data == list ? list.classList.add('active') : list.classList.remove('active') // 其他移除active
        })
      } 
    }
 }

代码讲解:

this.fixed = scrollY > 230 ? true : false

目录不跟随页面滚动,因此需要添加一个fixed属性,让他固定在文章的右边,230是目录前的一个盒子高度。当鼠标滚动到230px的时候目录就固定了带到了效果。

let top: number = 0 
top = i > 7 ? -28 * (i-7) : 0  
this.target[0].style.marginTop = `${top}px`

虽然目录不跟随页面滚动,但目录过长可能就显示不出来,因此需要去动态设置目录的margin-top属性,

top = i > 7 ? -28 * (i-7) : 0 // 目录第7条的时候开始向上滚动

结言

功能算是实现了,但还是有很多可以优化的地方,也希望指出给予意见。如果你不喜欢这样的目录,或者根自己的实际需求不一样,那无非就是css的不同罢了。功能实现往往决定了效果,可以根据自己需求去改写ul > li的css了。这只是从我实际项目中抽离出来的一部分。实际要比这难太多。不过这已经够用了,下期将带大家一起学习如何制作实现一个掘金Style的文章编辑器,敬请期待!