在开发基于React.js或Vue.js的前端项目时,我们经常会遇到需要将后端返回的扁平数据结构转换为前端组件所需的树形结构的需求。特别是使用Ant Design(antd)这类UI组件库时,级联选择器(Cascader)组件要求的数据格式就是典型的树形结构。
我最近在重构一个企业管理系统时,就遇到了部门数据从扁平列表到树形结构的转换需求。后端返回的数据结构是典型的扁平化部门列表,每个部门通过parentId字段建立关联关系。而前端antd的Cascader组件需要的是嵌套的树形结构数据。
这个转换过程看似简单,但实际开发中需要考虑性能优化、排序逻辑、边界条件处理等多个细节。下面我就详细分享这个数据转换的实现方案,包含完整的代码实现和我在实际项目中积累的经验技巧。
首先我们需要明确两种数据结构的差异:
扁平列表结构:
javascript复制const flatData = [
{ deptId: 1, deptName: '总部门', parentId: 0, orderNum: 1 },
{ deptId: 2, deptName: '技术部', parentId: 1, orderNum: 2 },
{ deptId: 3, deptName: '产品部', parentId: 1, orderNum: 1 },
{ deptId: 4, deptName: '前端组', parentId: 2, orderNum: 1 }
]
antd Cascader需要的树形结构:
javascript复制const treeData = [
{
value: 1,
label: '总部门',
children: [
{
value: 3,
label: '产品部',
children: []
},
{
value: 2,
label: '技术部',
children: [
{ value: 4, label: '前端组', children: [] }
]
}
]
}
]
关键差异点:
当部门数量较多时(比如大型企业的部门结构可能有几百个节点),转换算法的性能就变得很重要。我们需要避免在转换过程中出现O(n²)的时间复杂度。
常见的性能优化点:
首先我们需要准备两个关键变量:
javascript复制const deptMap = new Map(); // 用于快速查找节点的Map
const treeData = []; // 最终返回的树形数据
使用Map而不是普通对象的原因:
javascript复制flatDeptData.forEach(item => {
const node = {
value: item.deptId,
label: item.deptName,
parentId: item.parentId,
children: [],
orderNum: item.orderNum // 保留排序字段
};
deptMap.set(item.deptId, node);
});
这里我们保留了parentId字段,因为在后续建立关联关系时还需要使用。同时将orderNum也保存在节点中,为后续排序做准备。
javascript复制flatDeptData.forEach(item => {
const currentNode = deptMap.get(item.deptId);
const parentId = item.parentId;
if (parentId === 0 || parentId === '0') {
treeData.push(currentNode);
} else {
const parentNode = deptMap.get(parentId);
if (parentNode) {
parentNode.children.push(currentNode);
}
}
});
这里有几个关键点需要注意:
javascript复制const sortChildrenByOrder = (nodes) => {
nodes.forEach(node => {
node.children.sort((a, b) => {
const orderA = Number(a.orderNum || 0);
const orderB = Number(b.orderNum || 0);
return orderA - orderB;
});
sortChildrenByOrder(node.children);
});
};
排序逻辑说明:
对于大型树结构,递归排序可能会有性能问题。如果树的深度很大(比如超过10层),可以考虑以下优化:
javascript复制const sortTreeByOrder = (rootNodes) => {
const stack = [...rootNodes];
while (stack.length) {
const node = stack.pop();
if (node.children && node.children.length) {
node.children.sort((a, b) => (a.orderNum || 0) - (b.orderNum || 0));
stack.push(...node.children);
}
}
};
javascript复制/**
* 将扁平部门列表转换为antd Cascader需要的树形结构
* @param {Array} flatDeptData 扁平部门数据
* @returns {Array} 树形结构数据
*/
const convertDeptToCascaderData = (flatDeptData) => {
// 参数校验
if (!Array.isArray(flatDeptData)) {
console.warn('convertDeptToCascaderData: 参数必须是数组');
return [];
}
const deptMap = new Map();
const treeData = [];
// 第一次遍历:构建节点Map
flatDeptData.forEach(item => {
if (!item.deptId) {
console.warn('部门数据缺少deptId字段', item);
return;
}
const node = {
value: item.deptId,
label: item.deptName,
parentId: item.parentId,
children: [],
orderNum: item.orderNum
};
deptMap.set(item.deptId, node);
});
// 第二次遍历:建立树形关系
flatDeptData.forEach(item => {
const currentNode = deptMap.get(item.deptId);
if (!currentNode) return;
const parentId = item.parentId;
if (parentId === 0 || parentId === '0') {
treeData.push(currentNode);
} else {
const parentNode = deptMap.get(parentId);
if (parentNode) {
parentNode.children.push(currentNode);
} else {
console.warn(`找不到父节点ID: ${parentId}`, item);
}
}
});
// 递归排序
const sortChildrenByOrder = (nodes) => {
nodes.forEach(node => {
if (node.children && node.children.length > 1) {
node.children.sort((a, b) => {
const orderA = Number(a.orderNum || 0);
const orderB = Number(b.orderNum || 0);
return orderA - orderB;
});
sortChildrenByOrder(node.children);
}
});
};
sortChildrenByOrder(treeData);
return treeData;
};
在实际项目中,后端返回的数据可能存在各种边界情况,我们需要增强代码的健壮性:
javascript复制if (!flatDeptData || flatDeptData.length === 0) {
return [];
}
javascript复制if (!item.deptId || !item.deptName) {
console.warn('部门数据缺少必要字段', item);
continue; // 跳过无效数据
}
javascript复制// 在建立父子关系前检查是否会造成循环引用
const isCircular = (parent, child) => {
let current = parent;
while (current) {
if (current.value === child.value) return true;
current = deptMap.get(current.parentId);
}
return false;
};
if (parentNode && isCircular(currentNode, parentNode)) {
console.warn('检测到循环引用', currentNode, parentNode);
continue;
}
对于大型组织架构数据(超过500个节点),我们可以进一步优化:
javascript复制for (let i = 0; i < flatDeptData.length; i++) {
const item = flatDeptData[i];
// ...处理逻辑
}
javascript复制const nodes = new Array(flatDeptData.length);
flatDeptData.forEach((item, index) => {
nodes[index] = {
value: item.deptId,
// ...其他字段
};
});
为转换函数编写单元测试是保证稳定性的重要手段,主要测试用例包括:
示例测试代码(使用Jest):
javascript复制describe('convertDeptToCascaderData', () => {
test('正常转换扁平数据为树形结构', () => {
const flatData = [
{ deptId: 1, deptName: '总部', parentId: 0, orderNum: 1 },
{ deptId: 2, deptName: '技术部', parentId: 1, orderNum: 2 }
];
const result = convertDeptToCascaderData(flatData);
expect(result).toHaveLength(1);
expect(result[0].children).toHaveLength(1);
});
test('处理空数据', () => {
expect(convertDeptToCascaderData([])).toEqual([]);
expect(convertDeptToCascaderData(null)).toEqual([]);
});
});
转换后的数据可以直接用于antd Cascader组件:
jsx复制import { Cascader } from 'antd';
const DeptSelector = ({ deptData }) => {
const treeData = convertDeptToCascaderData(deptData);
return (
<Cascader
options={treeData}
placeholder="请选择部门"
changeOnSelect
fieldNames={{ label: 'label', value: 'value', children: 'children' }}
/>
);
};
使用技巧:
changeOnSelect允许选择任意层级fieldNames可以自定义字段名(如果你的数据结构使用不同字段名)loadData实现异步加载节点丢失问题:
排序无效问题:
性能问题:
这个转换算法不仅适用于部门数据,还可以应用于其他树形结构数据的转换,比如:
只需要调整字段映射关系即可复用核心逻辑。例如对于地区数据:
javascript复制const convertAreaData = (flatAreaData) => {
// 字段名调整
const node = {
value: item.areaCode,
label: item.areaName,
parentId: item.parentCode,
children: []
};
// 其余逻辑相同
};
除了本文介绍的两遍遍历+Map的方案外,还有其他几种常见的树形结构转换方案:
javascript复制function buildTree(items, parentId = 0) {
return items
.filter(item => item.parentId === parentId)
.map(item => ({
value: item.deptId,
label: item.deptName,
children: buildTree(items, item.deptId)
}));
}
优缺点:
比如使用lodash的_.transform等工具函数:
javascript复制import _ from 'lodash';
function convertByLodash(data) {
return _.transform(data, (result, item) => {
const node = { value: item.deptId, label: item.deptName };
if (!item.parentId) {
result.push(node);
} else {
const parent = _.find(result, { value: item.parentId });
if (parent) {
(parent.children || (parent.children = [])).push(node);
}
}
}, []);
}
优缺点:
根据实际场景选择合适方案:
jsx复制import React, { useState, useEffect } from 'react';
import { Cascader } from 'antd';
const DeptCascader = ({ api }) => {
const [treeData, setTreeData] = useState([]);
useEffect(() => {
const fetchData = async () => {
try {
const response = await api.getDeptList();
const data = convertDeptToCascaderData(response.data);
setTreeData(data);
} catch (error) {
console.error('获取部门数据失败:', error);
}
};
fetchData();
}, [api]);
return (
<Cascader
options={treeData}
placeholder="请选择部门"
/>
);
};
vue复制<template>
<a-cascader
v-model="selectedDept"
:options="treeData"
placeholder="请选择部门"
/>
</template>
<script>
import { ref, onMounted } from 'vue';
import { getDeptList } from '@/api/dept';
export default {
setup() {
const treeData = ref([]);
const selectedDept = ref([]);
onMounted(async () => {
try {
const res = await getDeptList();
treeData.value = convertDeptToCascaderData(res.data);
} catch (err) {
console.error('获取部门数据失败:', err);
}
});
return { treeData, selectedDept };
}
};
</script>
javascript复制let cachedTreeData = null;
const getDeptTreeData = async () => {
if (cachedTreeData) return cachedTreeData;
const res = await getDeptList();
cachedTreeData = convertDeptToCascaderData(res.data);
return cachedTreeData;
};
jsx复制import { VirtualScroll } from 'antd';
<VirtualScroll
data={treeData}
height={400}
itemHeight={32}
>
{(node) => (
<div>{node.label}</div>
)}
</VirtualScroll>
javascript复制const loadData = async (selectedOptions) => {
const targetOption = selectedOptions[selectedOptions.length - 1];
targetOption.loading = true;
const children = await api.getDeptChildren(targetOption.value);
targetOption.children = convertDeptToCascaderData(children);
targetOption.loading = false;
setTreeData([...treeData]);
};
<Cascader loadData={loadData} />
在实际项目中实现这个功能时,我总结了以下几点经验:
数据质量很重要:确保后端返回的数据parentId引用完整,没有循环引用
性能监控不可少:对于大型组织,使用performance API测量转换耗时:
javascript复制const start = performance.now();
const treeData = convertDeptToCascaderData(largeData);
console.log(`转换耗时: ${performance.now() - start}ms`);
typescript复制interface FlatDept {
deptId: number | string;
deptName: string;
parentId: number | string;
orderNum?: number | string;
}
interface TreeNode {
value: number | string;
label: string;
children: TreeNode[];
parentId?: number | string;
}
function convertDeptToCascaderData(flatDeptData: FlatDept[]): TreeNode[] {
// 实现逻辑
}
javascript复制function convertToSelectTree(data, format = 'antd') {
if (format === 'element') {
return convertForElementUI(data);
}
return convertForAntd(data);
}
javascript复制/**
* 将扁平部门列表转换为树形结构
* @param {Array} flatDeptData - 扁平部门数据
* @param {Object} options - 配置项
* @param {string} [options.idKey='deptId'] - ID字段名
* @param {string} [options.nameKey='deptName'] - 名称字段名
* @returns {Array} 树形结构数据
*/
function convertToTree(flatDeptData, options = {}) {
// 实现逻辑
}
这个数据转换问题看似简单,但实际开发中需要考虑的细节很多。经过多个项目的实践,我认为本文介绍的两遍遍历+Map的方案在大多数场景下都是最佳选择,它在代码可读性、性能和功能完备性之间取得了很好的平衡。