使用 js 实现加减乘除模板计算

1,868 阅读6分钟

最近接到了一个新需求,要求实现一个动态的录入表单,这个表单绝大多数都是数字录入,并且其中的某些字段是有关联关系的。例如三个字段 field1field2field3,它们的关系可能为: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