前言最近遇到了一个需求,要将算法的标签品类数据以树形的形式回显到后台管理页面上。通过和后端的沟通,决定了通过 parentCode 和 categoryCode 来让前端组成树形结构。通过接口获取的数据是这样的:
其中当节点为 root 节点时,parentCode 为空字符串。知道数据长啥样,我们就可以构建着手构建树了。
格式化数据树形结构首先我们要先确定树中的 node 类型应该是长怎么样的,明显我们只需要将数据关联起来,而原数据我们希望原封不动保留,所以我们可以用 ts 来定义一下数据结构
123export type Tree = T & { children?: Tree[];};
知道数据结构长啥样就可以着手写一下函数了。
编写函数在 js 中对象中,变量存储的是对象的引用(地址),我们利用这个特性,便可以通过一次遍历来构建出一颗树。
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263// tree.tstype NodeMap = Map>;// 新建一个空nodeconst createEmptyNode = () => { return { children: [], } as Tree;};// 获取nodeconst getNode = (map: NodeMap, id: T[keyof T]): [Tree, boolean] => { let node = map.get(id); const isExist = !!node; if (!node) { node = createEmptyNode(); } return [node, isExist];};// 定义一个将列表转换为树形结构的函数export const listToTreeById = ( arr: T[], options: { parentIdKey: keyof T; // 父节点的键名 idKey: keyof T; // 节点的键名 rootId?: string; // 根节点的ID }) => { // 从选项对象中解构出各个属性 const { parentIdKey, idKey, rootId = '', sortKey, handleRootTree } = options; // 创建一个新的Map对象 const map: NodeMap = new Map(); // 遍历输入的列表 arr.forEach((item) => { // 获取当前元素的父节点ID和自身ID const parentId = item[parentIdKey]; const id = item[idKey]; // 获取或创建当前元素的节点 const [child, isChildExist] = getNode(map, id); // 将当前元素的属性复制到节点上 Object.assign(child, item); // 如果节点不存在,则将其添加到Map中 if (!isChildExist) map.set(id, child); // 获取或创建父节点 const [parent, isParentExist] = getNode(map, parentId); // 将当前节点添加到父节点的子节点列表中 parent.children?.push(child); // 如果父节点不存在,则将其添加到Map中 if (!isParentExist) map.set(parentId, parent); }); // 获取root节点下的node const tree: Tree[] = map.get(rootId as any)?.children || []; // 返回树形结构,如果树形结构为空,则返回空数组 return tree || [];};
注意一下Object.assign(child, item);这个操作,因为后端返回的数组不一定是有序的,也就是有可能先遍历到子节点而再遍历父节点,当遇到这种情况,父节点会提前先创建但没有数据,所以当遍历到父节点时,要通过Object.assign来浅拷贝一下。
自定义返回有时候也会根据某个条件选取树,可以加一个传入函数来让用户灵活地使用。
123456789101112131415161718192021// tree.tsexport const listToTreeById = ( arr: T[], options: { parentIdKey: keyof T; // 父节点的键名 idKey: keyof T; // 节点的键名 rootId?: string; // 根节点的ID+ handleRootTree?: (map: NodeMap) => Tree[]; }) => { ... let tree: Tree[] = []; if (Object.hasOwn(options, 'rootId')) { tree = map.get(rootId as any)?.children || []; } else if (handleRootTree) { tree = handleRootTree(map); } return tree || [];}
排序对于叶子结点来说,往往也是需要排序的,我们通过 sortkey 将已经创建好的树通过 bfs 来进行排序。
123456789101112131415161718192021222324252627282930313233343536373839// tree.tsconst sort = (a: Tree, b: Tree, sortKey: keyof T) => Number(a[sortKey] || '0') - Number(b[sortKey] || '0');const treeSort = (tree: Tree[], sortKey: keyof T) => { const nodeList = [...tree].sort((a, b) => sort(a, b, sortKey)); let i = 0; while (i < nodeList.length) { const node = nodeList[i]; if (node.children) { node.children.sort((a, b) => sort(a, b, sortKey)); nodeList.push(...node.children); } i += 1; }};export const listToTreeById = ( arr: T[], options: { parentIdKey: keyof T; idKey: keyof T; handleRootTree?: (map: NodeMap) => Tree[]; rootId?: string; sortKey?: keyof T; }) => { ... let tree: Tree[] = []; if (Object.hasOwn(options, 'rootId')) { tree = map.get(rootId as any)?.children || []; } else if (handleRootTree) { tree = handleRootTree(map); } if (sortKey) { treeSort(tree, sortKey); } return tree || [];}
使用在 vue 文件中直接使用
123456789const treeList = shallowRef([]);const handleGetCategoryList = async () => { const { data } = await baseCategoryList(); treeList.value = listToTreeById( data, { parentIdKey: 'parentCode', idKey: 'categoryCode', rootId: '' } ); console.log(treeList.value);};
组件编写格式化成我们想要的数据后,接下来就是编写树形组件了。下面只会说一些重点的部分。
递归 or 平铺对于展示树形,很多人第一个思路是递归组件,达到展示树形的结果。一开始我也是这么想的,直到我看了一下 elment ui 中的 tree v2 源码后才发现,其实可以将树形数据当成一个平铺 list 去处理,这样的好处有四个:
减少了递归带来的复杂性和心智负担
没有递归带来的栈溢出风险
调试会比递归容易些
能够使用虚拟列表来优化 tree
数据监听在响应式编程中,对于数据 proxy,一般会使用ref来达成这样的效果,但是对于树形数据来说,全数据收集依赖会给性能带来影响,所以我们应该使用shallowRef来创建 proxy。
对于监听节点展开收起的重新渲染,我们可以维护一个 expandedKeySet 来达成目的。
1const expandedKeySet = ref>(new Set(props.defaultExpandedKeys));
proxy 一个 Set 要比 proxy 整个 tree 的代价还要小得多。下面是具体实现
1234567891011121314151617181920212223242526272829// type.tsexport type TreeKey = string | number;export type TreeNodeData = Record;export interface TreeNode { key: TreeKey; level: number; parent?: TreeNode; children?: TreeNode[]; data: TreeNodeData; disabled?: boolean; label?: string; isLeaf?: boolean;}export interface Tree { treeNodeMap: Map; levelTreeNodeMap: Map; treeNodes: TreeNode[]; maxLevel: number;}export interface TreeOptionProps { children?: string; label?: string; value?: string; disabled?: string;}
12345678910111213141516171819202122232425262728293031323334// use-treeconst expandedKeySet = ref>(new Set(props.defaultExpandedKeys));const flattenTree = computed(() => { const expandedKeys = expandedKeySet.value; const flattenNodes: TreeNode[] = []; const nodes = (tree.value && tree.value.treeNodes) || []; function traverse() { const stack: TreeNode[] = []; for (let i = nodes.length - 1; i >= 0; --i) { stack.push(nodes[i]); } while (stack.length) { const node = stack.pop(); if (node) { flattenNodes.push(node); if (expandedKeys.has(node.key)) { const { children } = node; if (children) { const { length } = children; for (let i = length - 1; i >= 0; --i) { stack.push(children[i]); } } } } } } traverse(); return flattenNodes;});
虚拟列表直接使用@vueuse/core 的 useVirtualList 就可以了
1234567891011121314151617181920212223242526272829303132333435363738394041// tree.vue
完整代码可以看这里。
性能表现
基本维持在 16.7ms 左右,也没用出现卡顿的情况。那么就可以愉快下班啦~
结语学习源码的过程收获良多,最大的启发是对比较大的数据进行响应式渲染时,不妨想想能不能借响应一些相对简单的数据结构来达成,这样能减少响应时的性能问题。还有就是递归组件的情况,下次遇到时,是不是可以将它从平铺的角度去考虑,考虑对于 ui 层面上,父与子的关联性是什么,从而转换为相对简单的数据结构去渲染。
参考
[1] https://github.com/element-plus/element-plus/tree/dev/packages/components/tree-v2