一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情。
一、业务背景
公司业务中有一个单据数据计算的功能,单据的最终结果是通过几步计算得出的。而这个计算的过程根据情况有所不同,同时不同单据的计算方式也不同,需要设计一个简单的计算公式的设计。
二、业务分析
1. 内容梳理
- 需要计算字符串形式的四则运算
- 对计算的精度有要求,会有浮点数的计算
- 每次计算都要获取上一步计算的结果
- 需要在抽取常量,并可以在公式中使用常量
2. 难点梳理
- 计算字符串四则运算
- 保证计算精度
- 如何获取上一步的计算结果
- 如何将常量的值替换到公式中(实际操作与第二点采用相同策略)
3. 实现效果:
三、实现过程:
1. 实现一个简单的四则计算功能
此处参考了掘金Ashy996大佬的文章,文章链接在最下面
我最初的设想是使用eval()或者直接用new Function()的方式直接去计算结果,不过这种直接放权给用户的方式显然不够安全,同时由于精度的问题,所以最后决定要自己写计算的方法。
(1)实现逻辑:
1. 将运算表达式按照四种运算符进行切分,最终生成一个每一项不是数值就是表达式的数组。
2. 由于负数中包含减号,所以字符串切分时会将负数处理错误,所以针对减法做处理,生成正确的负数。
3. 循环生成的数组,先计算乘除再计算加减。
4. 最终数组中仅剩一项,就是最终的计算结果
(2)代码实现:
// 字符串运算公式计算
const calculator = (str: string) => {
const newStr = str.replace(/\s*/g, ''); //去除空格
// 为去除字符的数组添加操作符
function addSymbal(res: string[], symbal: string) {
let length = res.length;
// 每隔一项添加一个字符
while (length > 1) {
res.splice(length - 1, 0, symbal);
length--;
}
return res
}
// 负数会因为减号被拆分情况,此方法将负数恢复
function minu(arr: string[]) {
// 如果运算值为负数,则数组首位会是空字符串,需要判断并去除
if (arr[0] === '') arr.splice(0, 1)
// 此时将指针指向数组的最后一位,并在下面的循环将指针向前移动
let length = arr.length - 1;
// 当指指针移出数组,退出循环
while (length >= 0) {
// 当前的值为负号,如果是在第一位上,或者减号前面不为数字,则将其与后一位合并成为负数,同时指针需要向前多移动一位
if (arr[length] === '-' && (length === 0 || !/^[0-9][0-9]./.test((arr[length - 1])))) {
arr.splice(length, 2, `-${arr[length + 1]}`);
length--;
}
// 如果因为负数的情况,会多一个空字符串的数组项,需要删除并将指针向前移动一位
if (arr[length] === '') {
arr.splice(length, 1)
length--
}
// 每次循环指针向前移位
length--;
}
// 返回最终处理后的数组
return arr
};
// 将四则运算符转换为操作符和数字的多维数组并最终转换为一维数组
// 此方法是按照加减乘除对表达式进行切分,然后在切分后组合成一个由数字和符号组成的数组,用于最终计算结果
const array = addSymbal(newStr.split('+'), '+')
.map(item =>
addSymbal(item.split('*'), '*')
.map(item =>
addSymbal(item.split('/'), '/')
.map(item =>
minu(addSymbal(item.split('-'), '-'))
)
)
).flat(3);
// 处理所有的乘除法计算
while (array.includes('*') || array.includes('/')) {
const symbal = array.find(item => item === '*' || item === '/');
const index = array.findIndex(item => item === '*' || item === '/');
index > 0 && symbal === '*'
? array.splice(index - 1, 3, multiplication(array[index - 1], array[index + 1]) + '')
: array.splice(index - 1, 3, division(array[index - 1], array[index + 1]) + '');
}
// 处理所有的加减法运算
while (array.length > 1) {
array[1] === '+'
? array.splice(0, 3, add(array[0], array[2]) + '')
: array.splice(0, 3, subtraction(array[0], array[2]) + '')
}
return Number(array[0]).toFixed(2);
}
(3)JS计算精度问题
此处我使用的是decimal.js,然后我又对其运算方法的调用进行简化,使用起来更简单
安装方式 npm install decimal.js
// 加 +
const add = (a: string, b: string) => new Decimal(a).add(b).toNumber();
// 减 -
const subtraction = (a: string, b: string) => new Decimal(a).sub(b).toNumber();
// 除 /
const division = (a: string, b: string) => new Decimal(a).div(b).toNumber();
// 乘 *
const multiplication = (a: string, b: string) => new Decimal(a).mul(b).toNumber();
2. 获取上一步的计算结果
(1)实现逻辑
- 核心逻辑是使用的string的字符替换(splice)
- UI使用的ant design组件库,其中公式部分使用的是form表单的动态增加表单项,使用input输入公式内容。
- 增删的公式是存储在form表单中的数组中。
- 用户输入公式的时候,如果需要调用上面操作的结果,就需要使用特定的变量名,这里使用的是X1、X2...
- 每一项的变量名是固定的,即每次新增一个公式项的时候,就使用当前项的序号加X组成标识。
- 此处可以注意到,节点的标识是自动生成的,不受用户控制,并且后面的序号是根据当前公式form表单数组序号生成的,所以后续处理计算过程中,偷了一点懒。
- 并且业务逻辑仅需调用上一项的据结果,且只调用一次,所以每个公式计算的时候,只需要使用当前的排序拼接X即可知道上一步的值
(2) 代码实现
...
<Form name="dynamic_form_nest_item" onFinish={onFinish} autoComplete="off" initialValues={value}>
...
<Form.Item name="purchase" label={<div>初始价格:<b>(X0)</b></div>} rules={[{ required: true, message: '请输入初始价格' }]}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.List name="links">
{(fields, { add, remove }) => (
<>
{fields.map((field, index) => (
<Space key={field.key} align="baseline" style={{ display: 'flex', marginBottom: 8 }}>
<MinusCircleOutlined onClick={() => remove(field.name)} />
<Form.Item
{...field}
label={<div>节点: <b>X{index + 1}</b></div>}
name={[field.name, 'sight']}
rules={[{ required: true, message: 'Missing sight' }]}
>
<Select style={{ width: 130 }} placeholder="请选择节点">
{optionList.map((item) => (
<Option key={item.value} value={item.value}>
{item.label}
</Option>
))}
</Select>
</Form.Item>
<Form.Item
{...field}
label="计算公式"
name={[field.name, 'price']}
rules={[{ required: true, message: '请输入计算公式,例如(X0*1.1)' }]}
>
<Input placeholder="例如(X0*1.1)" />
</Form.Item>
{linksValue.length > index ? linksValue[index] : ''}
</Space>
))}
<Form.Item>
<Button type="link" onClick={() => add()} icon={<PlusOutlined />}>
添加节点
</Button>
</Form.Item>
</>
)}
</Form.List>
<p>计算结果:{JSON.stringify(linksValue)}</p>
<Form.Item>
<Button type="primary" htmlType="submit">
确定
</Button>
</Form.Item>
</Form>
...
3. 替换对应常量并对公式进行处理
(1)实现逻辑
1. 使用的是字符替换的方式,通过正则匹配到常量符号的字符,然后去常量的数组中找到对应的值,将其值在替换到公式中原本变量名的位置,再进行计算。
2. 其中公式中可能会出现多个常量标识,所以需要匹配到全部变量并处理。
3. 运算函数根据公式列表循环调用,并返回公式运算结果,并将结果存储在数组中,这样下一步公式计算时,即可查询到上一步的计算结果,直接带入公式。
(2) 代码实现
// 本代码运行在react环境中,此处使用的是hooks的写法
const [value, setValue] = useState<Value>({
purchase: "",
constants: [{ value: '' }],
links: []
})
const [linksValue, setLinksValue] = useState<string[]>([])
const handleComputed = (value: Value) => {
setValue(value)
const { links, purchase } = value
// 清空旧的计算数据
setLinksValue([])
// 根据节点列表顺序进行循环计算最终结果
const res = links.reduce((prev, curr, index) => {
return handleComputedItem(index, curr.price, prev)
}, purchase)
return res
}
// 进行每一节点的计算
const handleComputedItem = (index: number, price: string, prev?: string) => {
if (!price) return '0';
if (!prev) prev = linksValue[index];
let equation = price.replace(`X${index}`, prev)
let res = '';
// 获取全部的常量的序号之即 S1 会得到 1,更方便的从数组中取得常量的值
let contants = equation.match(/S[0-9]+/g)?.map(item => item.replace('S', ''))
// 获取常量列表
const constants = value.constants
// 判断常量是否存在
if (contants?.some(i => Number(i) > constants.length)) {
message.error("存在未知常量!");
res = '0'
} else {
// 将常量替换到公式中
contants?.forEach(i => equation = equation.replace(`S${i}`, constants[Number(i) - 1].value))
res = calculator(equation)
}
setLinksValue(data => [...data, res])
return res
}
全部代码实现
本实例运行环境是React + TS + Hooks + antd
import { MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
import { Button, Form, Input, InputNumber, message, Select, Space } from "antd";
import React, { useState } from "react";
import { Decimal } from 'decimal.js'
import "./home.less";
const { Option } = Select;
interface Value {
purchase: string;
constants: Array<{ value: string }>;
links: Array<{ sight: string; price: string; }>
}
export function Home() {
const [value, setValue] = useState<Value>({
purchase: "",
constants: [{ value: '' }],
links: []
})
const optionList = [
{
label: '节点1',
value: 'ONE',
},
{
label: '节点2',
value: 'TWO',
},
{
label: '节点3',
value: 'THREE',
},
]
const [linksValue, setLinksValue] = useState<string[]>([])
const [form] = Form.useForm();
const onFinish = (values: any) => {
const res = handleComputed(values)
console.log('Received values of form:', values, res);
};
const handleComputed = (value: Value) => {
setValue(value)
const { links, purchase } = value
// 清空旧的计算数据
setLinksValue([])
// 根据节点列表顺序进行循环计算最终结果
const res = links.reduce((prev, curr, index) => {
return handleComputedItem(index, curr.price, prev)
}, purchase)
return res
}
// 进行每一节点的计算
const handleComputedItem = (index: number, price: string, prev?: string) => {
if (!price) return '0';
if (!prev) prev = linksValue[index];
let equation = price.replace(`X${index}`, prev)
let res = '';
// 获取全部的常量的序号之即 S1 会得到 1,更方便的从数组中取得常量的值
let contants = equation.match(/S[0-9]+/g)?.map(item => item.replace('S', ''))
// 获取常量列表
const constants = value.constants
// 判断常量是否存在
if (contants?.some(i => Number(i) > constants.length)) {
message.error("存在未知常量!");
res = '0'
} else {
// 将常量替换到公式中
contants?.forEach(i => equation = equation.replace(`S${i}`, constants[Number(i) - 1].value))
res = calculator(equation)
}
setLinksValue(data => [...data, res])
return res
}
// 加 +
const add = (a: string, b: string) => new Decimal(a).add(b).toNumber();
// 减 -
const subtraction = (a: string, b: string) => new Decimal(a).sub(b).toNumber();
// 除 /
const division = (a: string, b: string) => new Decimal(a).div(b).toNumber();
// 乘 *
const multiplication = (a: string, b: string) => new Decimal(a).mul(b).toNumber();
// 字符串运算公式计算
const calculator = (str: string) => {
const newStr = str.replace(/\s*/g, ''); //去除空格
// 为去除字符的数组添加操作符
function addSymbal(res: string[], symbal: string) {
let length = res.length;
// 每隔一项添加一个字符
while (length > 1) {
res.splice(length - 1, 0, symbal);
length--;
}
return res
}
// 负数会因为减号被拆分情况,此方法将负数恢复
function minu(arr: string[]) {
// 如果运算值为负数,则数组首位会是空字符串,需要判断并去除
if (arr[0] === '') arr.splice(0, 1)
// 此时将指针指向数组的最后一位,并在下面的循环将指针向前移动
let length = arr.length - 1;
// 当指指针移出数组,退出循环
while (length >= 0) {
// 当前的值为负号,如果是在第一位上,或者减号前面不为数字,则将其与后一位合并成为负数,同时指针需要向前多移动一位
if (arr[length] === '-' && (length === 0 || !/^[0-9][0-9]./.test((arr[length - 1])))) {
arr.splice(length, 2, `-${arr[length + 1]}`);
length--;
}
// 如果因为负数的情况,会多一个空字符串的数组项,需要删除并将指针向前移动一位
if (arr[length] === '') {
arr.splice(length, 1)
length--
}
// 每次循环指针向前移位
length--;
}
// 返回最终处理后的数组
return arr
};
// 将四则运算符转换为操作符和数字的多维数组并最终转换为一维数组
// 此方法是按照加减乘除对表达式进行切分,然后在切分后组合成一个由数字和符号组成的数组,用于最终计算结果
const array = addSymbal(newStr.split('+'), '+')
.map(item =>
addSymbal(item.split('*'), '*')
.map(item =>
addSymbal(item.split('/'), '/')
.map(item =>
minu(addSymbal(item.split('-'), '-'))
)
)
).flat(3);
// 处理所有的乘除法计算
while (array.includes('*') || array.includes('/')) {
const symbal = array.find(item => item === '*' || item === '/');
const index = array.findIndex(item => item === '*' || item === '/');
index > 0 && symbal === '*'
? array.splice(index - 1, 3, multiplication(array[index - 1], array[index + 1]) + '')
: array.splice(index - 1, 3, division(array[index - 1], array[index + 1]) + '');
}
// 处理所有的加减法运算
while (array.length > 1) {
array[1] === '+'
? array.splice(0, 3, add(array[0], array[2]) + '')
: array.splice(0, 3, subtraction(array[0], array[2]) + '')
}
return Number(array[0]).toFixed(2);
}
return (
<div className="home">
<Form name="dynamic_form_nest_item" onFinish={onFinish} autoComplete="off" initialValues={value}>
<Form.List name="constants">
{(fields, { add, remove }) => {
return (
<>
{fields.map((field, index) => (
<Space key={field.key} style={{ display: 'flex', marginBottom: 8 }} align="start">
<Form.Item
{...field}
label={<div>常量名:<b>S{index + 1}</b></div>}
name={[field.name, 'value']}
fieldKey={[field.fieldKey, 'last']}
rules={[{ required: true, message: '常量值不得为空' }]}
>
<InputNumber placeholder="常量值" />
</Form.Item>
<MinusCircleOutlined
onClick={() => {
remove(field.name);
}}
/>
</Space>
))}
<Form.Item>
<Button onClick={add} type="link">
<PlusOutlined /> 添加常量
</Button>
</Form.Item>
</>
);
}}
</Form.List>
{/* 此输入框是用来输入初始价格,用于测试公式计算过程和结果是否正确 */}
<Form.Item name="purchase" label={<div>初始价格:<b>(X0)</b></div>} rules={[{ required: true, message: '请输入初始价格' }]}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.List name="links">
{(fields, { add, remove }) => (
<>
{fields.map((field, index) => (
<Space key={field.key} align="baseline" style={{ display: 'flex', marginBottom: 8 }}>
<MinusCircleOutlined onClick={() => remove(field.name)} />
<Form.Item
{...field}
label={<div>节点: <b>X{index + 1}</b></div>}
name={[field.name, 'sight']}
rules={[{ required: true, message: 'Missing sight' }]}
>
<Select style={{ width: 130 }} placeholder="请选择节点">
{optionList.map((item) => (
<Option key={item.value} value={item.value}>
{item.label}
</Option>
))}
</Select>
</Form.Item>
<Form.Item
{...field}
label="计算公式"
name={[field.name, 'price']}
rules={[{ required: true, message: '请输入计算公式,例如(X0*1.1)' }]}
>
<Input placeholder="例如(X0*1.1)" />
</Form.Item>
{linksValue.length > index ? linksValue[index] : ''}
</Space>
))}
<Form.Item>
<Button type="link" onClick={() => add()} icon={<PlusOutlined />}>
添加节点
</Button>
</Form.Item>
</>
)}
</Form.List>
<p>计算结果:{JSON.stringify(linksValue)}</p>
<Form.Item>
<Button type="primary" htmlType="submit">
确定
</Button>
</Form.Item>
</Form>
</div>
)
}