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

前言

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

众所周知,前端文本的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。

相关推荐
恋猫de小郭44 分钟前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
崔庆才丨静觅7 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60618 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了8 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅8 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅9 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅9 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment9 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅9 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊9 小时前
jwt介绍
前端