PowerSearch:构建可组合的前端高级搜索栏

Celestial Globe by Gerhard Emmoser
Gerhard Emmoser,天球仪,1579 — 在一个精密的结构里,每颗星星都有自己的坐标和分类。高级搜索也是如此:用结构化的条件精确定位你要找的东西
这篇文章的价值:几乎每个 B 端产品都需要高级搜索,但大多数实现要么是堆砌输入框的简陋方案,要么是无法组合嵌套的半成品。这里分享一个基于递归条件树的数据模型,让搜索栏天然支持 AND/OR 组合和无限嵌套——数据结构对了,UI 交互和后端映射都会变得简单。

问题

B 端产品的列表页几乎都需要搜索和筛选。最简单的做法是放几个输入框和下拉框,但这在字段多了之后会变得很丑,而且无法表达复杂的逻辑——比如"状态是已完成 (创建者是张三 创建者是李四)"。

像 Jira、Linear、Notion 这类产品的搜索栏,支持用户动态添加筛选条件,选择字段、运算符和值,还能用 AND/OR 组合条件、嵌套分组。这种交互叫 PowerSearch(或 Advanced Filter、Structured Search)。

它看起来复杂,但数据结构其实很清晰——本质上就是一棵条件表达式树。

数据模型

整个系统的类型定义只有几个:

// 字段配置——定义可以搜索哪些字段
type FieldConfig = {
  id: string;          // "status"
  label: string;       // "状态"
  type: "text" | "number" | "date" | "select" | "boolean";
  operators: OperatorOption[];     // 该字段支持的运算符
  options?: { value: string; label: string }[];  // select 类型的选项
};

// 运算符
type OperatorOption = {
  id: string;      // "eq"
  label: string;   // "等于"
  symbol: string;  // "="
};

// 单个筛选条件
type FilterCondition = {
  id: string;
  type: "condition";
  fieldId: string;     // 哪个字段
  operatorId: string;  // 什么运算符
  value: string;       // 比较的值
};

// 条件组(递归结构,支持嵌套)
type ConditionGroup = {
  id: string;
  type: "group";
  operator: "AND" | "OR";
  conditions: (FilterCondition | ConditionGroup)[];  // 可以嵌套
};

关键在最后一个类型:ConditionGroupconditions 数组可以包含 FilterCondition(叶子节点),也可以包含 ConditionGroup(子树)。这就是一棵递归的表达式树。

一个实际的查询长这样:

// "状态 = 已完成 AND (创建者 = 张三 OR 创建者 = 李四)"
{
  id: "root",
  type: "group",
  operator: "AND",
  conditions: [
    {
      id: "c1", type: "condition",
      fieldId: "status", operatorId: "eq", value: "done"
    },
    {
      id: "g1", type: "group",
      operator: "OR",
      conditions: [
        {
          id: "c2", type: "condition",
          fieldId: "creator", operatorId: "eq", value: "zhangsan"
        },
        {
          id: "c3", type: "condition",
          fieldId: "creator", operatorId: "eq", value: "lisi"
        }
      ]
    }
  ]
}

组件 API 设计

使用方只需要提供两样东西:字段配置和搜索回调。

const fields: FieldConfig[] = [
  {
    id: "name",
    label: "名称",
    type: "text",
    operators: [
      { id: "contains", label: "包含", symbol: "⊃" },
      { id: "eq", label: "等于", symbol: "=" },
      { id: "starts_with", label: "开头是", symbol: "^" },
    ],
  },
  {
    id: "status",
    label: "状态",
    type: "select",
    operators: [
      { id: "eq", label: "等于", symbol: "=" },
      { id: "neq", label: "不等于", symbol: "≠" },
    ],
    options: [
      { value: "active", label: "进行中" },
      { value: "done", label: "已完成" },
      { value: "archived", label: "已归档" },
    ],
  },
  {
    id: "created_at",
    label: "创建日期",
    type: "date",
    operators: [
      { id: "after", label: "晚于", symbol: ">" },
      { id: "before", label: "早于", symbol: "<" },
    ],
  },
  {
    id: "priority",
    label: "优先级",
    type: "number",
    operators: [
      { id: "eq", label: "等于", symbol: "=" },
      { id: "gt", label: "大于", symbol: ">" },
      { id: "lt", label: "小于", symbol: "<" },
    ],
  },
  {
    id: "enabled",
    label: "是否启用",
    type: "boolean",
    operators: [
      { id: "eq", label: "等于", symbol: "=" },
    ],
  },
];

