前端樹形Tree數據結構使用-🤸🏻‍♂️各種姿勢總結

image.png


01、樹形結構數據

前端開發中會經常用到樹形結構數據,如多級菜單、商品的多級分類等。數據庫的設計和存儲都是扁平結構,就會用到各種Tree樹結構的轉換操作,本文就嘗試全面總結一下。

如下示例數據,關鍵字段id爲唯一標識,pid父級id,用來標識父級節點,實現任意多級樹形結構。"pid": 0“0”標識爲根節點,orderNum屬性用於控制排序。

const data = [
{ "id": 1, "name": "用戶中心", "orderNum": 1, "pid": 0 },
{ "id": 2, "name": "訂單中心", "orderNum": 2, "pid": 0 },
{ "id": 3, "name": "系統管理", "orderNum": 3, "pid": 0 },
{ "id": 12, "name": "所有訂單", "orderNum": 1, "pid": 2 },
{ "id": 14, "name": "待發貨", "orderNum": 1.2, "pid": 2 },
{ "id": 15, "name": "訂單導出", "orderNum": 2, "pid": 2 },
{ "id": 18, "name": "菜單設置", "orderNum": 1, "pid": 3 },
{ "id": 19, "name": "權限管理", "orderNum": 2, "pid": 3 },
{ "id": 21, "name": "系統權限", "orderNum": 1, "pid": 19 },
{ "id": 22, "name": "角色設置", "orderNum": 2, "pid": 19 },
];

在前端使用的時候,如樹形菜單、樹形列表、樹形表格、下拉樹形選擇器等,需要把數據轉換爲樹形結構數據,轉換後的數據結效果圖:

預期的樹形數據結構:多了children數組存放子節點數據。

[
    { "id": 1, "name": "用戶中心", "pid": 0 },
    {
        "id": 2, "name": "訂單中心", "pid": 0,
        "children": [
            { "id": 12, "name": "所有訂單", "pid": 2 },
            { "id": 14, "name": "待發貨", "pid": 2 },
            { "id": 15, "name": "訂單導出","pid": 2 }
        ]
    },
    {
        "id": 3, "name": "系統管理", "pid": 0,
        "children": [
            { "id": 18, "name": "菜單設置", "pid": 3 },
            {
                "id": 19, "name": "權限管理", "pid": 3,
                "children": [
                    { "id": 21, "name": "系統權限",  "pid": 19 },
                    { "id": 22, "name": "角色設置",  "pid": 19 }
                ]
            }
        ]
    }
]

02、列表轉樹-list2Tree

常用的算法有2種:

  • 🟢遞歸遍歷子節點:先找出根節點,然後從根節點開始遞歸遍歷尋找下級節點,構造出一顆樹,這是比較常用也比較簡單的方法,缺點是數據太多遞歸耗時多,效率不高。還有一個隱患就是如果數據量太,遞歸嵌套太多會造成JS調用棧溢出,參考《JavaScript函數(2)原理{深入}執行上下文》。
  • 🟢2次循環Object的Key值:利用數據對象的id作爲對象的key創建一個map對象,放置所有數據。通過對象的key快速獲取數據,實現快速查找,再來一次循環遍歷獲取根節點、設置父節點,就搞定了,效率更高。

🟢遞歸遍歷

從根節點遞歸,查找每個節點的子節點,直到葉子節點(沒有子節點)。

//遞歸函數,pid默認0爲根節點
function buildTree(items, pid = 0) {
  //查找pid子節點
  let pitems = items.filter(s => s.pid === pid)
  if (!pitems || pitems.length <= 0)
    return null
  //遞歸
  pitems.forEach(item => {
    const res = buildTree(items, item.id)
    if (res && res.length > 0)
      item.children = res
  })
  return pitems
}

🟢object的Key遍歷

簡單理解就是一次性循環遍歷查找所有節點的父節點,兩個循環就搞定了。

  • 第一次循環,把所有數據放入一個Object對象map中,id作爲屬性key,這樣就可以快速查找指定節點了。
  • 第二個循環獲取根節點、設置父節點。

分開兩個循環的原因是無法完全保障父節點數據一定在前面,若循環先遇到子節點,map中還沒有父節點的,否則一個循環也是可以的。

/**
 * 集合數據轉換爲樹形結構。option.parent支持函數,示例:(n) => n.meta.parentName
 * @param {Array} list 集合數據
 * @param {Object} option 對象鍵配置,默認值{ key: 'id', parent: 'pid', children: 'children' }
 * @returns 樹形結構數據tree
 */
export function list2Tree(list, option = { key: 'id', parent: 'pid', children: 'children' }) {
  let tree = []
  // 獲取父編碼統一爲函數
  let pvalue = typeof (option.parent) === 'function' ? option.parent : (n) => n[option.parent]
  // map存放所有對象
  let map = {}
  list.forEach(item => {
    map[item[option.key]] = item
  })
  //遍歷設置根節點、父級節點
  list.forEach(item => {
    if (!pvalue(item))
      tree.push(item)
    else {
      map[pvalue(item)][option.children] ??= []
      map[pvalue(item)][option.children].push(item)
    }
  })
  return tree
}
  • 參數option爲數據結構的配置,就可以兼容各種命名的數據結構了。
  • option中的parent 支持函數,兼容一些複雜的數據結構,如parent: (n) => n.meta.parentName,父節點屬性存在一個複合對象內部。

