mathjs 表达式解析与求值完全指南:evaluate、compile、parse 与 Parser 实战
【免费下载链接】mathjsAn extensive math library for JavaScript and Node.js项目地址: https://gitcode.com/gh_mirrors/ma/mathjs
导读
表达式解析与求值是 mathjs 的核心能力:它提供了一套接近 JavaScript 但更贴合数学习惯的表达式语法(如^表示幂、支持单位换算与隐式乘法),并给出四条由浅入深的使用路径——math.evaluate即算即得、math.compile一次编译多次求值、math.parse获取可遍历可导出的表达式树、math.parser()携带内存作用域的状态化解析器。读完本文,你将掌握这四种 API 的调用方式、作用域(Scope)对象与 Map 的传入机制、解析器内部的分词与语法树构建流程,以及序列化与安全相关的边界注意事项。
四种解析求值方式概览
在 docs/expressions/parsing.md 中,mathjs 官方将表达式的解析与求值归纳为四种途径:
| 方式 | 入口 | 特点 |
|---|---|---|
函数math.evaluate(expr [, scope]) | src/expression/function/evaluate.js | 一步到位,解析、编译、求值一气呵成 |
函数math.compile(expr) | src/expression/function/compile.js | 编译后返回带evaluate方法的代码对象,可反复求值 |
函数math.parse(expr) | src/expression/parse.js | 返回表达式树根节点,可编译、遍历、导出 |
math.parser()创建解析器 | src/expression/Parser.js | 自带内存作用域,提供evaluate及变量增删查改的便捷方法 |
从源码可以确认四者的调用关系:evaluate的实现是parse(expr).compile().evaluate(scope)(见 evaluate.js),compile的实现是parse(expr).compile()(见 compile.js),而Parser.evaluate则是evaluate(expr, this.scope)(见 Parser.js)。因此,parse → compile → evaluate 是整条链路的内核,其余 API 都是其上的封装。
math.evaluate:一步到位的即时求值
语法签名
math.evaluate(expr) math.evaluate(expr, scope) math.evaluate([expr1, expr2, expr3, ...]) math.evaluate([expr1, expr2, expr3, ...], scope)第一个参数接受单个表达式字符串,也可以是字符串数组;源码中通过typed分派到'Array | Matrix'重载,并用deepMap逐项求值(见 evaluate.js),因此传入Matrix同样可行。
第二个可选参数是scope(作用域),用于存放和解析表达式中的变量与函数。官方推荐使用 JavaScript 原生Map,同时也接受普通对象或任何实现了Map接口(具备get、set、keys、has方法)的自定义类。表达式中的赋值(如c = 2.3 + 4.5)会写回该作用域。
基础示例
// evaluate expressions math.evaluate('sqrt(3^2 + 4^2)') // 5 math.evaluate('sqrt(-4)') // 2i math.evaluate('2 inch to cm') // 5.08 cm math.evaluate('cos(45 deg)') // 0.7071067811865476 // provide a scope let scope = { a: 3, b: 4 } math.evaluate('a * b', scope) // 12 math.evaluate('c = 2.3 + 4.5', scope) // 6.8 scope.c // 6.8注意示例中的2 inch to cm与cos(45 deg):它们展示了 mathjs 表达式语法独有的能力——单位换算(to运算符)与带单位的三角函数,这些是纯 JavaScript 不具备的。
未传 scope 时的内部行为
当省略 scope 时,源码会调用createEmptyMap()新建一个空的Map参与求值(见 evaluate.js),赋值结果因此被丢弃。若想让表达式中的赋值持久化,务必显式传入 scope。
math.compile:一次编译,反复求值
语法签名
math.compile(expr) math.compile([expr1, expr2, expr3, ...])compile是「先 parse 再 compile」的快捷方式。它返回一个包含evaluate([scope])方法的代码对象:
const code = math.compile(expr) // compile an expression const result = code.evaluate([scope]) // evaluate the code with an optional scope传入数组时返回由多个代码对象组成的数组(源码中同样用deepMap逐项处理,见 compile.js)。
为什么需要编译?
一个表达式只需要编译一次,之后可以反复求值、且每次可以使用不同的 scope。当你在循环、事件回调或热路径中反复计算同一表达式时,这种「先编译、后求值」的模式能显著减少重复的解析与代码生成开销。
// parse an expression into a node, and evaluate the node const code1 = math.compile('sqrt(3^2 + 4^2)') code1.evaluate() // 5 let scope = { a: 3, b: 4 } const code2 = math.compile('a * b') code2.evaluate(scope) // 12 scope.a = 5 // 修改作用域后无需重新编译 code2.evaluate(scope) // 20编译背后的机制
Node.compile()是整条链路的真正执行者(见 Node.js)。其内部逻辑是:
- 调用各节点实现类(如
OperatorNode、SymbolNode)的_compile(mathWithTransform, {})方法,把表达式树预编译成一个接受(scope, args, context)的 JavaScript 闭包函数,把能在编译期确定的计算尽可能提前算好; - 返回
{ evaluate }对象;evaluate内部通过createMap(scope)将传入的对象包装成内部可用的 Map 接口(见 map.js),并调用_validateScope校验作用域中是否有保留关键字(见 Node.js)。
因此code.evaluate(scope)与node.evaluate(scope)在效果上是等价的——后者的实现就是this.compile().evaluate(scope)(见 Node.js)。
math.parse:获取表达式树
语法签名
math.parse(expr) math.parse([expr1, expr2, expr3, ...])parse返回表达式的根节点(Node),节点树可以进一步编译、求值、遍历与导出:
const node = math.parse(expr) // parse expression into a node tree const code = node.compile() // compile the node tree const result = code.evaluate([scope]) // evaluate the code with an optional scope节点 API 的完整说明见 docs/expressions/expression_trees.md。
完整示例
// parse an expression into a node, and evaluate the node const node1 = math.parse('sqrt(3^2 + 4^2)') const code1 = node1.compile() code1.evaluate() // 5 // provide a scope const node2 = math.parse('x^a') const code2 = node2.compile() let scope = { x: 3, a: 2 } code2.evaluate(scope) // 9 // change a value in the scope and re-evaluate the node scope.a = 3 code2.evaluate(scope) // 27导出为字符串与 LaTeX
解析后的表达式树支持两种文本导出:
node.toString():输出带括号的数学文本;node.toTex():输出 LaTeX 代码,可配合浏览器中的 MathJax 等渲染库实现公式的漂亮排版。
// parse an expression const node = math.parse('sqrt(x/x+1)') node.toString() // returns 'sqrt((x / x) + 1)' node.toTex() // returns '\sqrt{ {\frac{x}{x} }+{1} }'解析器的内部实现:从字符到节点树
math.parse的实现在 src/expression/parse.js 中,其内部采用递归下降解析器(recursive descent parser),核心流程分三层:
1. 词法分析(Tokenization)getToken逐个读取字符并归类为 Token。源码中定义了TOKENTYPE枚举:NULL(空)、DELIMITER(分隔符/运算符)、NUMBER(数字)、SYMBOL(符号)、UNKNOWN(未知,会直接抛语法错误)。DELIMITERS表中列出了全部支持的运算符与定界符,包括+ - * / ^ % !、矩阵定界符[ ] { } ( )、点运算符.* ./ .^、移位<< >> >>>、比较== != < > <= >=、逻辑& | ^|、空值合并??、可选链?.等;NAMED_DELIMITERS则把mod、to、in、and、xor、or、not识别为命名运算符(见 parse.js)。词法层还负责处理#注释、空白、0b/0o/0x进制字面量、科学计数法2.3e-4、Unicode 数学符号(含希腊字母与数学字母数字符号的代理对判断)等。
2. 语法分析(Parsing)按优先级从低到高逐级下降解析:parseStart → parseBlock → parseAssignment → parseConditional → parseLogicalOr/Xor/And → parseBitwiseOr/Xor/And → parseRelational → parseShift → parseConversion → parseRange → parseAddSubtract → ...。每个函数消费对应优先级的 Token 并构造对应的节点类型:AssignmentNode(变量赋值a = 2/3、矩阵子集赋值A[1,2] = 4、函数赋值f(x) = x^2)、ConditionalNode(三元? :)、OperatorNode(二元/一元运算)、RelationalNode(链式比较a > b >= c)、RangeNode(区间start:step:end)、BlockNode(多行/分号分隔的块)等。所有节点类位于 src/expression/node/。
3. 语义构造解析完成后,parseStart会检查表达式末尾是否有残留 Token,若有则抛出「Unexpected operator / Unexpected part」的语法错误(见 parse.js)。
值得注意的解析细节:to与in都被映射为'to'运算(in是to的别名,见 parse.js),但当in处于表达式末尾(后面跟])},;之一)时会被识别为英寸单位而非换算运算符(见 parse.js)——这正是2 inch to cm中单位语法能够成立的原因。此外,解析器还支持math.parse(expr, options)传入自定义节点(options.nodes,见 parse.js),用于扩展语法。
math.parser():携带作用域的状态化解析器
创建与函数清单
const parser = math.parser()与静态函数不同,Parser内部维护一个常驻内存的 scope(源码中通过Object.defineProperty定义为只读的Map,见 Parser.js),所有求值都读写该作用域。Parser提供以下方法:
| 方法 | 作用 |
|---|---|
clear() | 彻底清空解析器的 scope |
evaluate(expr) | 求值一个表达式,返回结果 |
get(name) | 从 scope 中取出变量或函数,不存在时返回undefined |
getAll() | 返回包含 scope 中全部变量的普通对象 |
getAllAsMap() | 返回包含 scope 中全部变量的Map |
remove(name) | 从 scope 中删除变量或函数 |
set(name, value) | 在 scope 中设置变量或函数 |
对应实现见 Parser.js:getAll通过toObject(this.scope)把 Map 转成对象,set会先用isValidVariableName校验变量名(仅允许字母、下划线、$、Unicode 字母与数字,见 Parser.js),remove调用scope.delete(name)。
使用示例
// create a parser const parser = math.parser() // evaluate expressions parser.evaluate('sqrt(3^2 + 4^2)') // 5 parser.evaluate('sqrt(-4)') // 2i parser.evaluate('2 inch to cm') // 5.08 cm parser.evaluate('cos(45 deg)') // 0.7071067811865476 // define variables and functions parser.evaluate('x = 7 / 2') // 3.5 parser.evaluate('x + 3') // 6.5 parser.evaluate('f(x, y) = x^y') // f(x, y) parser.evaluate('f(2, 3)') // 8 // get and set variables and functions const x = parser.get('x') // x = 3.5 const f = parser.get('f') // function const g = f(3, 3) // g = 27 parser.set('h', 500) parser.evaluate('h / 2') // 250 parser.set('hello', function (name) { return 'hello, ' + name + '!' }) parser.evaluate('hello("user")') // "hello, user!" // clear defined functions and variables parser.clear()这段示例体现了Parser的核心价值:f(x, y) = x^y定义的自定义函数不仅能在解析器内被调用,还能被parser.get('f')取出来当作普通 JavaScript 函数使用;同理,parser.set注入的 JavaScript 函数也能在表达式语法中直接调用。
math.parser()是new Parser()的便捷封装,二者等价(见 Parser.js 的注释说明)。此外Parser是 mathjs 的类之一,其类工厂声明在 src/expression/Parser.js 中,带isClass: true标记。
Scope:作用域机制深入
作用域的本质与三种形态
作用域(Scope)是表达式读写变量与函数的数据结构,通过math.evaluate、math.compile、math.parse求值或simplify等函数传入。根据 docs/expressions/parsing.md 的说明,它有三种形态:
- 普通 JavaScript 对象(Plain Object):使用最方便;
Map:更安全,官方推荐;- 任意实现了
get/set/has/keys方法的自定义类:最灵活。
无论哪种形态,mathjs 都保证尽量复用你传入的同一个对象(不复制),使你能在表达式执行后收集脚本写入的变量定义。
内部包装机制
从 src/utils/map.js 的源码可以确认两条关键机制:
ObjectWrappingMap:内部函数只能使用 Map 接口,因此当 scope 是普通对象时,mathjs 会将其包装为ObjectWrappingMap(见 map.js)。包装层通过getSafeProperty/setSafeProperty读写属性,并过滤__proto__等危险属性(见 map.js),这正是对象形态 scope 安全性设计的落点。
PartitionedMap:当表达式中定义了自定义函数(如f(x) = x^2)时,求值阶段会创建子作用域createSubScope(parentScope, args),把函数参数读写到临时 Map,其余变量读写回原 scope(见 src/utils/scope.js 与 map.js)。这样函数体内对参数名的赋值不会污染外部作用域,而其他变量仍能读写原对象。
安全边界:黑名单与保留关键字
- 使用普通对象作为 scope 时,
evaluate会拒绝表达式使用黑名单中的符号,防止 mathjs 表达式逃逸到 JavaScript(例如访问constructor、__proto__等危险属性); - 改用
Map作为 scope 则可以摆脱该黑名单的约束,让表达式自由定义任意名称的变量与函数; - 无论哪种形态,
_validateScope都会检查 scope 中是否包含保留关键字end(见 Node.js 与 src/expression/keywords.js),存在即抛错。
自定义作用域实战
官方在 examples/advanced/custom_scope_objects.js 中给出了四种作用域用法:裸对象、原生Map、只实现最小 Map 接口的CustomMap、以及带父作用域链的AdvancedCustomMap。自定义类只需实现get/set/has/keys四个方法即可被 mathjs 当作 scope 使用,这让「链式父作用域」「惰性解析」「按需加载变量」等灵活场景成为可能。更完整的表达式安全讨论见 docs/expressions/security.md。
序列化:把解析器状态存下来再还原
mathjs 的所有数据类型都可以被序列化,因此包含变量的 scope 也可以安全序列化。但存在一个例外:表达式解析器允许定义自定义函数(如f(x) = x^2),这类函数不能脱离作用域单独序列化——它可能绑定着作用域中的其他变量。
好在Parser提供了完整的序列化支持。其toJSON方法会把 scope 中的变量与表达式函数分别导出为variables与functions(函数以syntax = expr字符串形式保存,外部注入的非表达式函数会被拒绝序列化,见 Parser.js);fromJSON则反向重建,变量直接set,函数重新evaluate定义(见 Parser.js)。
const parser = math.parser() // evaluate some expressions parser.evaluate('w = 2') parser.evaluate('f(x) = x^w') parser.evaluate('c = f(3)') // 9 // serialize the parser with its state const str = JSON.stringify(parser) // deserialize the parser again const parser2 = JSON.parse(str, math.reviver) parser.evaluate('f(4)') // 16注意两个细节:
- 反序列化必须使用
math.reviver(实现见 src/json/reviver.js),它会识别 JSON 中的mathjs: 'Parser'标记并调用Parser.fromJSON; f(x) = x^w能跨序列化保持正确,正是因为f被以定义表达式字符串存储,反序列化时在新的解析器作用域中重新求值定义,其闭包引用也随之重建。
四条路径的选型建议
| 场景 | 推荐 API |
|---|---|
| 一次性计算、交互式 REPL、调试 | math.evaluate |
| 同一表达式在循环/回调中反复执行,追求性能 | math.compile或math.parse(...).compile() |
| 需要遍历、变换、导出表达式树(如公式编辑器、符号化简) | math.parse |
| 需要跨多次求值保持变量与函数状态(如计算器会话、脚本环境) | math.parser() |
相关表达式能力的延伸阅读:表达式语法、表达式树与节点 API、表达式安全。所有 API 的 TypeScript 类型声明可在 types/index.d.ts 中查阅。
【免费下载链接】mathjsAn extensive math library for JavaScript and Node.js项目地址: https://gitcode.com/gh_mirrors/ma/mathjs
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考