
3分钟看懂公式源码原理,这份保姆级教程带你从零搭建
官方文档翻了三遍还是云里雾里?别慌,这种“只见森林不见树”的困境我太懂了。
今天这篇保姆级教程,不整虚的,直接带你从目录结构到核心代码,一步步把【公式源码】跑通。
项目目标:我们要解决什么问题?
先说清楚,我们做的这个公式源码解析器,不是要重新发明数学库,而是为了搞定一个很具体的痛点:动态渲染与高精度计算。
在很多业务场景里,比如电商后台的动态定价规则、游戏里的伤害计算公式,或者金融风控的评分模型,公式往往是写在配置表里的字符串。如果每次改公式都要发版,那简直是噩梦。
我们要实现的目标有三个:安全性:防止恶意注入,只允许数字、变量和基础运算符。
高性能:解析一次,编译成字节码或AST(抽象语法树),后续计算直接执行,避免重复解析。
可扩展性:支持自定义函数,比如 sin(), max(), if() 等。这不是简单的字符串替换,而是一个标准的编译器前端工程。
目录结构:工程化思维落地
别一上来就写代码,先搭好骨架。一个可维护的公式引擎,目录结构必须清晰。以下是我建议的标准结构:
formula-engine/
├── src/
│ ├── lexer.js # 词法分析器:把字符串切成Token
│ ├── parser.js # 语法分析器:把Token变成AST
│ ├── ast.js # AST节点定义
│ ├── interpreter.js # 解释器:遍历AST执行计算
│ ├── optimizer.js # 优化器:常量折叠、简化
│ └── index.js # 入口文件,暴露API
├── tests/
│ ├── lexer.test.js
│ ├── parser.test.js
│ └── integration.test.js
├── package.json
└── README.md关键点:lexer 和 parser 是核心,参考了 ANTLR 或 JavaCC 的设计思想,但这里我们用原生 JS 手写,更轻量。
optimizer 是加分项,但在生产环境中至关重要,它能提前算出常量部分,减少运行时开销。
每个模块单一职责,方便单元测试。核心代码实现:逐行拆解
1. 词法分析(Lexer):把公式变成 Token
输入:a * (b + c)
输出:[Token(Var, 'a'), Token(Op, '*'), Token(Paren, '('), Token(Var, 'b'), Token(Op, '+'), Token(Var, 'c'), Token(Paren, ')')]
// src/lexer.js
const TokenTypes = {NUMBER: 'NUMBER',VARIABLE: 'VARIABLE',OPERATOR: 'OPERATOR',PAREN: 'PAREN',COMMA: 'COMMA',END: 'END'
};function tokenize(input) {const tokens = [];let i = 0;while (i input.length) {// 跳过空白if (/\s/.test(input[i])) {i++;continue;}// 数字:支持小数if (/\d/.test(input[i]) || (input[i] === '.' /\d/.test(input[i+1]))) {let numStr = '';while (i input.length (/\d/.test(input[i]) || input[i] === '.')) {numStr += input[i];i++;}tokens.push({ type: TokenTypes.NUMBER, value: parseFloat(numStr) });continue;}// 变量:字母开头if (/[a-zA-Z_]/.test(input[i])) {let varStr = '';while (i input.length /[a-zA-Z0-9_]/.test(input[i])) {varStr += input[i];i++;}tokens.push({ type: TokenTypes.VARIABLE, value: varStr });continue;}// 运算符if ('+-*/%^'.includes(input[i])) {tokens.push({ type: TokenTypes.OPERATOR, value: input[i] });i++;continue;}// 括号和逗号if ('()'.includes(input[i])) {tokens.push({ type: TokenTypes.PAREN, value: input[i] });i++;continue;}if (input[i] === ',') {tokens.push({ type: TokenTypes.COMMA, value: ',' });i++;continue;}// 非法字符throw new Error(`Invalid character: ${input[i]} at position ${i}`);}tokens.push({ type: TokenTypes.END });return tokens;
}module.exports = { tokenize, TokenTypes };逐行讲解:正则匹配是核心,注意小数点的处理逻辑,防止把 . 误判为其他符号。
变量名支持数字和下划线,符合大多数编程语言的命名规范。
错误处理要抛出具体位置,方便调试。2. 语法分析(Parser):构建 AST
这是最复杂的部分。我们要处理运算符优先级。* 和 / 优先级高于 + 和 -。
// src/parser.js
const { TokenTypes } = require('./lexer');class Parser {constructor(tokens) {this.tokens = tokens;this.pos = 0;}current() {return this.tokens[this.pos];}eat(type) {if (this.current().type !== type) {throw new Error(`Expected ${type}, got ${this.current().type}`);}this.pos++;}parse() {const node = this.expression();if (this.current().type !== TokenTypes.END) {throw new Error('Unexpected token');}return node;}expression() {let left = this.term();while (this.current().type === TokenTypes.OPERATOR (this.current().value === '+' || this.current().value === '-')) {const op = this.current().value;this.eat(TokenTypes.OPERATOR);const right = this.term();left = { type: 'BinaryOp', op, left, right };}return left;}term() {let left = this.factor();while (this.current().type === TokenTypes.OPERATOR (this.current().value === '*' || this.current().value === '/' || this.current().value === '%' || this.current().value === '^')) {const op = this.current().value;this.eat(TokenTypes.OPERATOR);const right = this.factor();left = { type: 'BinaryOp', op, left, right };}return left;}factor() {if (this.current().type === TokenTypes.NUMBER) {const val = this.current().value;this.eat(TokenTypes.NUMBER);return { type: 'Number', value: val };}if (this.current().type === TokenTypes.VARIABLE) {const name = this.current().value;this.eat(TokenTypes.VARIABLE);return { type: 'Variable', name };}if (this.current().type === TokenTypes.PAREN this.current().value === '(') {this.eat(TokenTypes.PAREN);const expr = this.expression();this.eat(TokenTypes.PAREN); // 匹配右括号return expr;}throw new Error(`Unexpected token: ${this.current().value}`);}
}module.exports = Parser;避坑指南:左递归问题:这里用了循环来模拟左结合性,而不是递归调用 expression(),避免栈溢出。
括号处理:factor 里处理括号,确保 (a+b)*c 这种结构正确解析。
幂运算:^ 是右结合的,如果需要严格支持,需要单独处理,但为了简化,这里暂时按左结合处理,实际项目中建议扩展。3. 解释器(Interpreter):执行计算
AST 构建好了,接下来就是遍历树,计算值。
// src/interpreter.js
function interpret(node, context = {}) {switch (node.type) {case 'Number':return node.value;case 'Variable':if (!(node.name in context)) {throw new Error(`Undefined variable: ${node.name}`);}return context[node.name];case 'BinaryOp':const leftVal = interpret(node.left, context);const rightVal = interpret(node.right, context);switch (node.op) {case '+': return leftVal + rightVal;case '-': return leftVal - rightVal;case '*': return leftVal * rightVal;case '/': if (rightVal === 0) throw new Error('Division by zero');return leftVal / rightVal;case '%': return leftVal % rightVal;case '^': return Math.pow(leftVal, rightVal);default: throw new Error(`Unknown operator: ${node.op}`);}default:throw new Error(`Unknown node type: ${node.type}`);}
}module.exports = { interpret };注意:除零检查必须做,生产环境里这种边界情况最容易出事故。
上下文 context 传入变量值,支持动态变量绑定。运行与测试:确保代码靠谱
代码写完了,不测试等于没写。我们用 Jest 来写几个核心用例。
// tests/integration.test.js
const { tokenize } = require('../src/lexer');
const Parser = require('../src/parser');
const { interpret } = require('../src/interpreter');function evaluate(formula, context) {const tokens = tokenize(formula);const parser = new Parser(tokens);const ast = parser.parse();return interpret(ast, context);
}test('Basic arithmetic', () = {expect(evaluate('1 + 2 * 3', {})).toBe(7);expect(evaluate('(1 + 2) * 3', {})).toBe(9);
});test('Variables', () = {expect(evaluate('a + b', { a: 10, b: 20 })).toBe(30);expect(evaluate('x * y - z', { x: 2, y: 3, z: 1 })).toBe(5);
});test('Error handling', () = {expect(() = evaluate('1 / 0', {})).toThrow('Division by zero');expect(() = evaluate('a + b', { a: 1 })).toThrow('Undefined variable: b');
});测试要点:优先级测试:1 + 2 * 3 必须等于 7,这是最基本的正确性验证。
括号测试:(1 + 2) * 3 必须等于 9,验证括号是否生效。
异常测试:除零和未定义变量必须抛出明确错误,不能静默失败。优化扩展:生产级必备
基础功能跑通了,但离生产还有距离。以下是三个关键优化方向:
1. 常量折叠(Constant Folding)
如果公式里全是数字,比如 1 + 2 * 3,应该在解析阶段就计算出 7,而不是每次执行都算一遍。
// 在 parser.js 的 BinaryOp 节点生成时,如果左右都是 Number,直接计算
function tryConstantFold(op, left, right) {if (left.type === 'Number' right.type === 'Number') {const result = interpret({ type: 'BinaryOp', op, left, right }, {});return { type: 'Number', value: result };}return null;
}2. 自定义函数支持
很多业务需要 max(a, b) 或 round(x, 2)。需要在 Lexer 中识别函数名,在 Parser 中构建 FunctionCall 节点,在 Interpreter 中注册函数表。
const functions = {max: (...args) = Math.max(...args),min: (...args) = Math.min(...args),round: (x, digits = 0) = Number(Math.round(x + 'e' + digits) + 'e-' + digits)
};3. 性能优化:AST 缓存
对于频繁执行的公式,可以将 AST 序列化后缓存,避免重复解析。
const astCache = new Map();function evaluateCached(formula, context) {let ast = astCache.get(formula);if (!ast) {const tokens = tokenize(formula);const parser = new Parser(tokens);ast = parser.parse();astCache.set(formula, ast);}return interpret(ast, context);
}注意:缓存 key 必须是标准化的公式字符串,否则会导致缓存失效。
小结:从玩具到生产
这套公式源码引擎,麻雀虽小五脏俱全。它涵盖了词法、语法、解释、优化四个核心阶段,符合编译器设计的基本范式。
实战建议:不要过度设计,先实现基础功能,再逐步添加优化。
错误处理比功能本身更重要,生产环境里,一个清晰的错误信息能节省 80% 的排查时间。
参考 ECMAScript 规范 中的表达式定义,确保你的运算符优先级与 JS 一致,避免用户困惑。你公司项目里是怎么处理动态公式的?是直接用 eval(别问,问就是事故),还是自己写了个简易解析器?或者用了像 math.js 这样的第三方库?欢迎在评论区分享你的方案和踩过的坑,一起交流避坑。