最近接到了一个新需求,要求实现一个动态的录入表单,这个表单绝大多数都是数字录入,并且其中的某些字段是有关联关系的。例如三个字段 field1、field2、field3,它们的关系可能为:field3 = field1 + field2。到时候后端会提供给你这个表达式,前端来自动计算。
你可能会想,没啥问题,监听 form 值变更,依赖字段变化了就把这个表达式里的字段名字符串替换成实际值,最后 eval 一下不就好了。我一开始也确实是这么做的,但是 js 的精度问题给了我一脚:
如果两个字段的值分别为 0.1 和 0.2,最后计算的结果将会为 0.30000000000000004。这在这种数字表单录入里是不能接受的,单纯的乘 100 最后除 100 也不靠谱,加上 eval 本身的不安全性,所以路还是要一步步的走。
现在回看上面的描述,我们可以把问题整理成下面这道题(完整实现见文末):
/**
* 实现一个函数,接收表达式模板和字段值,计算最终结果
*/
const templateCalc = (template, values) => {
// ...
}
templateCalc('(val1 + val2) / val3', { val1: 1, val2: 2, val3: 2 });
// 1.5
templateCalc('((val1 + val2) - val3 * val4) / val3', { val1: 1, val2: 2, val3: 2, val4: 10 });
// -8.5
templateCalc('val1 - val2', { val1: 0.3, val2: 0.2 });
// 0.1
templateCalc('(val1 + val2) / 10000', { val1: 100, val2: 5 });
// 0.0105
在精度问题上,我选择了 big.js - npm (npmjs.com) 来处理。处理的整体思路如下:
- 把模板中的字段名和操作符拆开,即将字符串解析为 token 数组
- 把 token 数组处理成逆波兰表达式
- 计算逆波兰表达式时将字段名替换为实际值并引入 big.js 计算
ok,接下来搞第一步:
解析模板
实现如下:
// 匹配加减乘除括号的正则
const operatorReg = /[()+\-/* ]/g;
/**
* 将模板处理为 token 数组
* @param {string} str 要计算的表达式模板
*/
export const strToToken = str => {
// 提取出所有操作数
const keys = str.split(operatorReg);
const tokens = [];
let temp = str;
// 解析模板
while (temp.length > 0) {
// 模板开头是操作数
if (keys.length > 0 && temp.startsWith(keys[0])) {
temp = temp.replace(keys[0], '');
tokens.push(keys.shift());
}
// 模板开头是操作符
else {
tokens.push(temp[0]);
temp = temp.substr(1);
}
}
// 把模板里的空白字符都丢掉
return tokens.filter(token => token && token !== ' ');
}
这里取了个巧,没有直接去解析模板,而且先用操作符把模板分隔开,这样剩下的就是操作数了,然后拿操作数去头部匹配模板,匹配上了就把这个操作数扔到最后队列里,没匹配上就说明是个操作符,把第一个字符扔队列里。
转换为逆波兰表达式
实现如下:
// 匹配加减乘除括号的正则
const operatorReg = /[()+\-/* ]/g;
/**
* 中缀表达式转换成逆波兰表达式
* @param {string[]} tokenList 中缀表达式 token 数组
*/
const tokenToRpn = tokenList => {
if (!tokenList || tokenList.length <= 0) return [];
const operators = [];
// 指定操作符优先级是否高于栈中操作符的优先级
const isTokenHighRank = token => {
const operatorRand = { '+': 1, '-': 1, '*': 2, '/': 2 };
const topOperator = operators[operators.length - 1];
return operators.length === 0 ||
topOperator === '(' ||
operatorRand[token] > operatorRand[topOperator];
}
const outputs = tokenList.reduce((outputs, token) => {
// 如果是变量,直接输出
if (!token.match(operatorReg)) outputs.push(token);
// 如果是左括号,入操作符栈
else if (token === '(') operators.push(token);
// 如果是右括号,把操作符弹出到遇见左括号
else if (token === ')') {
while (operators.length > 0) {
const operator = operators.pop();
if (operator === '(') break;
outputs.push(operator);
}
}
// 如果是运算符
else {
while (operators.length >= 0) {
// 把优先级更高的推入结果
if (isTokenHighRank(token)) {
operators.push(token);
break;
}
outputs.push(operators.pop());
}
}
return outputs;
}, []);
return [...outputs, ...operators];
}
里边要注意前后括号的匹配和操作符优先级问题。这个实现感觉还是有点长了,如果有更好的转换方法请务必告诉我。
替换数据并计算
实现如下:
const Big = require('big.js');
/**
* 运算符到实际操作的映射
*/
const calculators = {
'+': (num1, num2) => (new Big(num1).plus(num2)),
'-': (num1, num2) => (new Big(num1).minus(num2)),
'*': (num1, num2) => (new Big(num1).times(num2)),
'/': (num1, num2) => (new Big(num1).div(num2))
}
/**
* 从数据集里获取对应的数据
*/
const getValues = (key, values) => {
if (!key) return 0;
if (typeof key === 'string') return values[key] || Number(key) || 0;
return key;
}
/**
* 填充并计算数据
* @param {string[]} tokens rpn token 数组
* @param {object} values 数据集
* @returns 最终结果
*/
const calcRpn = (tokens, values) => {
let numarr = []
for (const token of tokens) {
const calculator = calculators[token];
if (!calculator) numarr.push(token);
else {
// 这两个值的创建顺序不能变,否则 pop 出来的值就反了
const val2 = getValues(numarr.pop(), values);
const val1 = getValues(numarr.pop(), values);
const result = calculator(val1, val2);
numarr.push(result.toNumber());
}
}
return numarr.pop();
};
这里需要注意的是,getValues 第一个参数接收的值是从计算栈 numarr 中弹出来的,所以它有可能是一个字段名 field1,也有可能是已经计算过的实际数值。所以需要判断其类型,这里还有一点需要注意的是,当 key 参数值的类型为 string 时,不仅代表其有可能为字段名,还有可能为一个模板中的操作符。
完整示例
这三步完成之后剩下的就好办了,把它们仨依次串联起来就可以了,下面是完整示例,丢给 node 就能跑:
const Big = require('big.js');
// 匹配加减乘除括号的正则
const operatorReg = /[()+\-/* ]/g;
/**
* 将模板处理为 token 数组
* @param {string} str 要计算的表达式模板
*/
const strToToken = str => {
// 提取出所有操作数
const keys = str.split(operatorReg);
const tokens = [];
let temp = str;
// 解析模板
while (temp.length > 0) {
// 模板开头是操作数
if (keys.length > 0 && temp.startsWith(keys[0])) {
temp = temp.replace(keys[0], '');
tokens.push(keys.shift());
}
// 模板开头是操作符
else {
tokens.push(temp[0]);
temp = temp.substr(1);
}
}
// 把模板里的空白字符都丢掉
return tokens.filter(token => token && token !== ' ');
}
/**
* 中缀表达式转换成逆波兰表达式
* @param {string[]} tokenList 中缀表达式 token 数组
*/
const tokenToRpn = tokenList => {
if (!tokenList || tokenList.length <= 0) return [];
const operators = [];
// 指定操作符优先级是否高于栈中操作符的优先级
const isTokenHighRank = token => {
const operatorRand = { '+': 1, '-': 1, '*': 2, '/': 2 };
const topOperator = operators[operators.length - 1];
return operators.length === 0 ||
topOperator === '(' ||
operatorRand[token] > operatorRand[topOperator];
}
const outputs = tokenList.reduce((outputs, token) => {
// 如果是变量,直接输出
if (!token.match(operatorReg)) outputs.push(token);
// 如果是左括号,入操作符栈
else if (token === '(') operators.push(token);
// 如果是右括号,把操作符弹出到遇见左括号
else if (token === ')') {
while (operators.length > 0) {
const operator = operators.pop();
if (operator === '(') break;
outputs.push(operator);
}
}
// 如果是运算符
else {
while (operators.length >= 0) {
// 把优先级更高的推入结果
if (isTokenHighRank(token)) {
operators.push(token);
break;
}
outputs.push(operators.pop());
}
}
return outputs;
}, []);
return [...outputs, ...operators];
}
/**
* 运算符到实际操作的映射
*/
const calculators = {
'+': (num1, num2) => (new Big(num1).plus(num2)),
'-': (num1, num2) => (new Big(num1).minus(num2)),
'*': (num1, num2) => (new Big(num1).times(num2)),
'/': (num1, num2) => (new Big(num1).div(num2))
}
/**
* 从数据集里获取对应的数据
*/
const getValues = (key, values) => {
if (!key) return 0;
if (typeof key === 'string') return values[key] || Number(key) || 0;
return key;
}
/**
* 填充并计算数据
* @param {string[]} tokens rpn token 数组
* @param {object} values 数据集
* @returns 最终结果
*/
const calcRpn = function(tokens, values) {
let numarr = []
for (const token of tokens) {
const calculator = calculators[token];
if (!calculator) numarr.push(token);
else {
// 这两个值的创建顺序不能变,否则 pop 出来的值就反了
const val2 = getValues(numarr.pop(), values);
const val1 = getValues(numarr.pop(), values);
const result = calculator(val1, val2);
numarr.push(result.toNumber());
}
}
return numarr.pop();
};
const templateCalc = (template, values) => {
const tokens = strToToken(template)
const rpn = tokenToRpn(tokens)
const result = calcRpn(rpn, values)
return result
}
console.log(templateCalc('(val1 + val2) / val3', { val1: 1, val2: 2, val3: 2 }));
// 1.5
console.log(templateCalc('((val1 + val2) - val3 * val4) / val3', { val1: 1, val2: 2, val3: 2, val4: 10 }));
// -8.5
console.log(templateCalc('val1 - val2', { val1: 0.3, val2: 0.2 }));
// 0.1
console.log(templateCalc('(val1 + val2) / 10000', { val1: 100, val2: 5 }));
// 0.0105