数据截至 (上游 commit 5a6a215cab71)
OpenUI Lang — 流式解析引擎
本章讲什么: OpenUI Lang 最与众不同之处是为流式而生——模型每吐一个 token,解析器都要能从「半句话」里挤 出一棵尽量完整的 UI 树,而且不能抛错、不能让界面闪。本章讲它怎么做到:autoClose 补全、增量解析的 watermark、materialize 降级 + 校验、以及 hoisting 带来的渐进式 reveal。
建议先读 01 章的 AST 与三步流程。
1. 为什么流式这么难
模型是逐 token 吐字的。某一刻你手里可能只有:
root = Card([header, cha
header = TextContent("本周
这既不是合法语法(字符串没闭合、括号没配齐),cha 也还没定义。普通解析器到这里就抛错了,而 UI 每来一个 chunk 重解析一次,意味着大部分时间都在解析「半句」。OpenUI 的解法是:先把半句补成合法,再用「可达性 + partial 标记」优雅地处理还没出现的部分。
2. autoClose:把半句补成合法
它要解决的小问题: 让任何残缺输入都能被解析,不抛错。
autoClose(parser/statements.ts:18)扫一遍文本,用一个栈记住所有未闭合的 ([{ 和是否在字符串里;扫完后按相反顺序补上闭合符,并补上未闭合的引号。
输入: Card([header, TextContent("本周
栈: ( [ ( 字符串未闭合
补全: Card([header, TextContent("本周")])
└──────── 补上 " ) ] ──────┘
它同时返回 wasIncomplete 标记——这个布尔后面会变成每个 ElementNode 的 partial: true,告诉渲染层「这棵子树还在长,别当最终态」(parser/types.ts:49-53)。
顺带一提,解析前还有 preprocess:剥 markdown 围栏 + 去注释(parser/parser.ts:402)。模型常把代码包在 ```openui-lang 里,或写 //、# 注释——stripFences(parser.ts:274)甚至是「字符串感知」的,能跳过字符串内部出现的 ```,避免误剥。
3. 增量解析:watermark 只算新增
它要解决的小问题: 每来一个 chunk 都从头全量重解析,长输出会越来越慢。
createStreamParser(parser/parser.ts:445)给的是 push(chunk) 接口,核心优化是一条 watermark(completedEnd):它把缓冲区分成「已完成」和「待定」两段。
buffer: ┌─────已完成语句─────┬──待定(最后一条还在流)──┐
0 completedEnd buf.length
│ │ │
│ 已解析进 │ 每次只重解析这段 │
│ completedStmtMap │ (autoClose 后) │
scanNewCompleted(parser.ts:466)从 watermark 往后扫,遇到「depth-0 且不在三元中途的换行」就认定一条语句完成,解析进 completedStmtMap 并推进 watermark(parser.ts:497-514)。只有最后那条还没完成的语句才需要 autoClose + 重解析。
一个关键的健壮性保证(parser.ts:573-579):合并时待定语句只能新增 ID,不能覆盖已完成的。这防止流式编辑途中,一段刚冒头的 root = Card(还没流完)把之前已完整的 root 给冲掉。
set(fullText) 是另一个入口:它 diff 内部缓冲,只 push 增量;若发现新文本不是旧文本的前缀(说明被整体替换了),自动 reset(parser.ts:608-615)。
4. materialize:AST 降级成 ElementNode + 校验
它要解决的小问题: AST 还是「语法树」,渲染层想要的是「组件树 + 已命名的 props」。同时要在这一步做校验、解析引用、应用默认值。
materializeValue(parser/materialize.ts:191)单遍递归地把 AST 降级,一次干完好几件事:
| 输入 AST | 产出 |
|---|---|
字面量 Str/Num/Bool/Null | 对应 JS 死值 |
Arr/Obj | 普通数组 / 对象(顺手丢掉解析不出的占位项) |
目录里的 Comp | ElementNode:位置参数映射成命名 props |
内置函数 Comp(Sum/Each…) | 保留为 AST,留给运行时 |
Ref | 从符号表内联(查环、查未定义) |
4.1 位置参数 → 命名 props
这是 materialize 最核心的一步。组件库经 compileSchema(parser.ts:631)变成 ParamMap:每个组件名 → 一串有序参数(名字 + 是否必填 + 默认值)。materialize 拿位置参数按顺序怼进参数名(materialize.ts:248-269):
Table(["col"], rows) ParamMap: Table → [columns, rows]
│ │
▼ ▼
props: { columns: ["col"], rows: <rows的值> }
4.2 顺手校验:四种错误
校验和降级是同一遍完成的,产出结构化错误(parser/types.ts:76-86,ValidationErrorCode):
| code |
|---|