基于键值对字符串的高级搜索模式

标题有点“高级”,但其实就是解析特定模式字符串里的键值对而已。

需求

首先说一下为什么有这个需求。

现在,有这么一个场景:页面上只有一个输入框,输入关键字查询,同时支持特定条件查询。一般情况下,直接搜索关键字已经满足使用需求了,如果需要特定条件筛选,可以在输入框上面或者下面或者右侧添加一些选项,tab 之类的。比如新网的这个输入框。

单输入框特定条件查询

但是,我不是很喜欢这种,因为会增加 UI 组件,并且每增加一个查询条件就需要调整 UI,多条件联合查询更是烧脑。

所以,github 那种高级搜索我就挺喜欢的,直接在原输入框增强,不会增加 UI 负担,同时扩展性更强。

模式

有了需求和参考,就可以设计方案了。于是,我想出了如下模式:

(/field:(decorator:))value
  • / 是模式开始标识。
  • field 是字段。就是搜索条件的 key。
  • : 是修饰符和值开始标识。
  • decorator 是修饰符。用来指示一些特定的行为,比如某个搜索条件可以自定义包含、全等、区分大小写等等。总之,可以自定义任何想要的行为,这里只是这个行为的符号。
  • value 是值。
  • () 表示可选。比如 /::value 和 /:valuevalue 是等同的。

重复此模式形成整个多条件的输入字符串。模式的字符部分可以自定义子模式,比如我想要 value 部分可以多个值,定义 – 为分隔符。三个部分都可以限定在特定可选项内。这些都可以在解析、校验阶段自行设计实现。

这个模式功能比较强大,但是,在实现解析(parse)部分后,我果断放弃了这个模式。因为这个模式缺点也比较明显:

  • 解析困难。因为 field 和 decorator 都是可选的,当出现 /field:xxx 这种情况的时候,xxx 究竟是什么呢?虽然我在模式配置和解析里实现了 decorator 的必填、可选,但在可选情况下依然存在是“模式存在,值空缺”或者“没有模式,是值”的判断,即修饰符和值的优先级问题。或许可以设定修饰符可选的情况下,表示无修饰符需要写为 /field::value 这种形式,但这依然会增加用户输入的心智负担。
  • 值包含 : 时无法判断修饰符和值。当然,我可以限制值不能包含冒号,或者值包含冒号的时候必须转义。前者不符合实际情况,后者增加心智负担(both)。
  • 难以转为对象。对象是 key:value 形式的,那么修饰符怎么处理呢?配置的时候指定映射字段?默认拼接在 field 上?都不好。映射字段还不如指定新的 field 呢,反正传给后端都是 key,处理还简单。默认拼接则不现实。

所以,我把模式改成了下面这样:

(/field:)value

这个模式简单多了,直接就是 key:value 的翻版。都不需要过多解释。但还是有必要解释几点的。

  • 为什么一定要让 field 可选?这是为了兼容普通输入(空模式简写),总不能一定要用户输入 /:value 这种吧。
  • 空模式简写输入一定要在最前面一个模式。因为放在后面会与 value 混淆,除非明确输入 /:value 这种形式。
  • 模式 field 不能重复。因为模式最终要转成对象。
  • 空模式转成对象成员默认 key 为 ‘default’。
  • 如果需要扩展,比如添加类似上面的修饰符功能,可以考虑 field.decorator 这种或类似形式。
  • 为什么不直接用 github 的形式,用空格作为模式分割符,或者 URL 里 QueryString 的形式呢?因为我不喜欢。

模式定义好了,就可以写代码了。

编码

编码主要分为两部分,定义(配置)和编译(解析、校验等)。

定义比较简单,无非指定 field、空模式、可选值、分隔符、多选等。

编译分为解析和校验。解析就是将字符串分割成为 field 和 values(因为可以多选),校验会根据配置检查值是否在指定范围(如果指定了),然后将模式转成对象。

代码如下:

export interface PatternCompileResultValue {
  pattern?: SearchPattern;
  field: string;
  rawValue: string;
  values: string[];
  valid: boolean;
  step?: string;
  options?: string[];
  presetedValues?: string[];
}

