复杂表达式输入组件的实现与校验逻辑

2,069 阅读6分钟

本文作者: 李嘉燊

1.业务背景

在电商后台的数据中存在着许多维度的筛选项,如工单ID,店铺ID,行业类型,是否14日有效负向反馈订单等。有时候需要组合这些维度项来查询匹配的商客服工单或CCR数据等。而这些组合就是将不同的维度项做加减乘除的运算,形如下图:

虽然可以灵活搭配,但搭配是否合理还是需要人工判断

并且在表达式输入有误时,需要校验并提醒:

可以看出,无论筛选项(灰色tag部分)有多少复杂,该组件可以被简单抽象为以下形式:

image.png

2.现有组件

我们所需的组件展示形式和 Auxo 的 标签输入框十分类似,只是它的标签是集中在了头部:
Auxo InputTag 组件总体是通过维护 1 个 tagRenderList 和 1 个尾部输入框,每输入一个tag,就将新的 tag append 到 tagRenderList 里面去:

image.png

我们可以将上图的组件视作一对(pair)并重复添加来实现我们所需要的 [筛选项] 和 [输入框] 相隔的效果:

image.png

最终组件的渲染形式可以由以上展示的一个 renderList 进行维护,里面包含了严格交叉的 tag(筛选项)和 input(输入框)

严格交叉的目的是为了达到可以在任何一个筛选项后面继续插入文本

3.简单实现

  • 基本的渲染形式确定之后,开始决定如何维护 renderList:
    • [tag] 和 [input] 的添加逻辑
    • [tag] 和 [input] 的删除逻辑

实现以上的效果首先需要确认 renderList 的数据格式声明

enum BlockType {
  Tag = 'tag',
  Input = 'input',
}

type RenderBlock = {
  type: BlockType; // 区分 tag 和 input
  id: string; // 用于追踪具体的 block
  component: React.FC; // 要进行渲染的组件本身
  value: any; // 该 block 包含的值,可以是 tag 里面代表的值,也可以是 input 里本身的字符串
}

type RenderList = RenderBlock[]

3.1简单添加逻辑

