实现文本异步替换之低代码表单公式异步计算

前言

最近公司低代码平台接到了一个新的需求,表单的公式计算需要支持异步计算,然后需要用到文本异步替换功能。

众所周知,前端文本的replace方法只支持同步替换,所以我自己实现了一个,下面给大家分享一下。

实现同步文本替换

先不用replace方法来实现同步文本替换,这里主要用到了字符串的matchAll方法,这个方法可以返回匹配到的字符串和位置。

拿到配置的文本和位置,现在我们只需要获取到123,然后再把他们拼接起来就行了。

上面为了方便大家理解,写死了一些参数,下面来实现一个完整版的replace。

js 复制代码
function replace(str, searchValue, replacer) {
      // 获取所有的匹配项
      const matches = [...(str.matchAll(searchValue))];

      let lastIndex = 0;

      // 遍历所有的匹配项

      const result = matches.reduce((prev, cur) => {
        const [inputValue, matchValue] = cur;
        // 拼接没有被匹配上的字符串
        prev += str.slice(lastIndex, cur.index);
        // 拼接被匹配上的字符串
        prev += replacer(inputValue, matchValue);
        // 更新上一次的位置
        lastIndex = cur.index + inputValue.length;

        return prev;
      }, '');
      
      // 后面有可能还有字符串,所以也拼接一下
      return result + str.slice(lastIndex);
    }

实现异步文本替换

实现了同步文本替换后,再实现异步就很简单了,看下面代码中的注释

js 复制代码
    /**
     * 异步文本替换
     * @param {*} str 字符串
     * @param {*} searchValue 需要替换的字符串
     * @param {*} replacer 异步方法
     * @returns 
     */
    async function asyncReplace(str, searchValue, replacer) {
      // 获取所有的匹配项
      const matches = str.matchAll(searchValue);
      // 存放promise
      const promises = [];
      // 记录上一次的字符串位置
      let lastIndex = 0;

      [...matches].forEach(item => {
        const [inputValue, matchValue] = item;
        // 拼接没有被匹配上的字符串
        promises.push(str.slice(lastIndex, item.index));
        // 把异步任务放到promise中
        promises.push(replacer(inputValue, matchValue));
        // 更新上一次的位置
        lastIndex = item.index + inputValue.length;
      });

      // 如果最后一次的位置不等于字符串长度,说明后面还有文本
      if (lastIndex < str.length) {
        promises.push(str.slice(lastIndex));
      }

      console.log(promises, 'promises');
      // 执行所有的异步任务
      const result = await Promise.all(promises);
      // 拼接字符串返回
      return result.join('');
    }

正常文本异步替换

正则表达式异步替换,获取到[1]中的1,异步乘以100返回。

低代码表单公式计算

背景

同步计算

公司的低代码平台,因为接了一些工程类的项目,表单里有大量的公式计算,所以表单项支持配置计算公式,最开始开始公式计算都是同步的,只支持简单的四则运算。

比如有一个表单,总价的公式也就是单价*数量,最终价格等于总价*折扣

总价的公式配置

最终价格的公式配置

异步计算

最近接了个新的需求,需要根据用户输入的开始日期和结束日期算工作了多少天,因为工作日是人工维护的,所以这个前端算不出来,需要调用后端接口才能算出来,这样的话只能改造公式计算方法来支持异步计算。

为了实现这个功能,给表单公式改造了一下,不但支持函数,并且还支持调后端接口的异步函数

实现

同步版本代码实现

根据上面需求我们先来实现一下同步计算的版本,异步版本在同步的版本上改一下就行了。

因为是公司的代码不太好直接拿出来说,这里我实现一个简单版本的。

先写一个表单

tsx 复制代码
import { DatePicker, Form, InputNumber } from 'antd';

const FormItem = Form.Item;

function App() {

  const [form] = Form.useForm()

  return (
    <Form
      form={form}
      labelCol={{ span: 5 }}
      wrapperCol={{ span: 16 }}
      style={{ marginTop: 50 }}
    >
      <FormItem label="单价" name="price">
        <DatePicker style={{ width: 300 }} />
      </FormItem>
      <FormItem label="数量" name="count">
        <DatePicker style={{ width: 300 }} />
      </FormItem>
      <FormItem label="总价" name="totalPrice">
        <InputNumber disabled style={{ width: 300 }} />
      </FormItem>
      <FormItem label="折扣" name="discount">
        <InputNumber style={{ width: 300 }} />
      </FormItem>
      <FormItem label="折扣后金额" name="discountedPrice">
        <InputNumber disabled style={{ width: 300 }} />
      </FormItem>
    </Form>
  )
}

export default App

按照上面需求拿到低代码设置配置的公式

ts 复制代码
const formulas = {
    totalPrice: `[[v.单价:price]] * [[v.数量:count]]`,
    discountedPrice: `[[v.总价:totalPrice]] * [[v.折扣:discount]]`,
};

