前言
最近公司低代码平台接到了一个新的需求,表单的公式计算需要支持异步计算,然后需要用到文本异步替换功能。
众所周知,前端文本的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是字段名称
如果对公式编辑器感兴趣,可以看下我以前关于公式编辑器的文章。
下面写一个公共公式计算类来计算公式
实现思路
先写一个方法来分析某个公式依赖了哪些字段,然后再分析某个字段改变会影响哪些公式,这样可以减少计算。
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。