做低代码、规则引擎或者复杂表单配置的时候,你一定遇到过这种需求:
「让业务同学用一小段表达式,就能从数据里算出结果」。
这篇文章重点分享两件事:
- 这套表达式解析器能处理哪些“看起来很刁钻”、但业务场景里非常常见的写法;
- 为什么我们选择基于 ASTJ 这类 AST 解析工具来落地,而不是用一堆正则和 if-else 把字符串硬抠出来。
一、我们这门表达式语言长什么样?
先快速感受一下语法风格,核心能力大致是:
- 模板插值:
${data.a}、${user.profile.name}、${list[0].value} - 算术:
+ - * / % - 比较:
> < >= <= == != - 逻辑:
&& || ! - 空值合并:
?? - 三元运算:
cond ? a : b - 字面量:数字、字符串、
true/false/null - 访问:点访问 + 下标访问
看起来和 JS 很像,但我们做了大量“业务向”的增强,来解决几个真实世界里经常踩坑的地方。
下面挑几类有代表性的“特殊场景”展开讲。
二、我们能解析的几个关键「特殊场景」
1. 模板 + 无引号中文文本的自然拼接
业务写表达式,最常见的就是这种文案拼接:
${data.a} + ${data.b} 人
${data.a} + ${data.b} - 人
${data.a} + ${data.b} / 人
你很难指望大家老老实实写成:
${data.a} + ${data.b} + "人"
${data.a} + ${data.b} + "-人"
${data.a} + ${data.b} + "/人"
我们做的事情是:
- 支持表达式和无引号的中文文本混写;
- 在词法阶段通过
tokenizeWithPlainText:- 判断
/ * -这些字符到底是运算符还是纯文本; - 把类似
15 人这种片段在 Token 层转成等价的15 + "人"。
- 判断
也就是说,这两种写法对引擎来说是等价的:
${data.a} + ${data.b} 人
${data.a} + ${data.b} + "人"
好处是:
- 业务写法更“自然口语化”,不需要对所有文案都加引号;
- 解析器内部仍然是一个完全合法的表达式 AST,后续可以照常做静态分析、优化和可视化。
技术细节上,我们在一次 Token 遍历结束后,再走一遍“补全隐式 +”的过程:
当发现前一个 Token 和后一个 Token 都是可拼接的(例如字符串或模板),就自动插入一个PLUSToken。
2. 区分「运算符」和「文本中的符号」
-、*、/ 这种字符有两个身份:
- 运算符:
a - b、-1、count * 2 - 文本符号:
15-人、15*人、15/人
如果只是靠简单的分隔符 split,很容易把这些文本中的符号误判成运算符,导致:
- 要么报语法错误;
- 要么 AST 结构完全错位。
我们的做法是在 tokenizeWithPlainText 里做一个向右看的判断:
if (input[pos] === '-' || input[pos] === '*' || input[pos] === '/') {
let look = pos + 1;
while (look < input.length && /\s/.test(input[look])) look++;
const ch = input[look];
const isOp =
ch &&
(
/[0-9]/.test(ch) || // -3, *2, /10
ch === '$' || // -${a}
ch === '(' || // -(1+2)
/[a-zA-Z_]/.test(ch) // -value
);
if (isOp) {
// 作为运算符处理
} else {
// 留给“纯文本读取”逻辑当普通字符
}
}
这套规则让我们可以同时支持:
-1/a - b/${a} * 2这样的正规表达式;15-人/15*人/15/人这样的业务文案。
3. 模板内容里的「中文路径」与嵌套表达式
一个有意思的场景是:模板内容本身也可能是一段表达式,而且可能包含中文:
${data.a + data.b}
${客户.姓名}
我们在 parsePrimary 中对 TEMPLATE 类型做了两层处理:
-
如果模板内容里没有中文:
-
用同一套
Lexer+Parser再跑一遍,把它当成子表达式; -
最终得到类似:
{ "type": "Template", "content": { "type": "BinaryOp", "operator": "+", ... }, "hasChinese": false }
-
-
如果模板内容里包含中文字符:
- 把整段内容当成一个“路径字符串”,例如
"客户.姓名"; - 在执行阶段走专门的
evaluateChinesePath逻辑,根据点号分段、允许中文 key 的规则,从上下文中取值。
- 把整段内容当成一个“路径字符串”,例如
例如,上下文里有:
{
客户: {
姓名: '张三'
}
}
表达式:
"客户:" + ${客户.姓名}
就能被正确解析并执行到 "客户:张三"。
这里我们做了两层保护:
- 模板内容解析失败时的兜底:如果尝试作为子表达式解析失败,会自动退回到“中文路径字符串”模式;
- 路径字符合法性校验:只允许由英文/数字/下划线/中文组成的片段,避免把带空格、斜杠的内容误当路径。
4. 可配置占位符语法:${}、{{}} 一键切换
不同系统里,模板占位符的写法不一样,比如:
- 经典 ES 模板字符串:
${var} - 模板引擎风格:
{{var}} - 某些平台可能是
<% var %>之类的语法
我们在表达式引擎外面套了一层 createExpressionEngine(options),支持配置:
const engine = createExpressionEngine({
placeholderStart: '{{',
placeholderEnd: '}}'
});
在真正 tokenize 之前,会先把自定义占位符规范化成内部标准的 ${} 形式:
function normalizeExpression(expr) {
const start = config.placeholderStart; // 比如 '{{'
const end = config.placeholderEnd; // 比如 '}}'
// 把 {{...}} 统一转换成 ${...}
}
这样一来:
- 对调用方来说,只需要说“我这里的占位符是
{{ ... }}”; - 对引擎内部来说,永远都是统一的 AST 规则,解析、执行逻辑完全复用。
5. 完整表达式能力:逻辑、空值合并、三元……
在以上这些“特殊需求”之外,这套表达式本身也具备完整的逻辑能力:
??空值合并:a ?? b只在a为null/undefined时才用b;&& / ||逻辑运算;- 嵌套三元:
cond ? a : b,支持在 AST 层严格校验? :的配对; - 成熟的运算符优先级处理:
* / %>+ -> 比较 > 逻辑 >??>?:。
这些都不是特别“花哨”的点,但一旦缺失,表达式的实际表达力就会大打折扣。
三、为什么要基于 ASTJ,而不是正则 + eval?
如果你只想实现一个最简单的 ${var} 替换,其实一两行正则就够了;
但一旦要支持上面这些复杂场景,基于 AST 思路的方案就非常有优势。
这里用“ASTJ”泛指一类 JS AST 解析工具/框架,我们的表达式引擎正是站在 AST 这一层上来设计的。
1. 语法清晰可控,而不是“一坨字符串”
有了 AST,你看到的就不再是这样的字符串:
${data.a} + ${data.b} 人
而是一棵结构化的数据:
{
"type": "BinaryOp",
"operator": "+",
"left": {
"type": "BinaryOp",
"operator": "+",
"left": { "type": "Template", "content": { "type": "Identifier", "name": "data.a" } },
"right": { "type": "Template", "content": { "type": "Identifier", "name": "data.b" } }
},
"right": { "type": "Literal", "value": "人" }
}
这带来的好处是:
- 容易做静态分析:比如找出表达式依赖了哪些字段;
- 容易做重写和优化:想把
a + 0优化掉,改写成a非常简单; - 容易做可视化:在 UI 里渲染成树形结构,让非前端同学也看得懂。
2. 安全边界清晰,避免直接 eval 的风险
如果用 eval 或 new Function 来执行表达式,难免会遇到这些问题:
- 表达式里可以写任意 JS 代码;
- 有机会访问到
window、document等全局对象; - 很难做白名单/黑名单控制。
而基于 ASTJ 的表达式引擎只执行我们定义过的节点类型:
Literal、BinaryOp、Template、Ternary、MemberAccess……- 没有
CallExpression就不会有任意函数调用; - 没有
NewExpression就不会随便 new; - 所有能力都在 AST 层有明确枚举。
这样一来,语言的边界完全可控,对安全性和治理是非常有利的。
3. 易于扩展,业务 DSL 可以逐步成长
当你发现有新的业务诉求时,比如:
- 想要一个
in运算符:status in ["draft", "done"]; - 想要内置一个
dateDiff()函数; - 想要特殊处理一些字段(比如“缺省值”)。
在 AST 方案下,你只需要:
- 在词法 + 语法层加上对应的 Token / 节点类型;
- 在 Evaluator 里实现该节点的执行逻辑。
整个过程是局部可控的,不会牵扯一堆“字符串切来切去”的逻辑,非常适合迭代演进一门 DSL。
4. 和调试、观测天然契合
AST 最大的附赠价值,就是可观测性:
- 我们可以把 AST 打印出来给前端,做成一个“表达式分析报告”;
- 可以标注每个节点对应的原始字符串位置,帮助业务精准定位问题;
- 可以记录执行过程中的中间值(对某些场景甚至能实现「逐步求值」的调试体验)。
这些能力都建立在“表达式先变成 AST 再执行”的前提上 ——
而 ASTJ 这样的解析工具,就是帮我们把这一步做扎实的地基。
四、总结
这套表达式解析器在设计上有两个核心目标:
- 业务友好:支持自然的中文文案拼接、中文路径、灵活的占位符写法,以及完整的逻辑表达能力;
- 工程友好:基于 AST 思路(借助 ASTJ 这类工具)来做解析和执行,带来可控的语法、安全的执行边界和良好的扩展性。
如果你也在做低代码平台、报表配置、规则引擎之类的系统,
不妨尝试用 AST 的视角重新审视你的表达式需求:
从一开始就把语言定义清楚,把解析和执行分开,对后续的演进和维护会有非常大的帮助。