測試一下:

data.sort((a, b) => a.orderNum - b.orderNum)
const sdata = list2Tree(data)
console.log(sdata)

對比一下

遞歸遍歷 object的Key遍歷
時間複雜度 O(n)最差的情況是n-1個節點都有子節點,就會遞歸n-1次 O(2)循環兩次
空間複雜度 沒有創建額外的非必要對象 O(n)額外創建了一個map對象,包含了所有節點
總結 容易理解,比較常用,但性能一般 藉助對象的屬性key,比較巧妙,性能高

延伸一下:Map和Object哪個更快?

在上面的方案2(object的Key遍歷)中使用的是Object,其實也是可以用ES6新增的Map對象。Object、Map都可用作鍵值查找,速度都還是比較快的,他們內部使用了哈希表(hash table)、紅黑樹等算法,不過不同引擎可能實現不同。

let obj = {};
obj['key1'] = 'objk1'
console.log(obj.key1)

let map = new Map()
map.set('key1','map1')
console.log(map.get('key1'))

大多數情況下Map的鍵值操作是要比Object更高效的,比如頻繁的插入、刪除操作,大量的數據集。相對而言,數據量不多,插入、刪除比較少的場景也是可以用Object的。


03、樹轉列表-tree2List

樹形數據結構轉列表,這就簡單了,廣度優先,先橫向再縱向,從上而下依次遍歷,把所有節點都放入一個數組中即可。

/**
 * 樹形轉平鋪list(廣度優先,先橫向再縱向)
 * @param {*} tree 一顆大樹
 * @param {*} option 對象鍵配置,默認值{ children: 'children' }
 * @returns 平鋪的列表
 */
export function tree2List(tree, option = { children: 'children' }) {
  const list = []
  const queue = [...tree]
  while (queue.length) {
    const item = queue.shift()
    if (item[option.children]?.length > 0)
      queue.push(...item[option.children])
    list.push(item)
  }
  return list
}

04、設置節點不可用-setTreeDisable

遞歸設置樹形結構中數據的 disabled 屬性值爲不可用。使用場景:在修改節點所屬父級時,不可選擇自己及後代。

image.png

基本思路:

  • 先重置disabled 屬性,遞歸樹所有節點,這一步可根據實際情況優化下。
  • 設置目標節點及其子節點的disabled 屬性。
/**
 * 遞歸設置樹形結構中數據的 disabled 屬性值爲不可用。使用場景:在修改父級時,不可選擇自己及後代
 * @param {*} tree 一顆大樹
 * @param {*} disabledNode 需要禁用的節點,就是當前節點
 * @param {*} option 對象鍵配置,默認值{ children: 'children', disabled: 'disabled' }
 * @returns void
 */
export function setTreeDisable(tree, disabledNode, option = { children: 'children', disabled: 'disabled' }) {
  if (!tree || tree.length <= 0)
    return tree
  // 遞歸更新disabled值
  const update = function(tree, value) {
    if (!tree || tree.length <= 0)
      return
    tree.forEach(item => {
      item[option.disabled] = value
      update(item[option.children], value)
    })
  }
  // 開始幹活,先重置
  update(tree, false)
  if (!disabledNode) return tree
  // 設置所有子節點disable = true
  disabledNode[option.disabled] = true
  update(disabledNode[option.children], true)
  return tree
}

05、搜索過濾樹-filterTree

搜索樹中符合條件的節點,但要包含其所有上級節點(父節點可能並沒有命中),便於友好展示。當樹形結構的數據量大、結構深時,搜索功能就很有必要了。

image.png

基本思路:

  • 爲避免污染原有Tree數據,這裏的對象都使用了簡單的淺拷貝const newNode = { ...node }
  • 遞歸爲主的思路,子節點有命中,則會包含父節點,當然父節點的children會被重置。
/**
 * 遞歸搜索樹,返回新的樹形結構數據,只要子節點命中保留其所有上級節點
 * @param {Array|Tree} tree 一顆大樹
 * @param {Function} func 過濾函數,參數爲節點對象
 * @param {Object} option 對象鍵配置,默認值{ children: 'children' }
 * @returns 過濾後的新 newTree
 */
export function filterTree(tree, func, option = { children: 'children' }) {
  let resTree = []
  if (!tree || tree?.length <= 0) return null
  tree.forEach(node => {
    if (func(node)) {
      // 當前節點命中
      const newNode = { ...node }
      if (node[option.children])
        newNode[option.children] = null //清空子節點,後面遞歸查詢賦值
      const cnodes = filterTree(node[option.children], func, option)
      if (cnodes && cnodes.length > 0)
        newNode[option.children] = cnodes
      resTree.push(newNode)
    }
    else {
      // 如果子節點有命中,則包含當前節點
      const fnode = filterTree(node[option.children], func, option)
      if (fnode && fnode.length > 0) {
        const newNode = { ...node, [option.children]: null }
        newNode[option.children] = fnode
        resTree.push(newNode)
      }
    }
  })
  return resTree
}

參考資料

  • 開源項目庫:kvue-admin
  • 文中tree源碼:tree.js
  • elementUI中樹形下拉框的實現

©️版權申明:版權所有@安木夕,本文內容僅供學習,歡迎指正、交流,轉載請註明出處!原文編輯地址-語雀

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章