@tni/tree
v2.0.0
Published
TNI 项目树结构工具库
Readme
@tni/tree
通用树结构工具库,提供数组转树、树转数组、节点查找、路径查找、筛选、插入、删除、移动、层级修复等能力。
适合处理:
- 菜单树、组织架构、分类树
- 表格树、级联选择器、目录树
- 依赖
id / parentId / children模式的前后端数据转换 jsTree一类“只有 level 没有 children”的线性树数据修复
安装
vp add @tni/tree导出内容
import {
SKIP_CHILDREN,
STOP_TRAVERSAL,
arrayToTree,
treeToArray,
traverseTree,
findTreeNode,
findTreeNodes,
findTreePath,
findTreePaths,
filterTree,
insertNodeBefore,
insertNodeAfter,
removeTreeNode,
removeTreeNodes,
updateTreeNode,
moveTreeNode,
getTreeMaxDepth,
getNodeChain,
getSiblingNodes,
getChildNodes,
getLeafNodes,
traverseLeafNodes,
setTreeLevelAndLeaf,
getAncestorNode,
countLeafNodes,
repairJSTree,
supplementJSTreeLevel,
getArrayChildren,
getArrayParent,
findNodeById,
createTreeHelper,
type TreeConfig,
type TreeVisitContext,
} from "@tni/tree";快速开始
import { arrayToTree, findTreePath, getLeafNodes, treeToArray } from "@tni/tree";
type Node = {
id: number;
parentId: number | null;
name: string;
children?: Node[];
};
const list: Node[] = [
{ id: 1, parentId: null, name: "华东" },
{ id: 2, parentId: 1, name: "上海" },
{ id: 3, parentId: 1, name: "杭州" },
{ id: 4, parentId: 3, name: "西湖区" },
];
const tree = arrayToTree(list);
const path = findTreePath(tree, 4).map((item) => item.name);
const leaves = getLeafNodes(tree).map((item) => item.name);
const flat = treeToArray(tree);设计约定
使用前建议先了解这几个约定,避免误判行为。
1. 默认字段名
默认把节点看作以下结构:
{
id: unknown;
parentId: unknown;
children: T[];
level: number;
isLeaf: boolean;
}默认配置如下:
| 配置项 | 默认值 |
| ------------------ | ------------------- |
| idKey | "id" |
| parentIdKey | "parentId" |
| childrenKey | "children" |
| levelKey | "level" |
| isLeafKey | "isLeaf" |
| rootParentValues | [undefined, null] |
2. 大多数“改结构”的方法都不会原地修改入参
以下方法会返回新的树或数组副本:
arrayToTreetreeToArrayfilterTreeinsertNodeBeforeinsertNodeAfterremoveTreeNoderemoveTreeNodesupdateTreeNodemoveTreeNodesetTreeLevelAndLeafrepairJSTreesupplementJSTreeLevel
以下方法主要做遍历或查询,返回的是原树中的节点引用:
findTreeNodefindTreeNodesfindTreePathfindTreePathsfindNodeByIdgetSiblingNodesgetChildNodesgetLeafNodesgetAncestorNodegetArrayChildrengetArrayParent
3. 节点 id 应保持唯一
库内部很多方法依赖 id 来匹配节点。如果同一棵树里有重复 id,多数行为都会变得不可靠,例如:
arrayToTree可能把后面的同 id 节点覆盖前面的节点findNodeById/moveTreeNode/removeTreeNode可能命中错误节点
4. 根节点的 parentId
默认认为 undefined 和 null 都是合法根节点标记。
arrayToTree遇到parentId是根值时会把节点作为根节点treeToArray会把根节点的parentId规范化为rootParentValues[0]moveTreeNode把节点移动到根层时,也会写入rootParentValues[0]
如果你的项目约定根节点必须是 0、"root" 或其他值,请通过 rootParentValues 显式配置。
自定义字段
如果你的字段名不是 id / parentId / children,建议用 createTreeHelper 先生成一组绑定配置的方法。
import { createTreeHelper } from "@tni/tree";
type MenuNode = {
key: string;
parentKey: string | null;
label: string;
nodes?: MenuNode[];
};
const tree = createTreeHelper<MenuNode>({
idKey: "key",
parentIdKey: "parentKey",
childrenKey: "nodes",
});
const menuTree = tree.arrayToTree([
{ key: "dashboard", parentKey: null, label: "Dashboard" },
{ key: "analysis", parentKey: "dashboard", label: "Analysis" },
]);常用配置项
所有 API 都支持 TreeConfig,部分方法还会有自己的额外选项。
| 配置项 | 类型 | 说明 |
| ------------------ | ----------- | -------------------------- |
| idKey | string | 节点 id 字段名 |
| parentIdKey | string | 父节点 id 字段名 |
| childrenKey | string | 子节点数组字段名 |
| levelKey | string | 层级字段名 |
| isLeafKey | string | 叶子节点标记字段名 |
| rootParentValues | unknown[] | 被视为根节点的父 id 值集合 |
API 说明
数据转换
arrayToTree(items, options?)
把扁平数组转换成树结构。
额外选项:
| 选项 | 类型 | 默认值 | 说明 |
| ----------------------- | --------- | ------ | -------------------------------------- |
| preserveOrphansAsRoot | boolean | true | 找不到父节点的孤儿节点是否提升为根节点 |
const tree = arrayToTree(list, {
preserveOrphansAsRoot: false,
});treeToArray(tree, options?)
把树结构拍平成数组,默认会删除每个节点的 children 字段。
额外选项:
| 选项 | 类型 | 默认值 | 说明 |
| -------------- | --------- | ------- | ---------------------------------------- |
| keepChildren | boolean | false | 扁平化后是否保留节点上的 children 字段 |
const flat = treeToArray(tree);createTreeHelper(config?)
返回一组已经绑定字段配置的 helper,适合项目里统一复用。
查询与遍历
traverseTree(tree, visitor, config?)
深度优先遍历整棵树。
visitor 第二个参数是 TreeVisitContext:
| 字段 | 说明 |
| ----------- | --------------------------- |
| parent | 当前节点父节点 |
| ancestors | 当前节点所有祖先节点 |
| path | 从根到当前节点的完整路径 |
| depth | 当前深度,根节点从 1 开始 |
| index | 当前节点在兄弟节点中的索引 |
| siblings | 当前层全部兄弟节点 |
可配合两个控制常量:
SKIP_CHILDREN: 跳过当前节点的所有子节点STOP_TRAVERSAL: 立即终止整棵树遍历
traverseTree(tree, (node) => {
if (node.disabled) return SKIP_CHILDREN;
if (node.id === 100) return STOP_TRAVERSAL;
});findTreeNode(tree, matcher, config?)
查找第一个匹配节点。
findTreeNodes(tree, matcher, config?)
查找所有匹配节点。
findTreePath(tree, matcher, config?)
返回第一个匹配节点从根到当前节点的路径。
findTreePaths(tree, matcher, config?)
返回所有匹配节点的路径集合。
findNodeById(tree, id, config?)
按 id 查找节点,等价于 findTreeNode(tree, id, config)。
getNodeChain(tree, matcher, config?)
返回节点链,当前实现等价于 findTreePath。
结构筛选与变更
filterTree(tree, predicate, options?)
筛选树结构。只要当前节点命中,或者子树里存在命中节点,就会保留当前节点。
额外选项:
| 选项 | 类型 | 默认值 | 说明 |
| ------------------------------- | --------- | ------- | ------------------------------------------ |
| includeDescendantsWhenMatched | boolean | false | 当前节点命中后,是否直接保留它的完整后代树 |
const visibleTree = filterTree(tree, (node) => node.name.includes(keyword), {
includeDescendantsWhenMatched: true,
});insertNodeBefore(tree, oldNode, newNode, config?)
在目标节点前插入新节点。
insertNodeAfter(tree, oldNode, newNode, config?)
在目标节点后插入新节点。
removeTreeNode(tree, matcher, config?)
删除第一个匹配节点。
removeTreeNodes(tree, matcher, config?)
删除所有匹配节点。
updateTreeNode(tree, matcher, updater, config?)
更新第一个匹配节点。
updater 可以是对象,也可以是函数:
const nextTree = updateTreeNode(tree, 4, (node) => ({
name: `${node.name}-updated`,
status: "active",
}));moveTreeNode(tree, source, target, options?)
移动节点位置。
额外选项:
| 选项 | 类型 | 默认值 | 说明 |
| ---------- | --------------------------------- | ---------- | --------------------------------------------------- |
| position | "before" \| "after" \| "inside" | "inside" | 插入到目标节点前、后或内部 |
| insertAt | "start" \| "end" | "end" | 当 position === "inside" 时插入到子节点头部或尾部 |
说明:
- 不允许把节点移动到它自己的后代内部
- 把节点移动到根层时,会把
parentId写成rootParentValues[0]
const moved = moveTreeNode(tree, 4, 5, {
position: "inside",
insertAt: "start",
});关系查询
getSiblingNodes(tree, matcher, options?)
获取兄弟节点。
额外选项:
| 选项 | 类型 | 默认值 | 说明 |
| ------------- | --------- | ------- | -------------------------------- |
| includeSelf | boolean | false | 是否把当前节点本身也包含在结果里 |
getChildNodes(tree, matcher, options?)
获取子节点。
额外选项:
| 选项 | 类型 | 默认值 | 说明 |
| ------ | --------- | ------- | ------------------------ |
| deep | boolean | false | 是否递归返回全部后代节点 |
getAncestorNode(tree, matcher, options?)
获取祖先节点。
额外选项:
| 选项 | 类型 | 默认值 | 说明 |
| ---------- | -------- | ------ | -------------------------------------- |
| distance | number | 1 | 距离当前节点第几层祖先,1 表示父节点 |
getArrayChildren(items, parent, config?)
在扁平数组里获取某个节点的直接子节点。
getArrayParent(items, node, config?)
在扁平数组里获取某个节点的父节点。
叶子节点与统计
getLeafNodes(tree, config?)
获取所有叶子节点。
traverseLeafNodes(tree, visitor, config?)
只遍历叶子节点。
countLeafNodes(tree, config?)
统计叶子节点数量。
getTreeMaxDepth(tree, config?)
获取树最大深度,根节点深度是 1。
setTreeLevelAndLeaf(tree, options?)
为每个节点补齐层级和叶子标记。
额外选项:
| 选项 | 类型 | 默认值 | 说明 |
| ------------ | -------- | ------ | -------------- |
| startLevel | number | 1 | 根节点起始层级 |
const markedTree = setTreeLevelAndLeaf(tree, {
startLevel: 1,
});jsTree 线性树辅助
这两个方法用于处理一类特殊数据:节点没有 children,只靠数组顺序和 level 表示层级。
例如:
[
{ id: 1, level: 1, parentId: null, name: "root" },
{ id: 2, level: 4, parentId: null, name: "child-a" },
{ id: 3, level: 2, parentId: null, name: "child-b" },
];repairJSTree(items, options?)
修正 level / parentId / isLeaf,让线性树数据变成自洽状态。
额外选项:
| 选项 | 类型 | 默认值 | 说明 |
| ------------ | -------- | ------ | ------------ |
| startLevel | number | 1 | 最小起始层级 |
修正规则:
- 第一项层级至少为
startLevel - 后一个节点层级最多只允许比前一个节点深一层
- 会自动重算
parentId - 会自动补齐
isLeaf
supplementJSTreeLevel(items, targetLevel, options?)
先执行 repairJSTree,再把整棵线性树整体平移到指定起始层级。
适合把一段局部树挂到更深的层级上下文中。
matcher 支持哪些写法
多数查找类和结构变更类 API 都使用 matcher 参数,它支持 4 种形式:
1. 直接传 id
findTreeNode(tree, 10);
removeTreeNode(tree, "menu:dashboard");2. 直接传节点对象
默认会按配置中的 idKey 比较 id,而不是要求对象引用必须相同。
moveTreeNode(tree, { id: 4 } as Node, 5);3. 传 predicate 函数
findTreeNodes(tree, (node, context) => {
return context.depth === 3 && node.name.startsWith("leaf");
});4. 在扁平数组辅助方法中也可用 predicate
const parent = getArrayParent(list, (_node, context) => context.index === 3);推荐用法
场景 1:后端返回扁平数组,前端组件需要树
const tree = arrayToTree(apiList);场景 2:前端编辑了一棵树,提交给后端前要拍平
const payload = treeToArray(editedTree);场景 3:搜索树节点并保留祖先链
const filtered = filterTree(tree, (node) => node.name.includes(keyword));场景 4:给 UI 组件补 level / isLeaf
const uiTree = setTreeLevelAndLeaf(tree);场景 5:项目里字段名不是默认值
const helper = createTreeHelper({
idKey: "key",
parentIdKey: "parentKey",
childrenKey: "nodes",
});边界说明
arrayToTree默认会把找不到父节点的孤儿节点提升为根节点;如果不需要,传preserveOrphansAsRoot: falsetreeToArray根节点的parentId会被归一化为rootParentValues[0]getChildNodes(tree, matcher, { deep: true })返回的是全部后代的扁平列表,不是子树removeTreeNode/updateTreeNode只处理第一个命中的节点;如果要批量处理,请用removeTreeNodes或自行filterTreemoveTreeNode不允许把节点移动到自己的后代节点下,否则会直接返回原树副本
测试覆盖
当前仓库已覆盖以下行为:
- 所有公开方法的主路径调用
- 遍历控制信号
SKIP_CHILDREN/STOP_TRAVERSAL - 扁平数组辅助方法的 predicate 匹配
treeToArray的根节点parentId归一化moveTreeNode的before / after / inside分支与非法后代移动保护
