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

1.业务背景

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

在这里插入图片描述
在这里插入图片描述

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

并且在表达式输入有误时,需要校验并提醒:
在这里插入图片描述

可以看出,无论筛选项(灰色tag部分)有多少复杂,该组件可以被简单抽象为以下形式:
在这里插入图片描述

2.现有组件

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

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

最终组件的渲染形式可以由以上展示的一个 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]
添加分为两类:

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

在这里插入图片描述

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

3.2简单删除逻辑

删除分为两类:

删除文本:该操作与普通的 标签一致
删除tag:该操作需要在视觉上与 [tag] 相连的 [input] 最左侧按下回退键触发,逻辑上要分成以下几步:
a. 移除(remove)触发操作所在的 [tag] 和 [input] 组合
b. 如果存在,则将前面的 [input] 和后面的 [input] 的文本合并
在这里插入图片描述

以上的简单添加逻辑和简单删除逻辑可以满足组件最基本的逻辑操作。

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 为例:
在这里插入图片描述

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

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

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

1.±/ 之间相邻,如:++,–,±
2.±
/ 和 ( 右相邻,如:(+,(/
3.±*/ 和 ) 左相邻,如:+),/)
4.) 和 ( 的相邻:)(

2.操作数和操作数相邻:

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

4.3 表达式合法性校验

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

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

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

1.创建一个存储操作数的栈 numbers 和 一个存储运算符的栈 operators

2.在遍历的时候,如果该字符是操作数,则 numbers.push(char);如果是运算符,则 operators.push(char)

3.循环遍历在 4.1 小节提取到的字符串数组:
a. 如果下一个字符是 ‘(’,那么持续往后遍历,直至遇到 ‘)’,相遇后,将当前的 numbers 和 operators 的值根据运算符优先级进行运算
b. 如果不是,根据该字符的类型压进对应的栈里

4.遍历结束时,将当前的 numbers 和 operators 的值根据运算符优先级进行运算

5.如果表达式合法,此时 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] )的内部逻辑,因此可以简单使用为普通的筛选字段的运算:
在这里插入图片描述
也可以使用复杂筛选项: 也可以使用复杂筛选项:
在这里插入图片描述

源码附件已经打包好上传到百度云了,大家自行下载即可~

链接: https://pan.baidu.com/s/14G-bpVthImHD4eosZUNSFA?pwd=yu27
提取码: yu27
百度云链接不稳定,随时可能会失效,大家抓紧保存哈。

如果百度云链接失效了的话,请留言告诉我,我看到后会及时更新~

开源地址

码云地址:
http://github.crmeb.net/u/defu

Github 地址:
http://github.crmeb.net/u/defu

链接:https://juejin.cn/post/7118931609134481421

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

CRMEB定制开发

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值