表达式语言与运行时

拆解低代码平台的表达式语言与运行时:词法语法与 AST 节点设计、求值器与作用域链、依赖静态提取与响应式更新、类型检查与错误提示、沙箱求值的白名单边界、与 JS 沙箱方案的取舍,以及编译缓存与表达式版本演化,给出可运行的解析器与求值器实现,回答如何让表达式既好用又不失控。

引言

低代码平台承诺「不写代码也能搭应用」,但业务逻辑总得有个出口:字段什么时候显示、是否必填、默认值取什么、流程走哪个分支、接口参数怎么拼。这些都需要一种「小到可以放进配置里」的编程能力,表达式语言就是这个出口。

表达式的定位很微妙。它比配置强,比代码弱:要能表达条件与计算,又不能变成 eval 的入口;要能被平台静态分析(提取依赖、做类型检查、渲染依赖图),又不能图灵完备;要能在浏览器与服务端两边跑出同样的结果,还要有可读的错误提示。这些约束叠加起来,表达式运行时其实是一门小型语言的完整实现。

工程上真正的难点不在「怎么算出一个值」,而在「怎么保证它算得安全、算得快、算错了能说清楚」。一个用 new Function 包一层的实现,第一天就能跑起来,但在依赖追踪、类型提示、沙箱安全三处会陆续撞墙。

本文按「定位 → 语法解析 → AST 与求值 → 依赖提取 → 类型检查 → 错误提示 → 沙箱边界 → 与 JS 沙箱取舍 → 编译缓存 → 版本演化」展开,给出可运行的解析器、求值器与依赖提取实现。读完后你应当能判断:表达式语言该自己写还是借用现成方案,以及哪些能力必须在第一版就设计进去。

目录

  1. 表达式在低代码中的位置
  2. 语法与解析
  3. AST 节点设计与求值
  4. 依赖提取与响应式更新
  5. 类型系统与静态检查
  6. 错误提示与调试体验
  7. 沙箱求值与安全边界
  8. 与 JS 沙箱方案的取舍
  9. 编译缓存与求值性能
  10. 版本演化与兼容

1. 表达式在低代码中的位置

表达式是元数据里的一个「字段」,而不是一段代码。

表达式的消费点:
  表单:visibleWhen / requiredWhen / defaultValue / optionsFilter
  表格:列显隐、行样式、单元格格式化
  流程:gateway 条件、任务分配规则、超时策略
  集成:URL 拼接、请求体映射、结果断言
  权限:数据行级过滤条件

共同特征:
  短小(通常一行)
  声明式(无副作用)
  需要被平台静态分析(依赖、类型)

正因为「短小 + 声明式」,平台才能对表达式做静态分析;一旦允许任意代码,依赖追踪与类型检查就都做不了了。这条约束是后面所有设计的前提。

2. 语法与解析

解析分两步:词法分析把字符串切成 token,语法分析把 token 组织成 AST。

type TokenType = 'num' | 'str' | 'ident' | 'op' | 'punc';
interface Token { t: TokenType; v: string; pos: number }
// Pratt 解析:用绑定力(binding power)统一处理优先级与结合性
const BP: Record<string, number> = {
  '||': 1, '??': 1,
  '&&': 2,
  '==': 3, '!=': 3,
  '>': 4, '<': 4, '>=': 4, '<=': 4,
  '+': 5, '-': 5,
  '*': 6, '/': 6, '%': 6,
};

function parseBinary(lhs: Node, minBp: number): Node {
  for (;;) {
    const op = peekOp();
    const bp = op ? BP[op] : 0;
    if (!bp || bp < minBp) return lhs;
    next();
    const rhs = parseBinary(parseUnary(), bp + 1);  // 左结合
    lhs = { k: 'binary', op, l: lhs, r: rhs };
  }
}

