引言
低代码平台承诺「不写代码也能搭应用」,但业务逻辑总得有个出口:字段什么时候显示、是否必填、默认值取什么、流程走哪个分支、接口参数怎么拼。这些都需要一种「小到可以放进配置里」的编程能力,表达式语言就是这个出口。
表达式的定位很微妙。它比配置强,比代码弱:要能表达条件与计算,又不能变成 eval 的入口;要能被平台静态分析(提取依赖、做类型检查、渲染依赖图),又不能图灵完备;要能在浏览器与服务端两边跑出同样的结果,还要有可读的错误提示。这些约束叠加起来,表达式运行时其实是一门小型语言的完整实现。
工程上真正的难点不在「怎么算出一个值」,而在「怎么保证它算得安全、算得快、算错了能说清楚」。一个用 new Function 包一层的实现,第一天就能跑起来,但在依赖追踪、类型提示、沙箱安全三处会陆续撞墙。
本文按「定位 → 语法解析 → AST 与求值 → 依赖提取 → 类型检查 → 错误提示 → 沙箱边界 → 与 JS 沙箱取舍 → 编译缓存 → 版本演化」展开,给出可运行的解析器、求值器与依赖提取实现。读完后你应当能判断:表达式语言该自己写还是借用现成方案,以及哪些能力必须在第一版就设计进去。
目录
- 表达式在低代码中的位置
- 语法与解析
- AST 节点设计与求值
- 依赖提取与响应式更新
- 类型系统与静态检查
- 错误提示与调试体验
- 沙箱求值与安全边界
- 与 JS 沙箱方案的取舍
- 编译缓存与求值性能
- 版本演化与兼容
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/Function | AST 解释 | AST,安全且可静态分析 |
| 依赖获取 | 运行时收集 | 编译期提取 | 编译期,无需先求值 |
| 类型检查 | 不做 | 上下文推断 | 做,编辑期拦截高频错误 |
| 错误粒度 | 抛异常 | 带位置与建议 | 带位置,可用性差一个量级 |
| 缓存 | 每次解析 | AST 缓存 | 缓存,按语言版本分键 |
常见坑清单
- 用
new Function实现表达式:无法静态分析依赖与类型,安全边界也守不住,必须用 AST 解释器。 - 短路运算符不惰性:
a && a.b.c在 a 为空时抛错,需写测试专门覆盖。 - 依赖靠运行时收集:首次渲染才能拿到依赖,应改为编译期静态提取。
- 允许动态下标
obj[expr]:沙箱形同虚设,只允许字面量键。 readProp返回函数引用:用户可借constructor逃逸,必须拦原型键且不返回函数。- 无步数上限:大数组上的过滤会拖垮页面,需步数与超时限制。
- 解析结果不缓存:每次渲染都重新 parse,字段一多就卡。
- 表达式可写副作用:依赖追踪失效,联动出现「改了不生效」。
- 悄悄改函数语义:存量应用行为突变,语义变更必须走破坏性流程。
- 错误只抛 message:没有位置与建议,用户无从下手。
小结
表达式语言与运行时的骨架是「解析 → AST → 求值 → 依赖提取 → 类型检查 → 错误提示 → 沙箱 → 缓存 → 版本」。贯穿全文的一条主线是可静态分析:正因为表达式是封闭的小语言,平台才能提取依赖、推断类型、渲染依赖图;一旦退化成任意 JS,这些能力全部失去。另一条主线是安全边界:不用 JS 本身就是最大的沙箱,剩下的只是白名单与拦截。
选型上最实用的判断是「这段逻辑需不需要被平台理解」。需要,就写进 DSL;不需要,就退回代码,不要在表达式里模拟编程语言。
表达式在流程分支与任务分配里被大量消费,其语义约束需要与流程引擎对齐;而当表达式确实表达不了需求时,出口在 代码生成与领域特定语言 所描述的「把逻辑落成可维护的代码」这条路径上。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。