当筛选功能遇上树形结构
背景
在管理系统中,分类、部门、菜单等数据通常采用树形结构组织。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
})
}
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": [] }
// ]
// }
// ]优点:
- 保留上下文,用户能看到层级关系
- 符合直觉,不会"丢失"数据
- 适合大多数场景
缺点:
- 结果中会包含不匹配的节点(父节点)
- 用户可能困惑:为什么"禁用"的节点也显示出来了?
适用场景:
- Ant Design Tree 组件的默认行为
- VS Code 文件树搜索
- JIRA 的 Epic/Story 层级筛选
- 我们的分类管理(典型场景)
策略 B:仅显示匹配节点(保留树形结构)
原理: 只显示完全符合条件的节点,但保留父子层级关系
示例:
原始树:
├─ 鲜花(禁用)
│ ├─ 玫瑰(启用)
│ │ └─ 红玫瑰(启用)
│ └─ 百合(禁用)
└─ 绿植(启用)
└─ 多肉(启用)
筛选"已启用"后:
├─ 玫瑰(启用)← 丢失了父节点"鲜花",但保留了树形结构
│ └─ 红玫瑰(启用)
├─ 绿植(启用)
└─ 多肉(启用)← 仍然是绿植的子节点实现思路 / 方案:
- 后序遍历树:先处理子节点,再决定当前节点
- 若当前节点匹配,保留该节点及其筛选后的子树
- 若当前节点不匹配但存在匹配子节点,将匹配子节点提升到当前层级
- 在匹配子树内保留父子关系,但不保留不匹配的祖先节点
与策略 C 的区别:
策略 B(保留树形):
├─ 玫瑰(启用)
│ └─ 红玫瑰(启用) ← 仍然有父子关系
└─ 绿植(启用)
└─ 多肉(启用)
策略 C(完全拍平):
- 玫瑰(启用) ← 都在同一层级,无父子关系
- 红玫瑰(启用)
- 绿植(启用)
- 多肉(启用)优点:
- 结果精确,数据量小
- 适合统计场景(只统计启用的)
缺点:
- 祖先上下文丢失(不匹配的父节点被移除)
- 仅在匹配子树内保留层级,跨层级的上下文不可见
适用场景:
- 数据报表(只统计特定状态)
- API 返回(只返回有效数据)
策略 C:平铺展示
原理: 将树形结构完全拍平,所有匹配节点展示在同一层级
示例:
原始树:
├─ 鲜花
│ ├─ 玫瑰
│ │ └─ 红玫瑰
│ └─ 百合
└─ 绿植
筛选名称包含"玫瑰"后(平铺):
- 玫瑰(路径:鲜花 > 玫瑰)
- 红玫瑰(路径:鲜花 > 玫瑰 > 红玫瑰)
所有结果都在同一层级,附带路径信息实现思路 / 方案:
- 先序遍历生成路径堆栈(如:鲜花 > 玫瑰 > 红玫瑰)
- 节点命中筛选条件时,将其平铺推入结果集,并附带路径文本或路径数组
- 所有命中节点在同一层级展示,不再保留树形父子关系
- 可选:在结果中附带原始节点引用以便跳转或定位
优点:
- 搜索结果清晰
- 适合全局搜索
缺点:
- 无法展开/折叠
- 失去树形结构
适用场景:
- 全局搜索(不关心层级)
- GitHub 的文件搜索结果
- 数据导出(CSV/Excel)