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
屬性值為不可用。使用場景:在修改節點所屬父級時,不可選擇自己及後代。
基本思路:
- 先重置
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
搜尋樹中符合條件的節點,但要包含其所有上級節點(父節點可能並沒有命中),便於友好展示。當樹形結構的資料量大、結構深時,搜尋功能就很有必要了。
基本思路:
- 為避免汙染原有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中樹形下拉框的實現
©️版權申明:版權所有@安木夕,本文內容僅供學習,歡迎指正、交流,轉載請註明出處!原文編輯地址-語雀