几条经验:不要用正则拼解析,一旦要支持嵌套三元与括号就会失控;不要用 eval,它会绕过所有静态分析;优先支持 ?. 与 ??,它们能消掉大量空值判断,显著提升表达式的可读性。

3. AST 节点设计与求值

AST 节点要「够用且封闭」——节点种类固定,才谈得上静态分析。

type Node =
  | { k: 'lit'; v: unknown }
  | { k: 'ref'; path: string[] }              // form.amount
  | { k: 'unary'; op: string; x: Node }
  | { k: 'binary'; op: string; l: Node; r: Node }
  | { k: 'cond'; c: Node; t: Node; f: Node }  // a ? b : c
  | { k: 'member'; o: Node; key: string; optional: boolean }
  | { k: 'call'; name: string; args: Node[] }
  | { k: 'arr'; items: Node[] }
  | { k: 'obj'; entries: Array<[string, Node]> };
function evalNode(n: Node, scope: Scope): unknown {
  switch (n.k) {
    case 'lit': return n.v;
    case 'ref': return scope.get(n.path);
    case 'member': {
      const o = evalNode(n.o, scope);
      if (o == null) return n.optional ? undefined : fail('NULL_MEMBER', n);
      return readProp(o, n.key);          // 白名单读取,见第 7 节
    }
    case 'unary': return UNARY[n.op](evalNode(n.x, scope));
    case 'binary': return evalBinary(n, scope);
    case 'cond': return truthy(evalNode(n.c, scope))
      ? evalNode(n.t, scope) : evalNode(n.f, scope);
    case 'call': return callFn(n.name, n.args.map((a) => evalNode(a, scope)));
    case 'arr': return n.items.map((i) => evalNode(i, scope));
    case 'obj': return Object.fromEntries(n.entries.map(([k, v]) => [k, evalNode(v, scope)]));
  }
}

3.1 短路必须真的短路

&&、||、?: 三者的语义是「惰性求值」。若实现成先算左右两边再合并,a && a.b.c 就会在 a 为空时先求 a.b.c 而抛错——这是自研求值器最经典的 bug,写测试时要专门覆盖。

4. 依赖提取与响应式更新

因为 AST 是封闭的,依赖可以在不求值的情况下静态提取出来。

function collectDeps(n: Node, out = new Set<string>()): Set<string> {
  switch (n.k) {
    case 'ref': out.add(n.path.join('.')); break;
    case 'member': collectDeps(n.o, out); break;
    case 'unary': collectDeps(n.x, out); break;
    case 'binary': collectDeps(n.l, out); collectDeps(n.r, out); break;
    case 'cond':
      collectDeps(n.c, out); collectDeps(n.t, out); collectDeps(n.f, out); break;
    case 'call': n.args.forEach((a) => collectDeps(a, out)); break;
    case 'arr': n.items.forEach((i) => collectDeps(i, out)); break;
    case 'obj': n.entries.forEach(([, v]) => collectDeps(v, out)); break;
  }
  return out;
}

有了依赖集合,引擎就能建立「字段 → 受影响表达式」的反向索引,字段变化时只重算受影响的节点,而不是全量重跑。这是 Schema 驱动的表单引擎 里联动依赖图的底层支撑。

前提是表达式无副作用:如果允许表达式写全局变量、调用有状态函数,依赖提取就失去意义。这条约束要在语法层面封死,而不是靠文档约定。

5. 类型系统与静态检查

表达式值不值得做类型检查?值得——它是把「运行时炸」提前到「编辑时红波浪线」的唯一手段。

类型来源:
  1. 数据模型:form.amount 的类型来自字段定义
  2. 内置函数表:函数签名声明参数与返回类型
  3. 推断:字面量、二元运算的结果类型、三元合并
  4. 标注:允许显式 as 断言(谨慎使用)
interface FnSig { params: Type[]; ret: Type; variadic?: boolean }

