低代码 Expression Engine:一个微型表达式解释器的设计

一、前言

在低代码场景里,很多业务需求表面上是在"配页面",本质上其实是在"配运行时逻辑"。

例如,一个低代码页面上的表格列需要显示订单金额 * 折扣率,业务人员在可视化编辑器里配置了表达式:

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 + 1item.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 标识符 stateitemtrue
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.pricestate.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 的值是 3data.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 场景。
相关推荐
冲浪中台5 小时前
魔力象限 + 信创适配:低代码管理平台双标准选型指南
低代码
共赢净土5 小时前
驾驭Claude代码:真正能触发的技能
低代码
小脑斧1236 小时前
自媒体内容工业化:基于AI Skills低代码实现穿搭账号矩阵自动化量产
人工智能·低代码·媒体·skills·openclaw·hermes·marvis
低代码布道师2 天前
健身房私教管理系统(四)教练账号审核
低代码
Jeking2172 天前
低代码平台表单设计器 unione form editor 组件 —— 子表单组件
低代码·动态表单·表单设计·表单引擎·unione cloud
Jeking2173 天前
低代码平台表单设计器 unione-form-editor 组件 —— 子数据组件
低代码·动态表单·表单设计·表单引擎·unione cloud
Jeking2173 天前
实战案例|引用组件在【销售订单表单】中的真实应用
低代码·动态表单·表单设计·表单引擎·unione cloud
2501_927283583 天前
堆垛机立体库:告别人工翻找与货物堆压
大数据·人工智能·低代码·自动化·区块链
多租户观察室3 天前
信通院标准体系2.0深度解读:低代码管理平台进入“精品竞争”时代
前端·低代码·程序员