可以规定:每当 [input] 输入了 +-*/( 运算符时就会自动插入一组 [tag] 和 [input]

这个操作可以通过在 [input] 中监听键盘事件(如 keyPress 和 keyDown)实现,通过 id 在 renderList 中索引到 index,在该 [input] 的后面插入新的一组 [tag] 和 [input]

添加分为两类:

  1. 在输入框最末端输入运算符:此时直接往 [input] 后面添加新的一组 [tag] 和 [input] 即可

image.png 2. 在输入框中间输入运算符:这里需要以输入运算符的位置为界,将 [input] 里的文本分为前半部分和后半部分,后半部分要和所输入的运算符(下图以 + 为例)放到新添加的 [tag] 和 [input] 里面去,实现分裂的效果。

image.png

3.2简单删除逻辑

删除分为两类:

  1. 删除文本:该操作与普通的 标签一致
  2. 删除tag:该操作需要在视觉上与 [tag] 相连的 [input] 最左侧按下回退键触发,逻辑上要分成以下几步:
    a. 移除(remove)触发操作所在的 [tag] 和 [input] 组合
    b. 如果存在,则将前面的 [input] 和后面的 [input] 的文本合并 image.png 以上的简单添加逻辑和简单删除逻辑可以满足组件最基本的逻辑操作。

4.校验逻辑

算术表达式的校验实际上就是去解析一个形如 (12+34) / 56 的表达式并运算出最终结果。

但与一般的表达式不同的是,我们这里还多了一个 [tag] ,导致表达式有可能会变成:(tag + 12) / tag

为了方便后续计算,可以约定将 [tag] 记为字符 '@',那么表达式字符串就会变成 (@ + 12) / @

4.1 运算符和操作数分离

回顾一下,在第三章提及到的 renderList 里,每一个 block 都有以下的数据结构:

type RenderBlock = {
  type: BlockType; // 区分 tag 和 input
  id: string; // 用于追踪具体的 block
  component: React.FC; // 要进行渲染的组件本身
  value: any; // 该 block 包含的值,可以是 tag 里面代表的值,也可以是 input 里本身的字符串
}

那么此时 [input] 的 value 可能存在 +123/5 这种运算符和操作数为分离的情况,因此需要对字符串进行切割处理,以 @ + 123*(@+@) -456 为例:

image.png

4.2 运算符和操作数合法性校验

在复杂表达式里,运算符和操作数存在以下几种非法情况:

  1. 运算符和运算符相邻:

    1. +-*/ 之间相邻,如:++,--,+-
    2. +-*/ 和 ( 右相邻,如:(+,(/
    3. +-*/ 和 ) 左相邻,如:+),/)
    4. ) 和 ( 的相邻:)(
  1. 操作数和操作数相邻:

    1. 数字和 '@' 相邻: tag 和 数字相邻,@12
    2. '@' 和 '@' 相邻:tag 和 tag 相邻,@@

4.3 表达式合法性校验

复杂表达式合法性校验和普通的表达式几乎一样,唯一的差异点在于,在遍历到 '@' 的时候,要将其视作数字 1,即 (@ + 12) / @ -> (1 + 12) / 1

这里视作非零数就可以,视作1可以方便验算计算结果

这里采用的解法为双栈解法,大致逻辑如下:

  1. 创建一个存储操作数的栈 numbers 和 一个存储运算符的栈 operators
  1. 在遍历的时候,如果该字符是操作数,则 numbers.push(char);如果是运算符,则 operators.push(char)
  1. 循环遍历在 4.1 小节提取到的字符串数组:

    a. 如果下一个字符是 '(',那么持续往后遍历,直至遇到 ')',相遇后,将当前的 numbers 和 operators 的值根据运算符优先级进行运算
    b. 如果不是,根据该字符的类型压进对应的栈里

  1. 遍历结束时,将当前的 numbers 和 operators 的值根据运算符优先级进行运算
  1. 如果表达式合法,此时 numbers 应当等于 [0, result],operators 应当等于 []

实际应用中,将 4.2 小节的校验的一部分放进了这里,具体代码如下:

const OperatorPriorityMap = {
  '+': 1,
  '-': 1,
  '*': 2,
  '/': 2,
};

// 第一步,非空时的字符校验
const isValidateSuccess = validateChars(values);
// 第二步,进行字符串分离
const factorList = isValidateSuccess ? splitValueList(values) : [];
// 第三步,校验表达式合法性
const stringList = factorList?.map(item => String(item));
const isValid = stringList?.length
      ? calculateExpression(stringList)
      : false;

5.体验优化

为了让该复杂表达式在体验上更像普通 标签,需要做以下优化

5.1 光标移动逻辑

利用 selection API,在每次添加组件和删除组件时,将光标聚焦到其该到的位置上

  • 添加:添加新的组件 [tag] 和 [input] 时,要跳转到新的 [input] 的最左侧
  • 删除:移动到前面一个 [input] 的最右侧
  • 左右移动:实现 [input] 之间的光标跳转,主要为:

    • 在 [input] 的最右侧按下右方向键时,跳转到后面 [input] 的最左侧
    • 在 [input] 的最左侧按下左方向键时,跳转到前面 [input] 的最右侧

Selection API 介绍:

  • Selection 对象:可以简单理解为包含了 选区(range)集 和相关操作
  • Range 对象:代表了一个选区,如

  • 那么此时相当于:
    • range.setStart(node, 2); // range.setStart(startNode, startOffset)
      range.setEnd(node, 5); // range.setEnd(endNode, endOffset)
      

具体实现如下,以删除时的移动逻辑为例:

const moveSelectionToEnd = (index: number) => {
      const lastInputBlock = renderList[index];
      const inputDomNode = lastInputBlock.ref?.current;
      inputDomNode?.focus();
      const textNode = inputDomNode?.childNodes[0];
      const selection = window.getSelection();
      const range = document.createRange();

      if (selection && textNode && textNode.nodeType === Node.TEXT_NODE) {
        range.setStart(textNode, textNode.nodeValue?.length ?? 0);
        range.collapse();
        selection.removeAllRanges();
        selection.addRange(range);
      }
    };

6.灵活使用

以上的复杂表达式是与筛选项解耦的,我们不关注筛选项(也就是 [tag] )的内部逻辑,因此可以简单使用为普通的筛选字段的运算:

也可以使用复杂筛选项: 也可以使用复杂筛选项:

P.S.

该复杂筛选项包含:维度,过滤项,是否去重 的筛选