文章页面目录自动生成方案

一、前言

前两天项目遇到一个需要给页面添加大纲导航的功能,要求把页面中的特定标签加入到大纲导航中。类似这样:

需求本身并不难,不过想把这个东西做得通用一些,也就是以后再有别的页面需要加导航,不用再重新写很复杂的逻辑了。下面说一下具体实现思路,并且文末会给出简便易用的导航生成工具。

二、实现思路

1、需求分析

做之前想到之前接触过的markdown编辑器mavon-editor有一个导航,不过那个导航只能用于编辑器自身,我去看了一下它的表现:


点击右边的导航节点,会自动定位到对应标题元素。当时思考了一下它是怎么记录标题元素的,会不会是给标题元素加了一个什么id之类的属性?于是我看了一下生成的DOM:


竟然是给标题元素加了一个带有id属性的a标签的子节点。不过它生成id的方式比较简单,单纯的"字符串_编号"而已,想来并不是那么可靠(难于保证编辑器外有相同id的元素)。

我大体有了一个基本的思路:

  • 既然是对于任意页面都可用,那可以遍历DOM树,寻找需要导航的标签,然后把相关节点位置信息存储起来。这里也可一类似mavon-editor给dom树中插入一个元素作为一个锚点。遍历DOM树的方法应该与DOM渲染后从上到下的顺序一致,即采用深度优先的先序遍历方法(先序遍历即先检查根元素,再检查子元素;后序遍历则相反;如果是二叉树,还有中序遍历)。

  • 在所有页面中,并不能单纯根据h1,h2等标签名来判别一个元素是否要导航,所以想到了用选择器来确定,同时添加根据选择器来排除一些例外的元素。

  • 最终的导航应该是一个树形结构,并且每一个节点对应一个插入的锚点,即每一个树节点应该包含一个锚点信息。

2、实现思路

因为项目是采用Vue来实现,数据控制视图,所以通常不需要直接操作DOM。但是这里需要在DOM中插入锚点,Vue自定义指令是一个不错的选择。于是可以写一个指令,通过需求分析,大体确定可以这个指令值可以绑定的一个包含以下三个信息的对象:

  • 一个列表selectors:列表中的每一项是一层导航对应的选择器,比如下标为0的元素是第一级导航,通常可以用选择器'h1',下标为1的元素是第二级导航,通常可以用选择器'h2';

  • 一个字符串exceptSelector,用于排除例外元素的选择器;

  • 一个回调函数callback,用于接收生成的导航树形数据。

三、具体实现

1、锚点生成函数

需要在每一个导航元素临近位置插入一个锚点,我这里插在导航元素前面,所以这个函数接收一个导航元素dom参数,并生成一个元素插入到dom之前。代码如下:

import uuidv4 from 'uuid/v4'let ATTR_NAME = 'navigation_anchor'function createLinkElement (dom) {  let id = uuidv4()  let element = document.createElement('a')  element.setAttribute('id', id)  element.setAttribute(ATTR_NAME, true)  dom.parentNode.insertBefore(element, dom)  return id}

这个函数接收导航元素dom作为参数,生成一个a标签,并且给a标签设置了一个uuid(确保唯一性)作为id,同时设置了一个特殊属性'navigation_anchor'(尽可能复杂,你甚至可以用uuid,不要与DOM中其他元素属性相同)便于清理所有生成的锚点。

2、锚点清理函数

用于清除生成的锚点元素。代码如下:

function clearLinkElement (dom) {  dom = dom || document  let domList = dom.querySelectorAll(`a[${ATTR_NAME}]`)  for (let idx = domList.length - 1; idx > -1; idx--) {    let element = domList[idx]    element.parentNode.removeChild(element)  }}

可以看到,通过给锚点元素设置一个特殊属性,在清除的时候非常容易。这里用到一个非常重要的函数querySelectorAll,它会根据调用的根节点遍历该节点的子DOM树,返回符合某个选择器的NodeList(一个类数组的对象,但不是数组!),而且遍历方式就是上文所述的深度优先先序遍历!真是激动人心!接下来我们可以用这个元素获取所有需要导航的元素列表。

3、生成树形导航数据函数

通过传入的导航元素DOM根节点、导航元素选择器列表、导航元素排除选择器,返回一个树形数据的列表list。查找出所有导航元素,插入对应锚点,并将锚点信息和导航元素标题存到list中。

