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

安木夕發表於2024-02-25

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中樹形下拉框的實現

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

相關文章