export interface PatternCompileResult {
  valid: boolean;
  patterns: PatternCompileResultValue[];
  pairs?: {
    [x: string]: string | string[];
  };
  /**
   * @desc 错误信息,表示第一个错误的模式
   */
  errorPattern?: PatternCompileResultValue;
  /**
   * @desc 当前输入阶段,field/value
   */
  step: string;
  /**
   * @desc 当前输入建议项
   */
  options: string[];
  /**
   * 是否可选--field 可选表示有空模式,可在开头自由输入
   */
  optional: boolean;
}

export interface SearchPattern {
  /**
   * @desc 如果 field 为空,表示空模式(字段)
   */
  field?: string;
  values?: string[];
  seperator?: string;
  multiple?: boolean;
  validate?: (values: string[]) => { valid: boolean; msg: string };
}

export interface SearchPatternCfg {
  /**
   * @desc 模式数量限制,默认一个,0 表示无限制
   */
  max?: number;
  patterns: SearchPattern[];
  validate?: (patterns: { pattern: SearchPattern; values: string[] }) => {
    valid: boolean;
    msg: string;
  };
}

const steps = {
  field: 'field',
  value: 'value',
};

/**
 * @name patternCompile
 * @param {SearchPatternCfg} cfg
 * @param {string} text 输入字符串
 * @param {boolean} final 是否输入结束
 * @desc 模式检查,返回模式检查结果以及输入可选项
 * ```markdown
 * 检查步骤
 * 1. 已完成模式(final === true 或者非最后一个模式)
 *   1.1 非 / 开头--字符串第一个字符
 *     1.1.1 允许空模式--匹配
 *     1.1.2 不允许空模式--不匹配
 *   1.2 / 开头
 *     1.2.1 模式开始,截止到第一个 : 为 field,尝试匹配
 *     1.2.2 : 后面为值,连字符拆分,有指定值范围则尝试匹配
 * 2. 输入中模式(final === false 并且最后一个模式)
 *   2.1 非 / 开头--字符串第一个字符
 *     2.1.1 允许空模式--匹配
 *     2.1.2 不允许空模式--不匹配
 *   2.2 / 开头
 *     2.2.1 模式开始,没有 :,用 startsWith 匹配 field,有 : 则完全匹配
 *     2.2.2 : 后的值,连字符拆分,如果有指定范围,用 startsWith 匹配,否则直接通过
 * ```
 */
function patternComiple(cfg: SearchPatternCfg, text: string, final = false) {
  text = text.trimStart();
  const res: PatternCompileResult = {
    valid: true,
    patterns: [],
    step: '',
    options: [],
    optional: false,
  };
  // 如果左侧不是 / 开头,说明空模式开头,补全模式标识
  if (text[0] !== '/') {
    if (text[0] !== ':') {
      text = ':' + text;
    }
  } else {
    // 去掉开头的 /,因为下面根据 / split 的时候前面会多一个空字符元素
    text = text.slice(1);
  }
  const patternTextArr = text.split('/');
  // 检测到的模式
  const patterns: PatternCompileResultValue[] = [];
  // 解析模式
  for (let i = 0; i < patternTextArr.length; i++) {
    // 是否在输入中--未完成,且是输入字符串中最后一个模式
    const typing = !final && i === patternTextArr.length - 1;
    const pattern = patternParse(cfg, patternTextArr[i], typing);
    patterns.push(pattern);
  }
  // 校验
  res.patterns = patterns;
  if (res.patterns.length) {
    patternValidate(res, final);
  }
  return res;
}

export default patternComiple;

/**
 * 解析模式
 * @param cfg
 * @param text
 * @param typing
 */
