一、前言
在低代码场景里,很多业务需求表面上是在"配页面",本质上其实是在"配运行时逻辑"。
例如,一个低代码页面上的表格列需要显示订单金额 * 折扣率,业务人员在可视化编辑器里配置了表达式:
json
{ "componentName": "Text", "props": { "content": "{{state.amount * state.discount}}" } }
但渲染时这段内容本身只是字符串,系统并不会把它计算成一个数值。
那怎么从 "{{state.amount * state.discount}}" 变成真正的结果呢?Expression Engine 就是解决这个问题的。
Expression Engine 的职责,就是在运行时把这类表达式解析并求值,让 Schema 里的"动态绑定"在运行时真正跑起来。
二、Expression Engine:低代码中的角色
Expression Engine 在低代码系统里,本质上是 Runtime 内部的求值引擎 。
它位于 Schema 和 Renderer 之间,负责把 Schema 里的表达式解析成最终值,再交给 Renderer 去渲染。
它在 Runtime 中的位置可以概括为:
Schema
↓
Runtime(Expression Engine 在此求值)
↓
resolvedProps
↓
Renderer 渲染
三、方案设计
实现表达式求值,主要有两条思路:new Function VS 解释器
对比两种方案:new Function VS 解释器
| 维度 | new Function() |
解释器 |
|---|---|---|
| 实现成本 | 极低,一行代码 | 完整的解释器链路(Tokenizer + Parser + Evaluator) |
| 安全性 | 暴露全局作用域,可执行任意代码 | 白名单 AST 校验,只允许显式声明的节点类型 |
| 能力边界 | 图灵完备,无法限制用户能做什么 | 引擎实现了什么就只能执行什么,其余一律拒绝 |
| 可扩展性 | 能力已经完整,无需扩展 | 按需渐进增强:加 AST 节点类型 + 解析规则即可 |
| 错误信息 | V8 原生报错,对非开发者不友好 | 自定义错误,可精确指向变量名或位置 |
表格已经说明了两种方案的差异。对低代码来说,比较关键的问题是"配置数据会不会变成不受控的可执行代码"。
如果表达式来自可视化编辑器,直接使用 new Function,就意味着像 window.location.href = 'xxx' 这类内容也可能被当成代码执行;而低代码运行时真正需要的,通常只是 state.count + 1、item.price * state.discount 这样的受控求值。所以这一版我最终选择解释器方案,把能力边界收在引擎内部。
三、核心原理:从字符串到执行结果
整个引擎分为三步:Tokenize → Parse → Evaluate。流程如下:
css
输入: "state.count * data.price + 1"
|
↓ Tokenizer(词法分析)
Tokens: [state, ., count, *, data, ., price, +, 1]
|
↓ Parser(语法分析,递归下降)
AST: BinaryExpression(+)
├── BinaryExpression(*)
│ ├── MemberExpression(state, count)
│ └── MemberExpression(data, price)
└── Literal(1)
↓ Evaluator(AST 求值)
ctx.state.count → 3, ctx.data.price → 99
3 * 99 + 1 → 298
3.1 Tokenizer:字符串 → Token 序列
Tokenizer 的职责是:把表达式字符串拆分成一串有类型的 Token ,供后续 Parser 使用。
例如:state.count + 1
会被拆成这样的 Token 序列:identifier(state) -> punctuation(.) -> identifier(count) -> operator(+) -> number(1) -> eof
它的处理过程:从左到右扫描字符串,依次识别空白、运算符、标点、字符串、数字和标识符,并把识别结果转换成对应类型的 token,最后补一个 eof 作为结束标记。
目前 Token 类型只保留最小 6 类:
| Token 类型 | 含义 | 示例 |
|---|---|---|
number |
数字字面量 | 42 |
string |
字符串字面量 | "hello" |
identifier |
标识符 | state、item、true |
operator |
运算符 | +、===、&& |
punctuation |
标点符号 | (、)、. |
eof |
结束标记 | (空) |
这一步有一个关键原则:多字符运算符优先匹配 。也就是先识别 ===、&&、||,再识别 + - * / > < 这类单字符运算符,避免把一个多字符错误拆开。
3.2 Parser:Token 序列 → AST
Parser 的职责是:把 Token 序列组织成 AST(抽象语法树) ,让表达式变成"有结构的语义树"。
这里的核心有两个原则:
- 按运算符优先级组织结构,保证表达式结合顺序正确
- 把表达式整理成有限几种 AST 节点,作为后续求值与白名单校验的基础
它做的事情可以直接理解为:按优先级规则读取 token,再把字面量、标识符、成员访问和运算表达式组织成一棵 AST,最终把"token 序列"变成"有结构的语义树"。
例如:state.count + data.price * 2
最终会被组织成的AST结构:
ts
BinaryExpression(+)
left: MemberExpression(state.count)
right: BinaryExpression(*)
left: MemberExpression(data.price)
right: Literal(2)
换成更直观一点的 JSON 形式,大致会是:
json
{
"type": "BinaryExpression",
"operator": "+",
"left": { "type": "MemberExpression", "object": "state", "property": "count" },
"right": {
"type": "BinaryExpression",
"operator": "*",
"left": { "type": "MemberExpression", "object": "data", "property": "price" },
"right": { "type": "Literal", "value": 2 }
}
}
我目前的设计,最终生成的 AST 节点类型只有 5 种------这也是白名单的基础:
css
type ExpressionNode =
| LiteralNode // 字面量:42, "hello", true, null
| IdentifierNode // 标识符:state, item, index
| MemberExpressionNode // 成员访问:state.count, item.name
| BinaryExpressionNode // 二元运算:a + b, a > b, a === b
| LogicalExpressionNode // 逻辑运算:a && b, a || b
3.3 Evaluator:AST → 值
Evaluator 的职责是:沿着 AST 递归求值,最终得到表达式结果。
这里的核心思路是:
- 遇到字面量,直接返回值
- 遇到标识符,从
scopeCtx中取值 - 遇到成员访问,先算左侧对象,再取属性值
- 遇到二元表达式或逻辑表达式,递归计算左右子树,再组合结果
具体实现细节:
typescript
function evaluateAst(node: ExpressionNode, scopeCtx: IRuntimeContext): any {
switch (node.type) {
case 'Literal':
return node.value
case 'Identifier':
return resolveIdentifier(node.name, scopeCtx)
case 'MemberExpression':
先递归求值 node.object
再读取 node.property
case 'BinaryExpression':
先计算 left / right
再根据 operator 组合结果
case 'LogicalExpression':
按逻辑运算规则处理,必要时短路
}
}
例如:item.price * state.count
求值时会先解析 item.price 和 state.count,再执行 * 运算。
3.4 AST 校验:白名单安全边界
AST 校验,它的职责是:在真正求值前,确认 AST 只包含允许的节点类型。
如果出现白名单之外的节点,就直接拒绝执行。
这就是 AST 校验的白名单安全边界:只允许受控的表达式结构进入求值阶段,超出边界的能力一律拒绝。
3.5 整体调用链
将整个解析器流程串起来:
typescript
export function evaluateExpression(expr: string, scopeCtx: IRuntimeContext): any {
const tokens = new Tokenizer(expr).tokenize() // Step 1: 词法分析
const ast = new Parser(tokens).parse() // Step 2: 语法分析
validateAst(ast) // Step 3: 白名单校验
return evaluateAst(ast, scopeCtx) // Step 4: 求值
}
附上一个最小可运行示例,直接感受一下这套解析器的效果:
typescript
import { RuntimeContext } from '@/context'
import { evaluateExpression } from '@/runtime/expression'
const scopeCtx = new RuntimeContext({
id: 'demo',
state: {
count: 3,
},
data: {
price: 99,
},
})
const result = evaluateExpression('state.count + data.price * 2', scopeCtx)
console.log(result) // 201
这个例子里,state.count 的值是 3,data.price 的值是 99,所以表达式最终会被计算为 3 + 99 * 2 = 201。
四、Runtime 如何使用 Expression Engine
前面讲的是 Expression Engine 内部如何完成"字符串解析 -> AST 构建 -> 表达式求值"。
在低代码 Runtime 里,它核心作用是被 Runtime 在 props 解析阶段调用。
也就是说:
- Runtime 负责遍历 schema 和组织运行时上下文
- Expression Engine 负责解析并求值表达式
- 两者通过一条清晰的调用链衔接起来
Runtime 内部具体调用关系如下:
rust
resolveProps → 面向 schema.props,对每个字段做处理
-> resolveTemplate → 面向字符串模板,识别 {{...}}
-> evaluateExpression → 面向表达式正文,完成真正求值
Runtime 负责组织运行时流程,然后把"表达式求值"委托给 Expression Engine,从而保持职责清晰。 详见:低代码 Runtime 策略注入:让表达式引擎真正可扩展
五、为什么先做最小 AST 能力集
这一版我没有追求"大而全"的表达式引擎,而是先把最小可用链路跑通,优先完成 Tokenizer -> Parser -> Evaluator -> Runtime 接入 这套基础设施。
之所以这样取舍,是因为当前需求只需要覆盖 Runtime 里的核心表达式场景。
如果一开始就支持过多语法和节点类型,复杂度会明显上升;需求也容易蔓延,能力容易冗余,安全边界还会被拉宽。
所以这一阶段我的策略很明确:只做现在真正用得到、且能支撑 Runtime 主链路的表达式能力,其余能力后续再按需求逐步演进。
六、解析器的常见使用场景
Tokenizer -> Parser -> Evaluator 这套引擎模式其实很常见,比如:
- 配置化表单:例如
visible: "{{form.type === 'advanced'}}",可以根据表单数据动态控制字段显隐。 - 规则引擎:例如审批系统里配置
{{amount > 10000 && level === 'M2'}},运行时根据此判断当前数据这条是否需要审批。 - 前端框架或模板编译:以 Vue 为例,模板里的表达式、指令和依赖更新,其背后同样离不开"解析 -> 组织结构 -> 执行/更新"这类思路。
- 模板引擎:例如邮件模板、消息模板里的
{{user.name}}、{{order.total}},本质上也是把模板里的表达式解析出来,再替换成最终结果。
七、当前实现结果
这一版我实现的是一套面向低代码 Runtime 的最小解析器。
它已经完成了 Tokenizer -> Parser -> Evaluator -> Runtime 这条核心链路,并以 5 种 AST 节点为边界,先支持了当前最需要的表达式能力:路径取值、二元运算、逻辑判断,以及 condition / loop 所需的求值能力。
这意味着,Schema 里的 {{state.count + 1}}、{{item.name}}、{{state.visible && item.enabled}} 这类表达式,已经可以被稳定解析并接入 Runtime 执行。
到这里,这套解析器已经完成了当前阶段的最小闭环,后续可以按需逐步扩展。
八、未来演进方向
后续的演进方向,我主要看 3 个方面:
- 性能方面 :同一个表达式在每次 rerender 时都会重新经过
Tokenizer + Parser,后续可以按表达式字符串做 AST 缓存,把重复解析的成本降下来。 - 和低代码调度系统结合 :后续可以在 Evaluate 过程中做依赖收集,记录访问了哪些
state.xxx / data.xxx,这样就能把 Expression Engine 和 Runtime 的调度系统结合起来,为精准更新、局部更新 UI 提供基础。 - 解析器能力扩展:在当前最小能力集稳定之后,再按真实需求逐步扩展语法能力,比如支持三元表达式、函数调用等,让表达式覆盖更多的 Runtime 场景。