v表示字段类型

冒号前面的单价是公式编辑器中显示的中文

冒号后面的price是字段名称

如果对公式编辑器感兴趣,可以看下我以前关于公式编辑器的文章。

记录一下使用codemirror6写一个低代码脚本编辑器遇到的坑

下面写一个公共公式计算类来计算公式

实现思路

先写一个方法来分析某个公式依赖了哪些字段,然后再分析某个字段改变会影响哪些公式,这样可以减少计算。

js 复制代码
export class FormulaUtils {
  /**
   * 公式
   */
  formulas;
  /**
   * 字段公式的依赖项,比如:字段c的公式为a+b,然后他的依赖项是[a, b],key就是c
   */
  depsMap = new Map();
  /**
   * 字段改变会影响哪些字段,比如:字段c的公式为a+b,a字段值改变时会影响c字段,让c重新计算,key就是a,值就是[c]
   */
  formulaEffectsMap = new Map();

  constructor(formulas) {
    this.formulas = formulas;
    this.analysisFormulas();
  }

  /**
   * 分析公式 
   */
  analysisFormulas() {
    if (!this.formulas) return;

    Object.keys(this.formulas).forEach(key => {
      this.setFormulasDeps(key, this.formulas[key]);
    });

    // 反向设置当前字段影响哪些字段
    this.depsMap.forEach((value, key) => {
      value.forEach(item => {
        if (this.formulaEffectsMap.has(item)) {
          this.formulaEffectsMap.get(item).push(key);
        } else {
          this.formulaEffectsMap.set(item, [key]);
        }
      });
    })
  }

  /**
   * 设置当前公式依赖字段
   * @param {*} field 字段名
   * @param {*} formula 公式
   * @returns 依赖
   */
  setFormulasDeps(field, formula) {
    const deps = [];

    // 如果分析过就不用分析了,直接返回
    if (this.depsMap.has(field)) {
      return this.depsMap.get(field);
    }

    // 这一步是把字段名解析出来
    formula.replace(/\[\[(.+?)\]\]/g, (_, $2) => {
      // 第一个是字段类型,demo只支持一种,就不加判断了。
      const [, ...rest] = $2.split('.');
      // 获取字段名称
      const code = rest[0].split(':')[1];
      // 如果当前字段是个公式,需要先分析当前公式,获取到依赖的字段,然后添加到依赖列表中
      if (this.formulas[code]) {
        deps.push(...this.setFormulasDeps(code, this.formulas[code]));
      }

      // 把当前字段也添加进去
      deps.push(code);
    });

    // 把依赖列表存起来
    this.depsMap.set(field, deps);
    // 返回当前公司的依赖列表
    return deps;
  }
}

看一下上面的输出

  • totalPrice的依赖是price、count
  • discountedPrice的依赖是price、count、totalPrice、discount
  • price和count改变会影响totalPrice、discountedPrice
  • totalPrice改变会影响discountedPrice
  • discount改变会影响discountedPrice

上面结果完全符合我们的公式

下面来实现一下计算方法,实现思路是先把公式转换为可执行脚本,然后使用new Function动态执行脚本,计算出结果,里面做了一些优化,可以看下代码注释。

监听form表单值改变事件

效果展示

上面看似实现的很完美,但是别忘了前端让人头疼的精度问题,把公式改一下,让大家看下问题。

我当时被这个问题困扰了一段时间,直到在某个群里有个大佬提醒我使用mathjs库试试,我看了一下mathjs库确实支持动态计算,但是用的时候要先设置一下数字类型为BigNumber,不然还是不行。

改造一下公式转换为脚本的方法,用math.evaluate方法包裹一下。

这里可能会有人提问,为啥用getValue方法去获取值,而不是直接values[code]返回具体的值,如果值是数字或文本是可以的,因为字符串的replace方式会把返回值转换为字符串,返回其他类型的数据就不行了。

异步版本实现

先把表单和公式按照异步版本实现一下

jsx 复制代码
import { DatePicker, Form, Input, InputNumber } from 'antd';

import { useRef } from 'react';
import { FormulaUtils } from './formula';

const FormItem = Form.Item;

