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中樹形下拉框的實現
©️版權申明:版權所有@安木夕,本文內容僅供學習,歡迎指正、交流,轉載請註明出處!原文編輯地址-語雀