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.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)

Released under the MIT License.