怎樣給一個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太舊了,之後就轉到思否吧。

相關文章
相關標籤/搜索