当筛选功能遇上树形结构
背景
在管理系统中,分类、部门、菜单等数据通常采用树形结构组织。Bush & Jungle 的分类管理就是这样的场景。
树形结构的筛选有其特殊性:当筛选子节点时,是否要保留父节点? 不同的策略会带来不同的用户体验。
筛选策略对比
策略 A:保留完整路径(最常见)✅ Bush/Jungle 采用
原理: 如果子节点匹配,保留整条从根到该节点的路径
示例:
原始树(鲜花分类):
├─ 鲜花(禁用)
│ ├─ 玫瑰(启用)
│ └─ 百合(禁用)
└─ 绿植(启用)
└─ 多肉(启用)
筛选"已启用"后:
├─ 鲜花(禁用)← 保留,因为有启用的子节点(玫瑰)
│ └─ 玫瑰(启用)
└─ 绿植(启用)
└─ 多肉(启用)实现代码:
typescript
// 模拟数据
const categories = [
{
id: 1,
name: '鲜花',
isActive: false,
children: [
{ id: 2, name: '玫瑰', isActive: true, children: [] },
{ id: 3, name: '百合', isActive: false, children: [] }
]
},
{
id: 4,
name: '绿植',
isActive: true,
children: [
{ id: 5, name: '多肉', isActive: true, children: [] }
]
}
];
// 策略 A:保留完整路径
function filterWithPath(nodes) {
return nodes.reduce((result, node) => {
// 递归筛选子节点
const filteredChildren = node.children
? filterWithPath(node.children)
: [];
// 判断:节点匹配 或 有匹配的子节点
const nodeMatches = node.isActive === true;
const hasMatchingChildren = filteredChildren.length > 0;
// 保留节点(本身匹配 或 子节点匹配)
if (nodeMatches || hasMatchingChildren) {
result.push({
...node,
children: filteredChildren.length > 0 ? filteredChildren : node.children
});
}
return result;
}, []);
}
// 使用示例:筛选已启用的分类
const filtered = filterWithPath(categories);
console.log(filtered);
// 输出结果:
// [
// {
// "id": 1,
// "name": "鲜花",
// "isActive": false, ← 父节点被保留
// "children": [
// { "id": 2, "name": "玫瑰", "isActive": true, "children": [] }
// ]
// },
// {
// "id": 4,
// "name": "绿植",
// "isActive": true,
// "children": [
// { "id": 5, "name": "多肉", "isActive": true, "children": [] }
// ]
// }
// ]优点:
- ✅ 保留上下文,用户能看到层级关系
- ✅ 符合直觉,不会"丢失"数据
- ✅ 适合大多数场景
缺点:
- ❌ 结果中会包含不匹配的节点(父节点)
- ❌ 用户可能困惑:为什么"禁用"的节点也显示出来了?
适用场景:
typescript
✅ Ant Design Tree 组件的默认行为
✅ VS Code 文件树搜索
✅ JIRA 的 Epic/Story 层级筛选
✅ 我们的分类管理(典型场景)策略 B:仅显示匹配节点(保留树形结构)
原理: 只显示完全符合条件的节点,但保留父子层级关系
示例:
原始树:
├─ 鲜花(禁用)
│ ├─ 玫瑰(启用)
│ │ └─ 红玫瑰(启用)
│ └─ 百合(禁用)
└─ 绿植(启用)
└─ 多肉(启用)
筛选"已启用"后:
├─ 玫瑰(启用)← 丢失了父节点"鲜花",但保留了树形结构
│ └─ 红玫瑰(启用)
├─ 绿植(启用)
└─ 多肉(启用)← 仍然是绿植的子节点实现代码:
typescript
// 模拟数据
const categories = [
{
id: 1,
name: '鲜花',
isActive: false,
children: [
{
id: 2,
name: '玫瑰',
isActive: true,
children: [
{ id: 6, name: '红玫瑰', isActive: true, children: [] }
]
},
{ id: 3, name: '百合', isActive: false, children: [] }
]
},
{
id: 4,
name: '绿植',
isActive: true,
children: [
{ id: 5, name: '多肉', isActive: true, children: [] }
]
}
];
// 策略 B:仅显示匹配节点(保留树形结构)
function strictFilter(nodes) {
const results = [];
for (const node of nodes) {
// 先递归处理子节点
const filteredChildren = node.children ? strictFilter(node.children) : [];
// 如果当前节点匹配,保留它和它的子节点
if (node.isActive === true) {
results.push({
...node,
children: filteredChildren.length > 0 ? filteredChildren : undefined
});
} else {
// 当前节点不匹配,但子节点匹配了,把子节点提升到当前层级
results.push(...filteredChildren);
}
}
return results;
}
// 使用示例:只保留已启用的分类
const filtered = strictFilter(categories);
console.log(filtered);
// 输出结果:
// [
// {
// "id": 2,
// "name": "玫瑰", ← "鲜花"被过滤掉了
// "isActive": true,
// "children": [
// { "id": 6, "name": "红玫瑰", "isActive": true }
// ]
// },
// {
// "id": 4,
// "name": "绿植",
// "isActive": true,
// "children": [
// { "id": 5, "name": "多肉", "isActive": true }
// ]
// }
// ]与策略 C 的区别:
策略 B(保留树形):
├─ 玫瑰(启用)
│ └─ 红玫瑰(启用) ← 仍然有父子关系
└─ 绿植(启用)
└─ 多肉(启用)
策略 C(完全拍平):
- 玫瑰(启用) ← 都在同一层级,无父子关系
- 红玫瑰(启用)
- 绿植(启用)
- 多肉(启用)优点:
- ✅ 结果精确,数据量小
- ✅ 适合统计场景(只统计启用的)
缺点:
- ❌ 丢失层级关系
- ❌ 用户看不到上下文
适用场景:
typescript
✅ 数据报表(只统计特定状态)
✅ API 返回(只返回有效数据)策略 C:平铺展示
原理: 将树形结构完全拍平,所有匹配节点展示在同一层级
示例:
原始树:
├─ 鲜花
│ ├─ 玫瑰
│ │ └─ 红玫瑰
│ └─ 百合
└─ 绿植
筛选名称包含"玫瑰"后(平铺):
- 玫瑰(路径:鲜花 > 玫瑰)
- 红玫瑰(路径:鲜花 > 玫瑰 > 红玫瑰)
所有结果都在同一层级,附带路径信息实现代码:
typescript
// 模拟数据
const categories = [
{
id: 1,
name: '鲜花',
children: [
{
id: 2,
name: '玫瑰',
children: [
{ id: 6, name: '红玫瑰', children: [] }
]
},
{ id: 3, name: '百合', children: [] }
]
},
{
id: 4,
name: '绿植',
children: []
}
];
// 策略 C:平铺展示
function flattenAndFilter(nodes, path = []) {
const results = [];
for (const node of nodes) {
const currentPath = [...path, node.name];
// 节点匹配则添加到结果(附带路径信息)
if (node.name.includes('玫瑰')) { // 直接写筛选条件
results.push({
...node,
pathText: currentPath.join(' > ') // 路径:鲜花 > 玫瑰
});
}
// 递归处理子节点
if (node.children) {
results.push(...flattenAndFilter(node.children, currentPath));
}
}
return results;
}
// 使用示例:搜索名称包含"玫瑰"的分类
const filtered = flattenAndFilter(categories);
console.log(filtered);
// 输出结果:
// [
// {
// "id": 2,
// "name": "玫瑰",
// "pathText": "鲜花 > 玫瑰", ← 附带路径信息
// "children": [...]
// },
// {
// "id": 6,
// "name": "红玫瑰",
// "pathText": "鲜花 > 玫瑰 > 红玫瑰", ← 所有结果都在同一层级
// "children": []
// }
// ]优点:
- ✅ 搜索结果清晰
- ✅ 适合全局搜索
缺点:
- ❌ 无法展开/折叠
- ❌ 失去树形结构
适用场景:
typescript
✅ 全局搜索(不关心层级)
✅ GitHub 的文件搜索结果
✅ 数据导出(CSV/Excel)