1

本博客的文章是基于markdown转化而成的。我的博客中大部分是技术文章,且有部分有比较多的内容,所以这时候要是边上有一个标题导航栏就太美了。这篇文章用来记录实现标题导航栏的过程。

1. 基于 markdown 内容生成导航数据

要实现导航栏功能,首先要从文章中提取出标题信息。已知 html 中的标题是通过 h 标签实现,而 markdown 中的标题是通过 # 实现的。下面介绍导航数据提取的探索和实现过程。

1.1 实现方式一:正则提取 markdown

已知 markdown 中标题的编码方式是 # 号 + 空格 + 标题名称,而有几个 # 号就代表几级标题。所以第一方案的关键方法就是根据这一特征对 markdown 内容进行提取。写完标题一般会用换行符结尾,我们可以生成这么一个正则来对标题进行提取:

const regHeader = /(#{1,5})\s.*\n/g // 只提取 h1-h5

我们需要的关键信息是 第几级标题标题内容。基于这个目标我们使用正则从markdown编码中提取出一个标题的数组

const regHeader = /(#{1,5})\s.*\n/g // 标题正则
const headerArr = contentMd.match(regHeader)
const showSideNav = headerArr && headerArr.length
const navArray = !showSideNav ? [] : headerArr.map(item => ({
    content: item.replace(/(#{1,5}\s)|(\n)/g, ''), // 标题内容
    headerIndex: item.match(/#{1,5}/)[0].length // 几级标题
  }))

1.1.1 不足分析

这种方法有个无法弥补的劣势,如果将 # 写在 code 中

 ```# 标题1```

我们希望得到的 # 标题1 是个纯文本,在这里并没有生成标题的想法,但是正则还是会将这个标题提出来。

1.2. 基于 marked.js 库中自定义 render 提取导航数据

在查阅资料和翻看 marked.js 文档的时候,我发现 marked.js 可以自定义 render 方法。那只要在 render 方法中保存标题信息就可以提取出导航数据了。
在这里我们要保存两个数据:

  • 导航栏列表 navList,里面包含三个字段
navList: [
    {
        level: 1, // 标题层级,h1,h2,h3,h4
        no: 1, // 同一层级下的序号,在该层级标题下的排序
        text: '标题1' // 标题内容
    }
]
  • 标题层级对象 navIndexObj
    这个对象用来记录当前标题渲染时已有该层级的标题数量,每次 render 标题的时候记录该层级标题的数量 + 1

对应的 marked.js render 函数

// 博客使用 react 实现,所以将这两个数据放到 redux 中保存
import marked from 'marked'
import hljs from './highlight'
import store from '../store'
import { actionCreators } from '../views/detail/store'
const renderer = new marked.Renderer()
renderer.heading = (text, level) => {
  if (window.location.href.includes('/detail/')) { // 详情页提取标题栏
    store.dispatch(actionCreators.addNavIndex(level)) // 该层级标题数量 + 1
    store.dispatch(actionCreators.addNavList(level, text)) // navList 列表新增一个数据
    const index = store.getState().getIn(['detail', 'navInfo', 'navIndexObj', level]).length + 1
    return `
      <h${level} id="h${level}-${index}" class="blog-detail-header" data-link="linkToh${level}${index}">${text}</h${level}>
    `
  } else {
    return `
      <h${level}>${text}</h${level}>
      `
  }
}
marked.setOptions({
  renderer,
  highlight: (code) => {
    return hljs.highlightAuto(code).value
  },
  pedantic: false,
  gfm: true,
  tables: true,
  breaks: true,
  headerIds: false,
  sanitize: false,
  smartLists: true,
  smartypants: false,
  xhtml: false
})

export default marked

对应的 reducer 代码

import { fromJS } from 'immutable'
const initialState = fromJS({
  navInfo: {
    navIndexObj: {},
    navList: []
  }
})
export default (state = initialState, action) => {
  switch (action.type) {
    case ADD_NAV_INDEX:
      return state.updateIn(['navInfo', 'navIndexObj', action.data], item => item ? new Array(item.length + 1) : [])
    case ADD_NAV_LIST:
      return state.updateIn(['navInfo', 'navList'], item => item.push({
        level: action.level,
        no: state.getIn(['navInfo', 'navIndexObj', action.level]).length + 1,
        text: action.text }))
    default:
      return state
  }
})

导航栏组件就根据 navList 数据生成即可。
具体代码可以参考 博客源码

1.2.1 不足分析

该方案没有明显缺点,选定为最终方案。

2. 实现标题和导航栏联动

上文我们已经提取出了文章的导航栏列表数据。下面我们通过这些数据实现标题和导航栏联动的功能。

2.1 点击导航栏跳转到文章标题

我们在用 marked.js 渲染标题的时候,已经根据标题的层级和顺序给标题生成了一个唯一的id。我们依赖 navList 生成导航栏,我们也可以根据这个数据轻易地拼出各条导航栏对应的标题的 id。找到 id 后通过 element.scrollIntoView() 让元素跳转到视图中即可。

2.2 滚动文章时标题对应的导航高亮

我们接下来实现另一个联动功能:在浏览文章时,当标题滚动到可视区域时对应的标题导航栏也滚动到可视区域且高亮。
JS有一个用来观察元素和窗体相交状态的观察器 IntersectionObserver,我们这里用它来实现文章关联标题栏的功能(不考虑兼容性)。
观察器相关代码如下

const scrollObserve = () => {
    const navObserver = new IntersectionObserver(entries => {
      entries.forEach(entry => {
          if (entry.isIntersecting) {
            activeNav(entry.target) // 高亮观察器对应的导航菜单
          }
      })
    })
    document.querySelectorAll('.blog-detail-header').forEach(ele => { // 对所有的标题绑定观察器
      navObserver.observe(ele)
    })
  }

具体功能代码可以参照源码
最终实现效果
示例图片
最终基本实现了标题和导航栏联动的功能。
完结撒花,新年快乐🧐🧐🧐

参考

使用 marked 解析 Markdown 并生成目录导航 TOC 功能
The renderer
尝试使用JS IntersectionObserver让标题和导航联动


MrBigShot
4.8k 声望3.1k 粉丝

菜鸡一个