Table of Contents
标题有点“高级”,但其实就是解析特定模式字符串里的键值对而已。
需求
首先说一下为什么有这个需求。
现在,有这么一个场景:页面上只有一个输入框,输入关键字查询,同时支持特定条件查询。一般情况下,直接搜索关键字已经满足使用需求了,如果需要特定条件筛选,可以在输入框上面或者下面或者右侧添加一些选项,tab 之类的。比如新网的这个输入框。

但是,我不是很喜欢这种,因为会增加 UI 组件,并且每增加一个查询条件就需要调整 UI,多条件联合查询更是烧脑。
所以,github 那种高级搜索我就挺喜欢的,直接在原输入框增强,不会增加 UI 负担,同时扩展性更强。
模式
有了需求和参考,就可以设计方案了。于是,我想出了如下模式:
(/field:(decorator:))value
-
/是模式开始标识。 field是字段。就是搜索条件的 key。:是修饰符和值开始标识。decorator是修饰符。用来指示一些特定的行为,比如某个搜索条件可以自定义包含、全等、区分大小写等等。总之,可以自定义任何想要的行为,这里只是这个行为的符号。value是值。()表示可选。比如/::value 和 /:value和value是等同的。
重复此模式形成整个多条件的输入字符串。模式的字符部分可以自定义子模式,比如我想要 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;
}
成果如下:

总结
一开始想得有点大而全,但实际上没必要做得很复杂。任何方案都是解决的有限问题,不能无限外延。否则方案难度会变得很大,落地困难。
此文提出的解决方案也不算新颖,业界也有成熟方案,但自己的产品,开心就好。