const FUNCTIONS: Record<string, FnSig> = {
  len:   { params: ['string|array'], ret: 'number' },
  round: { params: ['number', 'number?'], ret: 'number' },
  upper: { params: ['string'], ret: 'string' },
  sum:   { params: ['array'], ret: 'number' },
  now:   { params: [], ret: 'datetime' },
};

类型检查的价值在于「拼错字段名」与「数字与字符串相加」这两类错误能在编辑期就被拦住,而它们恰好是低代码用户最常犯的错。这一套思路与 TypeScript 的类型系统 里「把约束前移到编辑期」的收益是同构的,只是表达式的类型域小得多。

6. 错误提示与调试体验

表达式出错时的提示质量,直接决定平台的可用性。

错误分类与提示策略:
  语法错误     → 位置 + 期望 token("第 12 列:期望 ) 但得到 }")
  未知标识符   → 拼写建议(编辑距离最近的字段名)
  类型不匹配   → 期望类型 vs 实际类型
  空值成员访问 → 提示使用 ?. 或先判空
  函数参数错误 → 哪个参数、期望几个
class ExprError extends Error {
  constructor(
    public code: string,
    message: string,
    public span: [number, number],
    public suggestion?: string,
  ) { super(message); }
}

6.1 空值语义要显式定义

null 参与比较与运算的行为必须写进规范:null == 0 是 true 还是 false?'' + 1 等于 '1' 还是报错?这些决定一旦含糊,用户就会写出「在我机器上对」的表达式。推荐做法:比较运算符对 null 返回 false 而非报错,字符串拼接显式用 concat() 函数。

7. 沙箱求值与安全边界

表达式语言本身就是沙箱——因为它压根不是 JS,没有原型链、没有 this、没有动态作用域。

const BLOCKED = new Set(['__proto__', 'constructor', 'prototype']);

function readProp(obj: unknown, key: string): unknown {
  if (BLOCKED.has(key)) throw new ExprError('UNSAFE_PROP', `禁止访问 ${key}`, [0, 0]);
  if (typeof obj !== 'object' || obj === null) return undefined;
  const v = (obj as Record<string, unknown>)[key];
  return typeof v === 'function' ? undefined : v;   // 不把函数引用交出去
}
安全规则清单:
  1. 函数白名单:只有 FUNCTIONS 表里的名字可调用
  2. 属性读取走 readProp:拦原型链、不返回函数引用
  3. 禁止动态下标:obj[expr] 只允许字面量键
  4. 步数/超时上限:防止在超大集合上做昂贵运算
  5. 不暴露宿主对象:作用域里只有纯数据

7.1 步数与超时

即使表达式语言不图灵完备,也要防住「大数据集上的 O(n²) 过滤」这类问题。

class Scope {
  private steps = 0;
  tick() {
    if (++this.steps > 10_000) throw new ExprError('TIMEOUT', '表达式执行超时', [0, 0]);
  }
}

8. 与 JS 沙箱方案的取舍

「自己写一门小语言」听起来重,但它是三条路里唯一能同时满足静态分析与安全约束的。

方案表达力静态分析安全适用
自研表达式 DSL中强强元数据里的条件与计算
JS 子集(JSONata / Expr)中高中中需要更复杂的数据变换
完整 JS 沙箱强无弱逻辑复杂、可接受隔离成本
决策建议:
  元数据层(显隐、校验、分支、参数映射)→ 自研 DSL
  中等复杂度数据变换 → 引入 JSONata 这类成熟库,但限制其函数集
  复杂业务逻辑 → 不要在表达式里硬写,退回自定义代码

关键判断标准:这段逻辑需不需要被平台静态理解。需要(依赖追踪、类型检查、权限过滤),就必须是 DSL;不需要(纯计算),可以交给更通用的方案。复杂逻辑的出口应走「退回写代码」而非「把表达式写长」,这条边界在 治理边界与常见反模式 里有更完整的讨论。

9. 编译缓存与求值性能

表达式会在每次渲染、每次校验时被求值,解析绝不能重复做。