function generateNavTree (dom, selectors, exceptSelector) {  clearLinkElement(dom)  let list = []  if (exceptSelector) {    let exceptList = dom.querySelectorAll(exceptSelector)    exceptList.forEach(element => {      element.__nav_except = true    })  }  for (let idx in selectors) {    let elementList = dom.querySelectorAll(selectors[idx])    elementList.forEach(element => {      if (element.__nav_except || element.offsetParent === null) return      element.__nav_level = idx    })  }  let selector = selectors.join(',')  let domList = dom.querySelectorAll(selector)  for (let element of domList) {    if (!element.__nav_level) {      delete element.__nav_except      continue    }    let pushList = list    while (element.__nav_level > 0) {      pushList = pushList.length ? pushList[pushList.length - 1].children : null      if (!pushList) break      element.__nav_level--    }    let data = {      title: element.textContent,      children: [],      id: createLinkElement(element)    }    pushList && pushList.push(data)    delete element.__nav_level  }  return list}

到这一步有个很有必要注意的地方,导航数据里的title我最开始用了一个超级慢的属性innerText,然后整个页面生成导航(大约50个导航节点)竟然要2s左右,后面改为了才textContent。

经过我的测试,两个属性的访问时间相差n个数量级,访问innerText大约要30ms,而访问textContent大约要0.05ms左右。就是这么大的差别,查阅了相关资料,原因应该是innerText会引起浏览器重排,耗时超级多。

4、调用导航数据生成函数并通过回调传给组件。

现在生成导航数据的函数已经有了,一个问题就是何时调用此函数呢?我们通过Vue指令来实现,可以在相应的钩子函数中调用。

一个时机是当指令绑定的元素所在模板更新完成之时,另一个时机是指令绑定元素插入之时。
指令部分代码如下:

export default {  bind (el, binding, vNode) {    el.__navigationGenerateFunction = () => {      if (el.__generating) return      let selectors = binding.value.selectors || ['h1', 'h2']      let exceptSelector = binding.value.exceptSelector      el.__generating = true      let list = []      generateNavTree(el, selectors, exceptSelector, list)      binding.value.callback(list)      vNode.context.$nextTick(() => {        delete el.__generating      })    }  },  inserted (el, binding, vNode) {    el.__navigationGenerateFunction && el.__navigationGenerateFunction()  },  componentUpdated (el, binding, vNode) {    el.__navigationGenerateFunction && el.__navigationGenerateFunction()  },  unbind (el, binding, vNode) {    clearLinkElement()    if (el.__navigationGenerateFunction) {      delete el.__navigationGenerateFunction    }  }}

需要注意的是,我们在模板更新完成时插入锚点元素,而这本身又是会触发模板更新的,所以需要打个标记避死循环。

5、导航数据的展示

导航数据是一个树形数据,所以可以用树形组件来展示之。比如element或者iview的树组件都可以。不过因为曾经对element和iview的树形组件不甚满意,自己写过一个树形组simple-vue-tree件并且发布到了npm。
这里我就使用这个组件来展示,下面是一个完整的示例:

<template>  <div class="hello">    <div v-outline="{      callback: refreshNavTree,      selectors: ['h1', 'h2'],      exceptSelector: '[un-nav]'    }" class="content">    <!-- 需要导航的内容 -->      <div>        <h1>一级标题1</h1>        <div :style="{ margin: '.5rem 2rem' }">内容不出现在导航</div>        <h2>二级标题</h2>        <div :style="{ margin: '.5rem 2rem' }">内容不出现在导航</div>      </div>    </div>    <div class="navigation">      <div class="title">导航目录</div>      <simple-tree        :treeData="navTree"        :expand="false"        class="tree">        <div slot-scope="{ data, parentData }">          <div            class="node-render-content"            @click.stop="jumpToAnchor(data.id)">            {{ data.title }}          </div>        </div>      </simple-tree>    </div>  </div></template><script>export default {  data () {    return {      navTree: []    }  },  methods: {    refreshNavTree (treeData) {      this.navTree = treeData    },    jumpToAnchor (id) {      let element = document.getElementById(id)      if (element) {        element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' })      }    }  }}</script>

四、npm插件

这个导航工具我已经发布到npm了,地址为vue-outline。如果你需要用到并且不想造轮子的话,可以通过npm或者yarn等包管理工具安装,并且可以在npm上查看使用方法。

本文完~

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值