function handleSearch(query: ConditionGroup) {
  // 把条件树转成 API 请求
  console.log(JSON.stringify(query, null, 2));
}

<PowerSearchBar
  fields={fields}
  onSearch={handleSearch}
  placeholder="点击添加筛选条件..."
  defaultOperator="AND"
/>

组件内部维护一棵 ConditionGroup 树作为状态。每次用户添加/修改/删除条件,树更新后通过 onSearch 回调出去。调用方拿到这棵树,转成 GraphQL 的 where 子句或 REST API 的 query string,发给后端。

交互流程

添加一个筛选条件是一个三步引导流程:

1. 选字段 → 弹出字段列表(Field Dropdown)
                ┌─────────────────┐
                │ Select Field    │
                ├─────────────────┤
                │ 名称      text  │
                │ 状态    select  │
                │ 创建日期  date  │
                │ 优先级  number  │
                └─────────────────┘

2. 选运算符 → 根据字段类型显示对应的运算符
                ┌─────────────────┐
                │ Select Operator │
                ├─────────────────┤
                │ 等于          = │
                │ 不等于        ≠ │
                └─────────────────┘

3. 输入值 → 根据字段类型显示不同的输入方式
   text   → 文本输入框
   number → 数字输入框
   date   → 日期选择器
   select → 选项列表
   boolean → true/false 选择

每一步完成后自动进入下一步,三步走完自动提交条件。这个渐进式引导比直接显示三个并排的下拉框体验好得多——用户每次只需要做一个决策。

状态管理

组件内部有两个核心状态:

// 1. 条件树(完整的查询结构)
const [rootGroup, setRootGroup] = useState<ConditionGroup>({
  id: "root",
  type: "group",
  operator: defaultOperator,
  conditions: [],
});

// 2. 当前正在编辑的条件(三步引导的中间状态)
const [editingCondition, setEditingCondition] = useState<{
  condition: Partial<FilterCondition>;  // 可能只填了字段,还没选运算符
  groupId: string;   // 属于哪个组
  index?: number;    // 编辑已有条件时的索引
} | null>(null);

条件树的修改都通过深拷贝实现(immutable update),确保 React 能正确检测到变化:

// 深拷贝条件树
const deepCloneGroup = (group: ConditionGroup): ConditionGroup => ({
  ...group,
  conditions: group.conditions.map((c) =>
    c.type === "group" ? deepCloneGroup(c) : { ...c }
  ),
});

// 在嵌套的树中找到目标组
const findGroupById = (
  group: ConditionGroup, id: string
): ConditionGroup | null => {
  if (group.id === id) return group;
  for (const c of group.conditions) {
    if (c.type === "group") {
      const found = findGroupById(c, id);
      if (found) return found;
    }
  }
  return null;
};

每次修改条件时:深拷贝树 → 找到目标组 → 修改 → 设置新状态 → 触发 onSearch 回调。

值输入的类型适配

第三步"输入值"根据字段类型渲染不同的输入组件,这是一个 switch 分发:

const renderValueInput = () => {
  const field = getField(editingCondition.condition.fieldId);

  switch (field.type) {
    case "select":
      // 渲染选项列表,点击直接提交
      return <OptionsList options={field.options} onSelect={handleSubmit} />;

    case "boolean":
      // 只有 true/false 两个选项
      return <BooleanPicker onSelect={handleSubmit} />;

    case "date":
      // 日期选择器
      return <input type="date" onChange={handleValueChange} />;

    case "number":
      // 数字输入框
      return <input type="number" onChange={handleValueChange} />;

    default:  // text
      // 文本输入框,Enter 提交
      return <input type="text" onChange={handleValueChange}
                     onKeyDown={e => e.key === "Enter" && handleSubmit()} />;
  }
};

注意 selectboolean 类型的特殊处理:用户点击选项后直接提交条件,不需要额外点"确认"按钮。这减少了一次点击,交互更流畅。

