JS-手写系列:树与数组相互转换
前言
在前端业务中,后端返回的扁平化数组(Array)往往需要转换为树形结构(Tree)来适配 UI 组件(如 Element UI 的 Tree 或 Cascader)。掌握多种转换思路及性能差异,是进阶高级前端的必备技能。
一、 核心概念:结构对比
-
数组结构:每一项通过
parentId指向父级。const nodes = [ { id: 3, name: '节点C', parentId: 1 }, { id: 6, name: '节点F', parentId: 3 }, { id: 0, name: 'root', parentId: null }, { id: 1, name: '节点A', parentId: 0 }, { id: 8, name: '节点H', parentId: 4 }, { id: 4, name: '节点D', parentId: 1 }, { id: 2, name: '节点B', parentId: 0 }, { id: 5, name: '节点E', parentId: 2 }, { id: 7, name: '节点G', parentId: 2 }, { id: 9, name: '节点I', parentId: 5 }, ]; -
树形结构:父级通过
children数组包裹子级。let tree = [ { id: 1, name: 'text1', parentId: 1, children: [ { id: 2, name: 'text2', parentId: 1, children: [ { id: 4, name: 'text4', parentId: 2, }, ], }, { id: 3, name: 'text3', parentId: 1, }, ], }, ];
二、 数组转树
1. 递归思路
原理:
- 首先需要传递给函数两个参数:数组、当前的父节点id
- 设置一个结果数组res,遍历数组,先找到子元素的父节点id与父节点id一致的子项
- 将这个子项的id作为父节点id传入函数,继续遍历
- 将遍历的结果作为children返回,并给当前项添加children
- 将这个当前项,插入到res里面,并返回
注意:如果不想影响原数组,需要先深拷贝一下数组。
const cloneArr = JSON.parse(JSON.stringify (arr))
const nodes = [
{ id: 3, name: '节点C', parentId: 1 },
{ id: 6, name: '节点F', parentId: 3 },
{ id: 0, name: 'root', parentId: null },
{ id: 1, name: '节点A', parentId: 0 },
{ id: 8, name: '节点H', parentId: 4 },
{ id: 4, name: '节点D', parentId: 1 },
{ id: 2, name: '节点B', parentId: 0 },
{ id: 5, name: '节点E', parentId: 2 },
{ id: 7, name: '节点G', parentId: 2 },
{ id: 9, name: '节点I', parentId: 5 },
];
//递归写法
const arrToTree1 = (arr, id) => {
const res = [];
arr.forEach((item) => {
if (item.parentId === id) {
const children = arrToTree1(arr, item.id);
//如果希望每个元素都有children属性,可以直接赋值
if (children.length !== 0) {
item.children = children;
}
res.push(item);
}
});
return res;
};
console.log(arrToTree1(nodes, null));
2. 非递归思路
原理:利用 filter 进行二次筛选。虽然写法简洁,但在大数据量下性能较差()。
- 函数只需要接受一个参数,也就是需要转换的数组arr
- 第一层过滤数组,直接返回一个parentId为根id的元素
- 但是在返回之间,需要再根据当前id过滤里面的每一项(过滤规则为如果子项的paentId为当前的id,则在当前项的children插入这个子项)
const arrToTree2 = (arr) => {
return arr.filter((father) => {
const childrenArr = arr.filter((children) => {
return children.parentId === father.id;
});
//如果希望每个元素都有children属性,可以直接赋值
if (childrenArr.length !== 0) {
father.children = childrenArr;
}
return father.parentId === null;
});
};
console.log(arrToTree2(nodes));
3. Map 对象方案( 时间复杂度)
原理:利用对象的引用性质。先将数组转为 Map,再遍历一次即可完成。这是在大数据量下的首选方案。
const arrToTree3 = (arr) => {
const map = {};
const res = [];
// 1. 建立映射表
arr.forEach((item) => {
map[item.id] = { ...item, children: [] };
});
// 2. 组装树结构
arr.forEach((item) => {
const node = map[item.id];
if (item.parentId === null) {
res.push(node);
} else {
if (map[item.parentId]) {
map[item.parentId].children.push(node);
}
}
});
return res;
};
console.log(arrToTree3(nodes));
三、 树转数组
1. 递归遍历思路
原理:定义一个结果数组,递归遍历树的每一层,将节点信息(排除 children)推入数组。
- 首先定义一个结果数组res,遍历传入的树
- 直接将当前项的id、name、parentId包装在一个新对象里插入
- 判断是否有children属性,如果有则遍历children属性每一项,继续执行2、3步骤
let tree = [
{
id: 1,
name: 'text1',
parentId: 1,
children: [
{
id: 2,
name: 'text2',
parentId: 1,
children: [
{
id: 4,
name: 'text4',
parentId: 2,
},
],
},
{
id: 3,
name: 'text3',
parentId: 1,
},
],
},
];
const treeToArr = (tree) => {
const res = [];
tree.forEach((item) => {
const loop = (data) => {
res.push({
id: data.id,
name: data.name,
parseId: data.parentId,
});
if (data.children) {
data.children.forEach((itemChild) => {
loop(itemChild);
});
}
};
loop(item);
});
return res;
};
console.log(treeToArr(tree));
四、 注意事项:深拷贝的必要性
在处理这些转换时,由于 JS 的对象是引用类型,直接修改 item.children 会改变原始数组的内容。
-
快捷方案:
const cloneArr = JSON.parse(JSON.stringify(arr))。 -
避坑点:如果数组项中包含
Date对象、RegExp或Function,JSON.parse会导致数据失真,此时应使用其他深拷贝方案。