数据截至 (上游 commit 5a6a215cab71)
OpenUI Lang — 运行时求值
本章讲什么: [02 章]的解析器产出的 ElementNode 树里,props 仍可能含有「保留的 AST 节点」——
$days、sales.rows.value、a ? b : c。这一章讲运行时怎么把它们算成真实值,以及让界面真正「活起来」的三件套:取数(queryManager)、响应式状态(store)、按钮编排(Action)。最后讲增量编辑(edit-mode)。
核心仍在 lang-core(框架无关);React 适配在 react-lang/src/Renderer.tsx 把它们接进 React。
1. 全景:谁喂谁
ParseResult(第 02 章)
├── root: ElementNode 树(props 里残留 AST)
├── stateDeclarations: { $days: "7", ... }
├── queryStatements: [ {tool, argsAST, depsAST, ...} ]
└── mutationStatements: [ ... ]
│
▼
┌───────────────┐ 读 $变量 ┌ ──────────┐
│ evaluator │◄─────────────│ store │ 响应式状态
│ evaluate(ast)│ resolveRef └──────────┘
│ │◄─────────────┐
└──────┬────────┘ Query 结果 │
│ 求值后的 props 树 │
▼ ┌─────┴────────┐
React 渲染 │ queryManager │ 取数/缓存/刷新
└──────────────┘
│ callTool
▼
toolProvider(你的后端/MCP)
怎么读: evaluator 是中枢,算 props 时需要值就找两个数据源——$变量找 store,Query/Mutation 结果找 queryManager。算完的纯值树交给 React 画。
2. evaluator:把保留的 AST 算成值
它要解决的小问题: kpi = TextContent("合计:" + total) 里的 "合计:" + total 在渲染时得变成真正的字符串。
evaluate(runtime/evaluator.ts:42)是一个直白的 AST 求值器,按 k 分发。几个有「DSL 个性」的求值规则:
| 表达式 | 求值规则 | 位置 |
|---|---|---|
$days | 优先 extraScope,否则查 store | evaluator.ts:61-62 |
arr.field(数组上) | 拔字段:对每个元素取 .field 成新数组 | evaluator.ts:208-217 |
a + b | 任一边是字符串就字符串拼接(null 当 ""),否则数字加 | evaluator.ts:157-162 |
a / 0 | 返回 0(不抛 Infinity/NaN,DSL 取舍) | evaluator.ts:168-169 |
&& / || | 短路求值 | evaluator.ts:144-151 |
那个「数组上 .field 自动拔字段」很重要:它让 sales.rows.value 直接得到一列数值喂给图表,模型不用写循环。这是语言对「给 LLM 用」的人体工学优化。
@Each:唯一的「惰性」内置
大多数内置函数(Sum/Filter/Sort…)是「先算参数,再调 .fn」(evaluator.ts:82-86)。但 @Each(array, varName, template) 不行——它得对每个元素重算一遍 template,所以参数不能提前求值,归为 LAZY_BUILTINS(builtins.ts:185)。
evaluateLazyBuiltin(evaluator.ts:447)对每个数组项:把 template AST 里的循环变量 Ref(varName) 预先替换成该项的字面值(substituteRef),再求值。为什么要预替换而不只是临时塞进作用域?因为像 Action([@Set($id, t.id)]) 这种延迟到点击时才执行的表达式,如果只靠临时作用域,等点击时循环早结束、t 早没了——预替换把 t.id 在生成时就固化成具体值(evaluator.ts:440-446 注释)。
3. queryManager:取数引擎
它要解决的小问题: Query() 要做到「默认值立刻显示、真数据到了替换、依赖变了重取、可定时刷新、出错有结构化错误」,还不能因为竞态显示过期数据。
createQueryManager(runtime/queryManager.ts:137)是个不依赖框架的小型数据层。核心是缓存键:
cacheKey = toolName + "::" + stableStringify(args) + "::" + stableStringify(deps)
buildCacheKey(queryManager.ts:130)用稳定序列化(键排序、特殊值归一)拼键。args 或依赖($变量值)一变,cacheKey 就变 → 视为新查询 → 自动重取(evaluateQueries,queryManager.ts:319)。这就是「下拉框一改、Query 自动刷新」的底层机制——依赖在解析期由 collectQueryDeps 预抽([01 章]),运行时求值后并进缓存键。
几个让人「用着顺手」的细节:
- 乐观显示旧值。 重取途中先显示上一个 cacheKey 的数据(
prevCacheKey兜底),不闪空(queryManager.ts:163-175)。 - 竞态防护。 发起 fetch 时记下
fetchKey,回来时若 query 的当前 cacheKey 已变(说明依赖又变了),丢弃这次结果(queryManager.ts:236-240)。 - 定时刷新。 第四个参数是秒数,装个
setInterval周期重取(queryManager.ts:374-390)。 - 结构化错误。 工具找不到/MCP 错/一般错分别映射成带
code与hint的OpenUIError(queryManager.ts:256-289),喂回模型纠错。
Mutation 走另一条路:不自动触发,只在按钮点击经 fireMutation 执行(queryManager.ts:471),带 idle/loading/success/error 状态机,且拒绝并发重复提交(queryManager.ts:481);成功后可顺带 invalidate 一批 Query 实现「写完即刷新」。