Skip to content

当筛选功能遇上树形结构

背景

在管理系统中,分类、部门、菜单等数据通常采用树形结构组织。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)

Released under the MIT License.