前言
在前端开发中,调用接口获取数据是家常便饭。但我们都知道,不是每次 API 请求都会返回给我们想要的数据。比如下面这一段数据:
[
{ "id": 12, "parentId": 1, "name": "朝阳区" },
{ "id": 241, "parentId": 24, "name": "田林街道" },
{ "id": 31, "parentId": 3, "name": "广州市" },
{ "id": 13, "parentId": 1, "name": "昌平区" },
{ "id": 2421, "parentId": 242, "name": "上海科技绿洲" },
{ "id": 21, "parentId": 2, "name": "静安区" }
{ "id": 242, "parentId": 24, "name": "漕河泾街道" },
{ "id": 22, "parentId": 2, "name": "黄浦区" },
{ "id": 11, "parentId": 1, "name": "顺义区" },
{ "id": 2, "parentId": 0, "name": "上海市" },
{ "id": 24, "parentId": 2, "name": "徐汇区" },
{ "id": 1, "parentId": 0, "name": "北京市" },
{ "id": 2422, "parentId": 242, "name": "漕河泾开发区" },
{ "id": 32, "parentId": 3, "name": "深圳市" },
{ "id": 33, "parentId": 3, "name": "东莞市" },
{ "id": 3, "parentId": 0, "name": "广东省" }
]
在这段数据中,省,市,区,甚至街道这些有明显层级关系的都作为同级数据排列,这种结构就是典型的对前端很不友好的代表。一般情况下,类似这种数据,我们需要将其转换为树形结构才可以正常使用。
如果恰好你司的后端大佬愿意修改成你想要的结构,那恭喜你。但实际情况是,大多数人并非这么幸运。下面我就来介绍几种方法,自己动手处理为自己想要的数据。
方法一
我们知道,浅拷贝是拷贝对象的引用地址,只要修改,所有引用都会同步修改。利用这个特点,我们将子节点依次放入父节点,最后将最外层父节点返回即可。
/**
* 数组转树形结构
* @param {array} arr 被转换的数组
* @param {number|string} root 根节点(最外层节点)的 id
* @return array
*/
function arrayToTree(arr, root) {
const result = [] // 用于存放结果
const map = {} // 用于存放 list 下的节点
// 1. 遍历 arr,将 arr 下的所有节点使用 id 作为索引存入到 map
for (const item of arr) {
map[item.id] = item // 浅拷贝(存储对 item 的引用)
}
// 2. 再次遍历,将根节点放入最外层,子节点放入父节点
for (const item of arr) {
// 3. 获取节点的 id 和 父 id
const { id, parentId } = item // ES6 解构赋值
// 4. 如果是根节点,存入 result
if (item.parentId === root) {
result.push(map[id])
} else {
// 5. 反之,存入到父节点
map[parentId].children ? map[parentId].children.push(map[id]) : (map[parentId].children = [map[id]])
}
}
// 将结果返回
return result
}
方法二
与方法一的原理一样,但只遍历一次。区别是在遍历的过程中,逐渐将子节点和父节点存入到 map。
/**
* 数组转树形结构
* @param {array} arr 被转换的数组
* @param {number|string} root 根节点(最外层节点)
* @returns array
*/
function arrayToTreeV2(arr, root) {
const result = [] // 用于存放结果
const map = {} // 用于存放 list 下的节点
// 遍历 list
for (const item of arr) {
// 1. 获取节点的 id 和 父 id
const { id, parentId } = item // ES6 解构赋值
// 2. 将节点存入 map
if (!map[id]) map[id] = {}
// 3. 根据 id,将节点与之前存入的子节点合并
map[id] = map[id].children ? { ...item, children: map[id].children } : { ...item }
// 4. 如果是根节点,存入 result
if (parentId === root) {
result.push(map[id])
} else {
// 5. 反之,存入父节点
if (!map[parentId]) map[parentId] = {}
if (!map[parentId].children) map[parentId].children = []
map[parentId].children.push(map[id])
}
}
// 将结果返回
return result
}
方法三
方法三利用了递归。在每次遍历中筛选出当前根节点的子节点,直至所有节点都归类到相应父节点。此时,filter 返回空数组,递归停止。
/**
* 数组转树形结构
* @param {array} arr 被转换的数组
* @param {number|string} root 根节点(最外层节点)
* @returns array
*/
function arrayToTreeV3(arr, root) {
return arr.filter(item => item.parentId === root).map(item => ({ ...item, children: arrayToTreeV3(arr, item.id) }))
}
性能对比
虽然以上三种方法都能实现将数组转换为树形结构,但我们明显看到方法三的代码最少,也最容易理解。方法一和二原理一样,但遍历的次数不一样。写法不一样,意味着性能表现也不一样。
下面我们就使用 JSBench 测试一下:
性能测试结果显示:
-
方法一尽管使用了两次遍历,但其利用浅拷贝的方式表现出较好的性能。浅拷贝在操作对象引用时效率较高,而且这种分步骤的处理方式可能在特定的数据结构和操作场景下具有较好的性能表现。
-
方法二次之,虽然只需一次遍历,但实现方式相比之下较为复杂。
-
方法三由于递归特性,虽然代码最少,但在性能上表现最差。递归在处理大规模数据时,可能会因为函数调用的开销和栈空间的消耗导致性能下降。每次递归调用都需要分配新的栈帧来保存函数的状态,这可能会较多的资源,尤其是在数据量较大时。