AND/OR 切换与条件分组

条件之间的逻辑关系通过 ConditionGroup.operator 控制。UI 上是一个可点击的小标签,每次点击在 AND 和 OR 之间切换:

const handleToggleOperator = (groupId: string) => {
  setRootGroup((prev) => {
    const updated = deepCloneGroup(prev);
    const group = findGroupById(updated, groupId);
    if (group) {
      group.operator = group.operator === "AND" ? "OR" : "AND";
    }
    onSearch(updated);
    return updated;
  });
};

嵌套分组让用户可以表达更复杂的逻辑。比如"(A AND B) OR (C AND D)"——顶层是 OR,两个子组各自是 AND。实现上限制了最大嵌套深度为 3 层,防止条件树过于复杂:

// 只在深度 < 3 时显示"添加分组"按钮
{depth < 3 && (
  <button onClick={() => handleAddGroup(group.id)}>
    Add Group
  </button>
)}

条件的渲染

条件树的渲染也是递归的。每个 ConditionGroup 渲染自己的 operator 标签和 conditions 列表,遇到子组就递归调用自己:

const renderGroup = (group: ConditionGroup, depth = 0) => (
  <div className={depth > 0 ? "nested-group" : ""}>
    {/* AND/OR 切换按钮 */}
    {group.conditions.length > 0 && (
      <button onClick={() => handleToggleOperator(group.id)}>
        {group.operator}
      </button>
    )}

    {/* 逐个渲染条件 */}
    {group.conditions.map((condition, index) => {
      if (condition.type === "group") {
        // 递归渲染子组
        return renderGroup(condition, depth + 1);
      } else {
        // 渲染单个条件 pill
        const field = getField(condition.fieldId);
        const operator = getOperator(condition.fieldId, condition.operatorId);
        return (
          <div className="condition-pill">
            <span className="field">{field?.label}</span>
            <span className="operator">{operator?.symbol}</span>
            <span className="value">{condition.value}</span>
            <button onClick={() => handleRemove(group.id, index)}>×</button>
          </div>
        );
      }
    })}

    {/* 添加条件/分组按钮 */}
    <button onClick={() => handleAddCondition(group.id)}>+ Add Filter</button>
    {depth < 3 && <button onClick={() => handleAddGroup(group.id)}>() Add Group</button>}
  </div>
);

嵌套的子组用左边框缩进(border-left + padding-left),视觉上清晰地表达层级关系。

和后端对接

onSearch 回调返回的是一棵 ConditionGroup 树。后端通常需要把它转成数据库查询。这个转换是递归的:

// 转成 SQL WHERE 子句(伪代码)
function toSQL(group: ConditionGroup): string {
  const parts = group.conditions.map((c) => {
    if (c.type === "group") {
      return `(${toSQL(c)})`;
    }
    const op = operatorToSQL(c.operatorId);  // "eq" → "=", "contains" → "LIKE"
    const val = escapeSQL(c.value);
    return `${c.fieldId} ${op} ${val}`;
  });
  return parts.join(` ${group.operator} `);
}

// 输出: status = 'done' AND (creator = 'zhangsan' OR creator = 'lisi')

如果用 GraphQL,可以直接把树结构映射成嵌套的 where input:

// GraphQL where input
{
  and: [
    { status: { eq: "done" } },
    {
      or: [
        { creator: { eq: "zhangsan" } },
        { creator: { eq: "lisi" } }
      ]
    }
  ]
}

数据结构是同构的——前端的 ConditionGroup 和 GraphQL 的 where input 只是同一棵树的不同表示。转换几乎是一对一的映射。

设计思考

扩展方向

总结

PowerSearch 的核心就是两件事:一个递归的条件树数据结构,和一个三步渐进式的条件编辑交互。数据结构决定了它能表达多复杂的查询(任意 AND/OR 嵌套),交互设计决定了复杂的查询是否容易构建(字段 → 运算符 → 值的引导流程)。

从工程角度看,这个组件的核心代码量不大——类型定义 30 行,状态管理 50 行,递归渲染 50 行。大部分代码在 UI 细节上(下拉框样式、hover 效果、键盘事件)。这是前端组件的常态:核心逻辑简单,但要做好交互体验需要大量的细节打磨。