从平面到层次:JavaScript 实现JSON数组到树形菜单的优雅转换
在现代前端开发中,树形结构是一种非常常见且重要的UI组件,它被广泛用于展示具有层级关系的数据,例如文件系统、组织架构、评论嵌套、商品分类等,我们从后端API获取的数据可能是一个扁平的JSON数组,每个元素都包含其自身的ID和父ID,我们的任务就是将这些“扁平”的数据,通过JavaScript,巧妙地“编织”成一个可供渲染的树形结构。
本文将带你一步步了解如何实现这个过程,从核心思路到具体的代码实现,并最终展示如何将生成的树形数据渲染成菜单。
核心思路:寻找“父子”关系
将JSON数组转换为树形结构的核心思想是:为每个数据项找到它的“孩子”。
一个扁平的数组可能长这样:
[
{ "id": 1, "name": "技术部", "parentId": null },
{ "id": 2, "name": "前端组", "parentId": 1 },
{ "id": 3, "name": "后端组", "parentId": 1 },
{ "id": 4, "name": "React", "parentId": 2 },
{ "id": 5, "name": "Vue", "parentId": 2 },
{ "id": 6, "name": "Java", "parentId": 3 },
{ "id": 7, "name": "运维组", "parentId": 1 }
]
我们的目标是把它变成这样:
{
"id": 1,
"name": "技术部",
"parentId": null,
"children": [
{ "id": 2, "name": "前端组", "parentId": 1, "children": [
{ "id": 4, "name": "React", "parentId": 2, "children": [] },
{ "id": 5, "name": "Vue", "parentId": 2, "children": [] }
]},
{ "id": 3, "name": "后端组", "parentId": 1, "children": [
{ "id": 6, "name": "Java", "parentId": 3, "children": [] }
]},
{ "id": 7, "name": "运维组", "parentId": 1, "children": [] }
]
}
实现这个目标最经典、最高效的方法是使用 Map 数据结构。
实现步骤:使用 Map 进行高效转换
Map 对象允许我们使用任意类型的值作为键,并且它的查找、插入和删除操作的时间复杂度接近 O(1),非常适合我们的场景。
以下是详细的实现步骤:
第一步:准备数据
我们使用上面提到的扁平数组。
第二步:创建一个 Map 并建立 ID 到节点的映射
我们遍历原始数组,将每个节点以其 id 为键存储到 Map 中,这一步的目的是为了后续能够快速地通过 parentId 找到其父节点。
const flatData = [
{ "id": 1, "name": "技术部", "parentId": null },
{ "id": 2, "name": "前端组", "parentId": 1 },
// ... 其他数据
];
const nodeMap = new Map();
// 第一次遍历:将所有节点存入 Map
flatData.forEach(node => {
// 初始化 children 数组,防止后续操作出错
node.children = [];
nodeMap.set(node.id, node);
});
第三步:第二次遍历,构建父子关系
再次遍历原始数组,对于每一个节点,我们通过它的 parentId 去 Map 中查找它的父节点。
- 如果找到了父节点(
parentNode存在),就将当前节点推入父节点的children数组中。 - 如果没找到父节点(
parentId为null或不存在于数据中),那么这个节点就是根节点。
const rootNodes = []; // 用于存放所有根节点
flatData.forEach(node => {
const parentId = node.parentId;
if (parentId !== null && nodeMap.has(parentId)) {
const parentNode = nodeMap.get(parentId);
parentNode.children.push(node);
} else {
// 如果没有父ID,或者父ID在Map中不存在,则视为根节点
rootNodes.push(node);
}
});
第四步:获取最终的树形结构
经过第三步,nodeMap 中的所有节点都已经通过 children 属性连接起来,我们只需要返回第二步中收集到的 rootNodes 数组即可,如果整个数据结构只有一个根,rootNodes 数组里就只有一个元素。
console.log(rootNodes); // 输出结果就是我们期望的树形结构
完整代码示例
将以上步骤整合成一个可复用的函数,代码会更加清晰。
/**
* 将扁平的JSON数组转换为树形结构
* @param {Array} flatData 扁平化的数据数组
* @param {string|number} [idKey='id'] 作为唯一标识的键名
* @param {string|number} [parentIdKey='parentId'] 作为父级ID的键名
* @param {string} [childrenKey='children'] 子节点数组的键名
* @returns {Array} 转换后的树形结构数组
*/
function arrayToTree(flatData, idKey = 'id', parentIdKey = 'parentId', childrenKey = 'children') {
const nodeMap = new Map();
const rootNodes = [];
// 1. 第一次遍历:建立节点索引
flatData.forEach(node => {
// 确保每个节点都有一个 children 属性
node[childrenKey] = node[childrenKey] || [];
nodeMap.set(node[idKey], node);
});
// 2. 第二次遍历:构建父子关系
flatData.forEach(node => {
const parentId = node[parentIdKey];
if (parentId !== null && nodeMap.has(parentId)) {
const parentNode = nodeMap.get(parentId);
parentNode[childrenKey].push(node);
} else {
// 没有父节点的,视为根节点
rootNodes.push(node);
}
});
return rootNodes;
}
// --- 使用示例 ---
const flatData = [
{ "id": 1, "name": "技术部", "parentId": null },
{ "id": 2, "name": "前端组", "parentId": 1 },
{ "id": 3, "name": "后端组", "parentId": 1 },
{ "id": 4, "name": "React", "parentId": 2 },
{ "id": 5, "name": "Vue", "parentId": 2 },
{ "id": 6, "name": "Java", "parentId": 3 },
{ "id": 7, "name": "运维组", "parentId": 1 }
];
const treeData = arrayToTree(flatData);
console.log(JSON.stringify(treeData, null, 2));
如何渲染树形菜单
数据转换完成后,下一步就是将其渲染成用户可见的菜单,在JavaScript中,我们通常使用递归来处理树形结构的渲染。
下面是一个简单的递归渲染函数,它会生成一个无序列表(<ul> 和 <li>)。
/**
* 递归渲染树形菜单
* @param {Array} treeData 树形数据
* @param {HTMLElement} container 容器元素
*/
function renderTreeMenu(treeData, container) {
// 创建根 <ul>
const ul = document.createElement('ul');
ul.style.listStyle = 'none';
ul.style.paddingLeft = '20px';
treeData.forEach(node => {
// 创建 <li>
const li = document.createElement('li');
li.textContent = node.name;
li.style.padding = '5px 0';
li.style.cursor = 'pointer';
// 如果有子节点,递归渲染子菜单
if (node.children && node.children.length > 0) {
const childUl = renderTreeMenu(node.children, document.createElement('div'));
// 默认隐藏子菜单
childUl.style.display = 'none';
li.appendChild(childUl);
// 点击父节点时,切换子菜单的显示/隐藏
li.addEventListener('click', (e) => {
e.stopPropagation(); // 防止事件冒泡
childUl.style.display = childUl.style.display === 'none' ? 'block' : 'none';
});
}
ul.appendChild(li);
});
return ul;
}
// --- 在页面上渲染 ---
const treeContainer = document.getElementById('tree-menu-container');
if (treeContainer) {
const menuElement = renderTreeMenu(treeData, treeContainer);
treeContainer.appendChild(menuElement);
}


还没有评论,来说两句吧...