I recently received a new requirement to implement a dynamic entry form that is mostly numeric and has some fields that are related. For example, three fields field1, field2, field3 might have the following relationship: field3 = field1 + field2. And then the back end will give you this expression, and the front end will evaluate it automatically.

You might think, well, if you’re listening for a form value change, depending on the field change, just replace the field name string in the expression with the actual value, and then eval it. This is exactly what I did at first, but the accuracy of JS gave me a kick:

If the values of the two fields are 0.1 and 0.2 respectively, the final computed result will be 0.30000000000000004. This is not acceptable in digital form entry. Simply multiplying by 100 and dividing by 100 is not feasible, and eval is inherently insecure.

Now looking back at the description above, we can summarize the problem into the following question (see the full implementation at the end of the article) :

/** * implements a function that takes expression templates and field values and evaluates the final result */
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
Copy the code

For accuracy, I chose big.js-NPM (nPMjs.com). The overall idea is as follows:

  • Unwrap the field names and operators in the template to parse the string into a token array
  • Process the token array into an inverse Polish expression
  • The inverse Polish expression is evaluated by replacing the field name with the actual value and importing big.js

Ok, step 1:

Parsing the template

The implementation is as follows:

// Matches the re of the parentheses of addition, subtraction, multiplication and division
const operatorReg = /[()+\-/* ]/g;

/** * process the template as a token array *@param {string} STR evaluates the expression template */
export const strToToken = str= > {
    // Extract all operands
    const keys = str.split(operatorReg);
    const tokens = [];
    let temp = str;
    // Parse the template
    while (temp.length > 0) {
        // Template starts with operand
        if (keys.length > 0 && temp.startsWith(keys[0])) {
            temp = temp.replace(keys[0].' ');
            tokens.push(keys.shift());
        }
        // The template begins with an operator
        else {
            tokens.push(temp[0]);
            temp = temp.substr(1); }}// Discard all whitespace characters in the template
    return tokens.filter(token= >token && token ! = =' ');
}
Copy the code

Instead of parsing the template directly, we split the template with the operator so that all that’s left is the operand, and then we match the operand to the template, and if we match the operand, we throw the operand to the last queue, and if we don’t match the operand, we throw the first character to the queue.

Converts to the inverse Polish expression

The implementation is as follows:

// Matches the re of the parentheses of addition, subtraction, multiplication and division
const operatorReg = /[()+\-/* ]/g;

/** * An infix expression is converted to an inverse Polish expression@param {string[]} TokenList infix expression token array */
const tokenToRpn = tokenList= > {
    if(! tokenList || tokenList.length <=0) return [];
    const operators = [];

    // Specifies whether the precedence of the operator is higher than that of the operator in the stack
    const isTokenHighRank = token= > {
        const operatorRand = { '+': 1.The '-': 1.The '*': 2.'/': 2 };
        const topOperator = operators[operators.length - 1];

        return operators.length === 0 ||
            topOperator === '(' ||
            operatorRand[token] > operatorRand[topOperator];
    }

    const outputs = tokenList.reduce((outputs, token) = > {
        // If it is a variable, print it directly
        if(! token.match(operatorReg)) outputs.push(token);// If it is an open parenthesis, go to the operator stack
        else if (token === '(') operators.push(token);
        // If it is a close parenthesis, pop the operator until it meets an open parenthesis
        else if (token === ') ') {
            while (operators.length > 0) {
                const operator = operators.pop();
                if (operator === '(') break; outputs.push(operator); }}// If it is an operator
        else {
            while (operators.length >= 0) {
                // Push the higher priority into the result
                if (isTokenHighRank(token)) {
                    operators.push(token);
                    break; } outputs.push(operators.pop()); }}returnoutputs; } []);return [...outputs, ...operators];
}
Copy the code

Pay attention to matching parentheses and precedence of operators. This implementation still feels a bit long, please let me know if there is a better conversion method.

Replace data and calculate

The implementation is as follows:

const Big = require('big.js');

/** * operator to actual operation */
const calculators = {
    '+': (num1, num2) = > (new Big(num1).plus(num2)),
    The '-': (num1, num2) = > (new Big(num1).minus(num2)),
    The '*': (num1, num2) = > (new Big(num1).times(num2)),
    '/': (num1, num2) = > (new Big(num1).div(num2))
}

/** * get the corresponding data from the dataset */
const getValues = (key, values) = > {
    if(! key)return 0;
    if (typeof key === 'string') return values[key] || Number(key) || 0;
    return key;
}

/** * populates and computes data *@param {string[]} Tokens RPN Token array *@param {object} Values dataset *@returns End result */
const calcRpn = (tokens, values) = > {
    let numarr = []
    for (const token of tokens) {
        const calculator = calculators[token];

        if(! calculator) numarr.push(token);else {
            // The order in which these two values are created cannot be changed, otherwise the pop values will be reversed
            const val2 = getValues(numarr.pop(), values);
            const val1 = getValues(numarr.pop(), values);
            constresult = calculator(val1, val2); numarr.push(result.toNumber()); }}return numarr.pop();
};
Copy the code

It is important to note that the first parameter to getValues receives a value that pops out of numarr, so it could be a field name field1, or it could be the actual value that has been calculated. So you need to determine the type. Another thing to note here is that when the key parameter value is of type string, it may not only be the name of a field, but also an operand in a template.

Complete sample

After these three steps have been completed, the rest of the process is ready to go. You can concatenate all three of them in sequence. Here is the complete example.

const Big = require('big.js');

// Matches the re of the parentheses of addition, subtraction, multiplication and division
const operatorReg = /[()+\-/* ]/g;

/** * process the template as a token array *@param {string} STR evaluates the expression template */
const strToToken = str= > {
    // Extract all operands
    const keys = str.split(operatorReg);
    const tokens = [];
    let temp = str;
    // Parse the template
    while (temp.length > 0) {
        // Template starts with operand
        if (keys.length > 0 && temp.startsWith(keys[0])) {
            temp = temp.replace(keys[0].' ');
            tokens.push(keys.shift());
        }
        // The template begins with an operator
        else {
            tokens.push(temp[0]);
            temp = temp.substr(1); }}// Discard all whitespace characters in the template
    return tokens.filter(token= >token && token ! = =' ');
}

/** * An infix expression is converted to an inverse Polish expression@param {string[]} TokenList infix expression token array */
const tokenToRpn = tokenList= > {
    if(! tokenList || tokenList.length <=0) return [];
    const operators = [];

    // Specifies whether the precedence of the operator is higher than that of the operator in the stack
    const isTokenHighRank = token= > {
        const operatorRand = { '+': 1.The '-': 1.The '*': 2.'/': 2 };
        const topOperator = operators[operators.length - 1];

        return operators.length === 0 ||
            topOperator === '(' ||
            operatorRand[token] > operatorRand[topOperator];
    }

    const outputs = tokenList.reduce((outputs, token) = > {
        // If it is a variable, print it directly
        if(! token.match(operatorReg)) outputs.push(token);// If it is an open parenthesis, go to the operator stack
        else if (token === '(') operators.push(token);
        // If it is a close parenthesis, pop the operator until it meets an open parenthesis
        else if (token === ') ') {
            while (operators.length > 0) {
                const operator = operators.pop();
                if (operator === '(') break; outputs.push(operator); }}// If it is an operator
        else {
            while (operators.length >= 0) {
                // Push the higher priority into the result
                if (isTokenHighRank(token)) {
                    operators.push(token);
                    break; } outputs.push(operators.pop()); }}returnoutputs; } []);return [...outputs, ...operators];
}

/** * operator to actual operation */
const calculators = {
    '+': (num1, num2) = > (new Big(num1).plus(num2)),
    The '-': (num1, num2) = > (new Big(num1).minus(num2)),
    The '*': (num1, num2) = > (new Big(num1).times(num2)),
    '/': (num1, num2) = > (new Big(num1).div(num2))
}

/** * get the corresponding data from the dataset */
const getValues = (key, values) = > {
    if(! key)return 0;
    if (typeof key === 'string') return values[key] || Number(key) || 0;
    return key;
}

/** * populates and computes data *@param {string[]} Tokens RPN Token array *@param {object} Values dataset *@returns End result */
const calcRpn = function(tokens, values) {
    let numarr = []
    for (const token of tokens) {
        const calculator = calculators[token];

        if(! calculator) numarr.push(token);else {
            // The order in which these two values are created cannot be changed, otherwise the pop values will be reversed
            const val2 = getValues(numarr.pop(), values);
            const val1 = getValues(numarr.pop(), values);
            constresult = 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
Copy the code