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