跳到主要内容

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:44-48)。

顺带一提,解析前还有 preprocess:剥 markdown 围栏 + 去注释(parser/parser.ts:395)。模型常把代码包在 ```openui-lang 里,或写 //# 注释——stripFences(parser.ts:267)甚至是「字符串感知」的,能跳过字符串内部出现的 ```,避免误剥。


3. 增量解析:watermark 只算新增

它要解决的小问题: 每来一个 chunk 都从头全量重解析,长输出会越来越慢。

createStreamParser(parser/parser.ts:438)给的是 push(chunk) 接口,核心优化是一条 watermark(completedEnd):它把缓冲区分成「已完成」和「待定」两段。

buffer: ┌─────已完成语句─────┬──待定(最后一条还在流)──┐
0 completedEnd buf.length
│ │ │
│ 已解析进 │ 每次只重解析这段 │
│ completedStmtMap │ (autoClose 后) │

scanNewCompleted(parser.ts:459)从 watermark 往后扫,遇到「depth-0 且不在三元中途的换行」就认定一条语句完成,解析进 completedStmtMap 并推进 watermark(parser.ts:490-507)。只有最后那条还没完成的语句才需要 autoClose + 重解析。

一个关键的健壮性保证(parser.ts:566-572):合并时待定语句只能新增 ID,不能覆盖已完成的。这防止流式编辑途中,一段刚冒头的 root = Card(还没流完)把之前已完整的 root 给冲掉。

set(fullText) 是另一个入口:它 diff 内部缓冲,只 push 增量;若发现新文本不是旧文本的前缀(说明被整体替换了),自动 reset(parser.ts:601-608)。


4. materialize:AST 降级成 ElementNode + 校验

它要解决的小问题: AST 还是「语法树」,渲染层想要的是「组件树 + 已命名的 props」。同时要在这一步做校验、解析引用、应用默认值。

materializeValue(parser/materialize.ts:201)单遍递归地把 AST 降级,一次干完好几件事:

输入 AST产出
字面量 Str/Num/Bool/Null对应 JS 死值
Arr/Obj普通数组 / 对象(顺手丢掉解析不出的占位项)
目录里的 CompElementNode:位置参数映射成命名 props
内置函数 Comp(Sum/Each…)保留为 AST,留给运行时
Ref从符号表内联(查环、查未定义)

4.1 位置参数 → 命名 props

这是 materialize 最核心的一步。组件库经 compileSchema(parser.ts:624)变成 ParamMap:每个组件名 → 一串有序参数(名字 + 是否必填 + 默认值)。materialize 拿位置参数按顺序怼进参数名(materialize.ts:264-268):

Table(["col"], rows) ParamMap: Table → [columns, rows]
│ │
▼ ▼
props: { columns: ["col"], rows: <rows的值> }

4.2 顺手校验:四种错误

校验和降级是同一遍完成的,产出结构化错误(parser/types.ts:71-76,ValidationErrorCode):

code触发处理
unknown-component组件不在库也不在内置丢弃该组件(返回 null)
missing-required / null-required必填参数缺失/为 null先试默认值,仍缺则丢弃
excess-args位置参数比定义多多的静默丢弃,但记一条错误
inline-reservedQuery() 当内联值用而非顶层语句报错

「丢弃 + 记错」而不是「抛异常」是贯穿全局的策略:残缺输入照样产出能渲染的树,错误另走 OpenUIError 通道喂回模型纠错。

4.3 静态 vs 动态:hasDynamicProps

降级时给每个 ElementNode 标 hasDynamicProps(materialize.ts:322):如果所有 props 都是死值,标 false——第 04 章的求值器看到 false 会整棵跳过,不浪费。判断靠 containsDynamicValue 递归找有没有残留的 AST 节点(materialize.ts:14-23)。


5. hoisting:可前引用 → 渐进式 reveal

它要解决的小问题: 怎么让界面「结构先出、数据后填」?

OpenUI Lang 支持 hoisting:一个名字可以先用后定义root = Card([header, chart]) 可以写在 headerchart 之前——解析器在拿到全部语句后才统一解析引用(parser.ts:buildResult 从入口 entryId 递归 materialize,引用现查符号表)。

这正好和流式咬合:

时刻 t1 收到: root = Card([header, chart])
→ header/chart 还没定义 → 记进 meta.unresolved → 渲染成空位
→ 但 Card 外壳立刻出现 ✅

时刻 t2 收到: header = TextContent("本周")
→ header 解析出来,空位补上 ✅

时刻 t3 收到: chart = BarChart(...)
→ chart 补上,UI 完整 ✅

所以提示词专门教模型「先写 root」(prompt.ts:streamingRules),让外壳第一时间出现。未解析的引用记在 meta.unresolved,不算错误——它们是预期中的「还没流到」(parser.ts:198materialize.ts:43-50)。

可达性与 orphaned

hoisting 的反面:定义了却没人引用的语句是 orphaned(孤儿)。buildResult 从入口开始做可达性遍历,把没被走到的值语句记进 meta.orphaned(parser.ts:200-228)——但 $state/Query/Mutation 声明豁免(它们运行时才被用,不靠 Ref 连接)。这就是 [01 章]那条「未引用的会被丢弃」规则的实现。

入口本身怎么定?pickEntryId(parser.ts:164):优先名为 root 的语句,否则库指定的 root 组件名,否则第一个组件语句(parser.ts:170-179)。


6. 巧妙之处

  • 解析永不抛错。 autoClose + 「丢弃而非抛异常」让流式途中每一帧都能渲染(statements.ts:autoClosematerialize.ts 全程 errors.push 不 throw)。
  • watermark 把全量重解析降成增量。 已完成语句缓存进 map,只重算最后一条待定语句(parser.ts:scanNewCompleted)。
  • 待定语句不能覆盖已完成的。 一条规则挡住了流式编辑时的状态污染(parser.ts:566-572)。
  • 降级与校验合一。 materializeValue 一遍同时做引用解析、参数映射、校验、默认值、静态/动态标记——少走几遍树(materialize.ts:materializeValue)。

7. 边界与局限

  • 无类型检查到值层。 参数是否是「对的类型」并不在 materialize 校验(只查必填/未知/数量);类型靠提示词里的签名「软约束」模型。
  • 环引用被切断:a 引用 bb 引用 a 会在 resolveRefvisited 检测处断开并记 unresolved(materialize.ts:43-46),不会死循环但结果是 null。
  • 单引号与双引号字符串的不对称只在词法层。 autoClose 对两种引号都会按匹配引号补全(statements.ts:20-36inStr 跟踪并 close with matching quote),所以流式补全本身是对称的;真正不同的是 lexer 的逐 token 转义解析——双引号走原生 JSON.parse,单引号是手写扫描器(lexer.ts:206-216 vs 220-250)。

8. 代码地图

主题文件符号
补全半句packages/lang-core/src/parser/statements.tsautoClose
剥围栏/注释packages/lang-core/src/parser/parser.tsstripFencespreprocess
增量流式解析packages/lang-core/src/parser/parser.tscreateStreamParserscanNewCompleted
降级 + 校验packages/lang-core/src/parser/materialize.tsmaterializeValuematerializeExpr
位置→命名 propspackages/lang-core/src/parser/parser.tscompileSchema
入口/可达性/孤儿packages/lang-core/src/parser/parser.tsbuildResultpickEntryId
partial/动态标记packages/lang-core/src/parser/types.tsElementNode(partialhasDynamicProps)