function App() {

  const [form] = Form.useForm();

  const formulas = {
    workDays: 'func.getWorkDays([[v.开始日期:startDate]], [[v.结束日期:endDate]])',
    salary: '[[v.工作天数:workDays]] + [[v.日薪:daySalary]]',
  };

  const formulaUtil = useRef(new FormulaUtils(formulas));

  async function valuesChange(changeValue, values) {
    const changeField = Object.keys(changeValue)[0];
    const result = formulaUtil.current.calc(changeField, values);
    form.setFieldsValue(result);
  }

  return (
    <Form
      form={form}
      labelCol={{ span: 5 }}
      wrapperCol={{ span: 16 }}
      style={{ marginTop: 50 }}
      onValuesChange={valuesChange}
    >
      <FormItem label="开始日期" name="startDate">
        <DatePicker style={{ width: 300 }} />
      </FormItem>
      <FormItem label="结束日期" name="endDate">
        <DatePicker style={{ width: 300 }} />
      </FormItem>
      <FormItem label="日薪" name="daySalary">
        <InputNumber style={{ width: 300 }} />
      </FormItem>
      <FormItem label="工作天数" name="workDays">
        <Input disabled style={{ width: 300 }} />
      </FormItem>
      <FormItem label="薪资" name="salary">
        <Input disabled style={{ width: 300 }} />
      </FormItem>
    </Form>
  )
}

export default App

可以看到上面公式里用到了一个函数,这个函数是模拟调用后端接口返回两个日期的工作天数,是一个异步函数。

改造执行脚本方法,这里不用new Function方法执行脚本了,直接用mathjs库的公式计算。mathjs有个大问题,虽然执行脚本的时候支持上下文,但是不支持异步函数,如果不用mathjs就会有精度问题,我被这个问题卡了很久,最后在mathjs仓库的issse里找到了个解决方法,我给改造了一下,目前只支持加减乘除,不支持取余等计算符号,不过这些不常用的符号,可以提供函数给用户使用。

我自己封装的math方法,支持异步函数

js 复制代码
import { all, create, factory } from 'mathjs';

const mathjs = create(all, {
  number: 'BigNumber',
});

const createOperatorPromise = (op) => {
  const opScalar = `${op}Scalar`;
  return factory(opScalar, ['typed', 'Promise'], ({ typed, Promise }) => {
    return typed(opScalar, {
      'Promise, Promise': function (xPromise, yPromise) {
        return Promise.all([xPromise, yPromise]).then(([x, y]) =>
          mathjs[opScalar](x, y)
        )
      }
    })
  })
}

const createPromise = factory('Promise', ['typed'], ({ typed }) => {
  // add a new type Promise in mathjs
  typed.addType({
    name: 'Promise',
    test: function (x) {
      return x
        ? typeof x.then === 'function' && typeof x.catch === 'function'
        : false
    }
  })

  // create a conversion to convert from any value to a Promise,
  // so we can mix promise and non-promise inputs to our functions
  typed.addConversion({
    from: 'any',
    to: 'Promise',
    convert: x => Promise.resolve(x)
  })

  return Promise
})

mathjs.import([
  createPromise,
  createOperatorPromise('add'),
  createOperatorPromise('subtract'),
  createOperatorPromise('multiply'),
  createOperatorPromise('divide'),
]);

export default mathjs;

改造根据公式获取值方法,因为执行脚本方法变成了异步,这里给前面加上await。

上面计算字段值的方法变成了异步,而公式转脚本方法用到了计算字段值方法,所以公式转脚本方法也变成了异步方法。

看一下截图里的方法,因为里面用到了getValueByField方法,然后这个方法是异步的,所以这里需要用到前面封装的异步文本替换。

把calc方法改造成异步的

改造onChange方法,因为是异步的,所以计算的时候加一个loading。

效果展示

总结

上面实现了异步文本替换方法,并且和大家分享了我们公司低代码表单公式计算方法,目前方案还有一些缺点,比如不支持异步函数嵌套,这个我正在想办法解决,大家有好的建议可以再评论区提醒我一下。

因为上面代码只是demo,所以一些边界判断和异常捕获没有仔细去做,可能存在一些bug。

相关推荐
嘤嘤怪呆呆狗6 分钟前
【插件】vscode Todo Tree 简介和使用方法
前端·ide·vue.js·vscode·编辑器
大今野12 分钟前
node.js和js
开发语言·javascript·node.js
ᥬ 小月亮19 分钟前
Js前端模块化规范及其产品
开发语言·前端·javascript
码小瑞34 分钟前
某些iphone手机录音获取流stream延迟问题 以及 录音一次第二次不录音问题
前端·javascript·vue.js
weixin_18936 分钟前
‌Vite和Webpack区别 及 优劣势
前端·webpack·vue·vite
半吊子伯爵37 分钟前
开发过程优化·自定义鼠标右键菜单
前端·javascript·自定义鼠标右键菜单
xcLeigh40 分钟前
HTML5实现好看的喜庆圣诞节网站源码
前端·html·html5
Tirzano1 小时前
vue3 ts 简单动态表单 和表格
前端·javascript·vue.js
杰~JIE1 小时前
前端工程化概述(初版)
前端·自动化·工程化·前端工程化·sop
程序员_三木1 小时前
使用 Three.js 创建圣诞树场景
开发语言·前端·javascript·ecmascript·three