怎样给一个Vue页面添加大纲导航

1、前言

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

clipboard.png

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

2、实现思路

一、需求分析

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

clipboard.png

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

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

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

  • 既然是对于任意页面均可用,那能够遍历DOM树,寻找须要导航的标签,而后把相关节点位置信息存储起来。这里也可一相似mavon-editor给dom树中插入一个元素做为一个锚点。遍历DOM树的方法应该与DOM渲染后从上到下的顺序一致,即采用深度优先的先序遍历方法(先序遍历即先检查根元素,再检查子元素;后序遍历则相反;若是是二叉树,还有中序遍历)。
  • 在全部页面中,并不能单纯根据h1,h2等标签名来判别一个元素是否要导航,因此想到了用选择器来肯定,同时添加根据选择器来排除一些例外的元素。
  • 最终的导航应该是一个树形结构,而且每个节点对应一个插入的锚点,即每个树节点应该包含一个锚点信息。

2.实现思路

由于项目是采用Vue来实现,数据控制视图,因此一般不须要直接操做DOM。可是这里须要在DOM中插入锚点,Vue自定义指令是一个不错的选择。因而能够写一个指令,经过需求分析,大致肯定能够这个指令值能够绑定的一个包含如下三个信息的对象:iview

  • 一个列表selectors:列表中的每一项是一层导航对应的选择器,好比下标为0的元素是第一级导航,一般能够用选择器'h1',下标为1的元素是第二级导航,一般能够用选择器'h2';
  • 一个字符串exceptSelector,用于排除例外元素的选择器;
  • 一个回调函数callback,用于接收生成的导航树形数据。

3、具体实现

1. 锚点生成函数

须要在每个导航元素临近位置插入一个锚点,我这里插在导航元素前面,因此这个函数接收一个导航元素dom参数,并生成一个元素插入到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>

4、npm插件

这个导航工具我已经发布到npm了,地址为vue-outline。若是你须要用到而且不想造轮子的话,能够经过npm或者yarn等包管理工具安装,而且能够在npm上查看使用方法


就这样吧,感谢阅读。第一次在思否写文章,以前一直都在CSDN写博客,不过CSDN太旧了,之后就转到思否吧。

相关文章
相关标签/搜索