const astCache = new Map<string, Node>();

function compileCached(src: string, langVersion: number): Node {
  const key = `${langVersion}:${src}`;
  let ast = astCache.get(key);
  if (!ast) {
    ast = parse(src);            // 解析是 O(n),求值是 O(节点数)
    astCache.set(key, ast);
    if (astCache.size > 5_000) astCache.clear();   // 简单 LRU 兜底
  }
  return ast;
}

9.1 求值开销的两个来源

一是作用域查找:form.amount 每次求值都要走一遍路径,扁平化作用域(把 form.amount 直接映射成一个键)能省掉这层;二是对象拷贝:求值器不应为了「安全」而深拷贝整个作用域,只在 readProp 处做浅层拦截即可。

10. 版本演化与兼容

表达式语法会随平台演进,但已保存的元数据不能因此变坏。

兼容策略:
  1. 语法只增不减:新增运算符/函数,不移除
  2. 函数表按平台版本注入,旧版本环境不加载新函数
  3. 表达式保存时记录 langVersion,求值器按版本分派
  4. 弃用先告警:先在使用处标黄,两个大版本后再移除
  5. 语义变更必须当成破坏性变更,走迁移工具

最容易踩的坑是「悄悄改了某个函数的行为」(比如 round 从四舍五入改成银行家舍入)。语义变更比语法变更隐蔽得多,必须有回归测试锁住。

权衡取舍

决策点选项 A选项 B建议
语言来源自研 DSL借用 JS 子集元数据层自研,变换层借用
求值方式eval/FunctionAST 解释AST,安全且可静态分析
依赖获取运行时收集编译期提取编译期,无需先求值
类型检查不做上下文推断做,编辑期拦截高频错误
错误粒度抛异常带位置与建议带位置,可用性差一个量级
缓存每次解析AST 缓存缓存,按语言版本分键

常见坑清单

  1. 用 new Function 实现表达式:无法静态分析依赖与类型,安全边界也守不住,必须用 AST 解释器。
  2. 短路运算符不惰性:a && a.b.c 在 a 为空时抛错,需写测试专门覆盖。
  3. 依赖靠运行时收集:首次渲染才能拿到依赖,应改为编译期静态提取。
  4. 允许动态下标 obj[expr]:沙箱形同虚设,只允许字面量键。
  5. readProp 返回函数引用:用户可借 constructor 逃逸,必须拦原型键且不返回函数。
  6. 无步数上限:大数组上的过滤会拖垮页面,需步数与超时限制。
  7. 解析结果不缓存:每次渲染都重新 parse,字段一多就卡。
  8. 表达式可写副作用:依赖追踪失效,联动出现「改了不生效」。
  9. 悄悄改函数语义:存量应用行为突变,语义变更必须走破坏性流程。
  10. 错误只抛 message:没有位置与建议,用户无从下手。

小结

表达式语言与运行时的骨架是「解析 → AST → 求值 → 依赖提取 → 类型检查 → 错误提示 → 沙箱 → 缓存 → 版本」。贯穿全文的一条主线是可静态分析:正因为表达式是封闭的小语言,平台才能提取依赖、推断类型、渲染依赖图;一旦退化成任意 JS,这些能力全部失去。另一条主线是安全边界:不用 JS 本身就是最大的沙箱,剩下的只是白名单与拦截。

选型上最实用的判断是「这段逻辑需不需要被平台理解」。需要,就写进 DSL;不需要,就退回代码,不要在表达式里模拟编程语言。

表达式在流程分支与任务分配里被大量消费,其语义约束需要与流程引擎对齐;而当表达式确实表达不了需求时,出口在 代码生成与领域特定语言 所描述的「把逻辑落成可维护的代码」这条路径上。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「低代码」更多文章

  1. 自定义代码与逃生舱
  2. 低代码应用测试与质量
  3. 连接器与 API 编排