function patternParse(
  cfg: SearchPatternCfg,
  text: string,
  typing: boolean
): PatternCompileResultValue {
  const segments = text.split(':');
  const pattern: PatternCompileResultValue = {
    field: '',
    rawValue: '',
    values: [],
    valid: true,
  };
  // 解析 field
  const field = parseField(cfg, segments[0], typing && segments.length === 1);
  Object.assign(pattern, field);
  // 解析 values
  pattern.rawValue = segments.slice(1).join(':');
  // 输入错误,或者输入中,结束解析
  if (!field.valid || field.step) {
    return pattern;
  }

  if (field.pattern) {
    const values = parseValues(pattern.pattern, pattern.rawValue, typing);
    Object.assign(pattern, values);
  }
  return pattern;
}

function parseField(cfg: SearchPatternCfg, fieldText: string, typing: boolean) {
  const res: {
    valid: boolean;
    field: string;
    pattern?: SearchPattern;
    step?: string;
    options?: string[];
    optional?: boolean;
  } = {
    valid: true,
    field: fieldText,
  };
  // 输入中给出输入提示
  if (typing) {
    res.step = steps.field;
    res.options = cfg.patterns
      ?.filter?.((item) => item.field && item.field?.startsWith(fieldText))
      .map((item) => item.field as string);
    // 是否可以空模式
    res.optional = !res.field && cfg.patterns.some((item) => !item.field);
  } else {
    // 否则找出具体的 pattern
    res.pattern = cfg.patterns.find((item) => item.field === fieldText);
    res.valid = !!res.pattern;
  }
  return res;
}

function parseValues(
  pattern: SearchPattern | undefined,
  rawValue: string,
  typing: boolean
) {
  const res: {
    values: string[];
    valid: boolean;
    step?: string;
    options?: string[];
    optional?: boolean;
    multiple?: boolean;
    seperator?: string;
    presetedValues?: string[];
  } = {
    values: [],
    valid: true,
  };
  res.multiple = pattern?.multiple;
  res.seperator = pattern?.seperator;
  res.presetedValues = pattern?.values;
  const inputValueList =
    res.multiple && res.seperator && rawValue
      ? rawValue.split(res.seperator)
      : [rawValue].filter((item) => item);
  // 如果输入中,不校验有效性,直接筛选提示
  if (typing) {
    res.options = res.presetedValues;
    res.optional = !pattern?.values?.length;
    res.step = steps.value;
  } else {
    // 如果限定了输入,需要校验输入是否有效
    if (res.presetedValues?.length) {
      res.valid = inputValueList.every((item) =>
        res.presetedValues?.includes(item)
      );
      if (!res.valid) {
        res.step = steps.value;
      } else {
        res.values = inputValueList;
      }
    } else {
      res.values = inputValueList;
    }
  }

  return res;
}

/**
 * 校验模式
 */
function patternValidate(res: PatternCompileResult, final: boolean) {
  // 找出已经存在的错误
  const invalidPattern = res.patterns.find((item) => !item.valid);
  if (invalidPattern) {
    res.valid = false;
    res.errorPattern = invalidPattern;
    return res;
  }
  // 如果输入中,设置提示信息
  if (!final) {
    const pattern = res.patterns.slice(-1)[0];
    res.step = pattern.step ?? '';
    res.options = pattern.options ?? [];
    return res;
  }
  // 检查
  res.patterns.map((item) => {
    // 正确的才继续检查
    if (item.valid) {
      // 必填--如果 field 存在,values 必须有值
      if (item.field && !item.values.length) {
        item.valid = false;
        item.step = steps.value;
      }
    }
    if (!item.valid && res.valid) {
      res.valid = false;
      res.errorPattern = item;
    }
  });
  // 结果对象
  const pairs: { [x: string]: string | string[] } = {};
  // 如果全部正确,返回
  res.patterns.map((item) => {
    const key = item.field || 'default';
    pairs[key] = item.pattern?.multiple ? item.values : item.values[0] ?? '';
  });
  res.pairs = pairs;
  return res;
}

成果如下:

高级搜索字符串模式提示

总结

一开始想得有点大而全,但实际上没必要做得很复杂。任何方案都是解决的有限问题,不能无限外延。否则方案难度会变得很大,落地困难。

此文提出的解决方案也不算新颖,业界也有成熟方案,但自己的产品,开心就好。