OpenUI 生成式 UI 完整实现详解

OpenUI 是一个开放标准的生成式 UI 框架,核心思路是:LLM 不输出 JSON,而是输出一种专为流式设计的紧凑语言(OpenUI Lang),前端解析后直接渲染为可交互的 React 组件树

整个系统分为 6 个步骤,从组件定义到最终渲染形成一条完整管线。


第一步:组件库定义

核心文件: packages/lang-core/src/library.ts

用 Zod v4 schema 同时定义组件的类型、验证规则和 LLM 提示词。一次定义,三处生效——TypeScript 类型检查、运行时验证、LLM prompt 生成。

defineComponent — 定义单个组件

以 Button 组件为例,分两部分定义:

Schema(属性定义): packages/react-ui/src/genui-lib/Button/schema.ts

import { z } from "zod/v4";
import { actionPropSchema } from "../Action/schema";

export const ButtonSchema = z.object({
  label: z.string(),                                        // 必填
  action: actionPropSchema.optional(),                      // 可选,Action 表达式
  variant: z.enum(["primary", "secondary", "tertiary"]).optional(),
  type: z.enum(["normal", "destructive"]).optional(),
  size: z.enum(["extra-small", "small", "medium", "large"]).optional(),
});

Component(渲染函数): packages/react-ui/src/genui-lib/Button/index.tsx

import { defineComponent, useFormValidation, useTriggerAction } from "@openuidev/react-lang";
import { Button as OpenUIButton } from "../../components/Button";
import { ButtonSchema } from "./schema";

export const Button = defineComponent({
  name: "Button",
  props: ButtonSchema,
  description: "Clickable button",
  component: ({ props }) => {
    const triggerAction = useTriggerAction();
    const isStreaming = useIsStreaming();

    return (
      <OpenUIButton
        variant={variantMap[props.variant] || "primary"}
        size={props.size || "medium"}
        disabled={isStreaming}
        onClick={() => {
          triggerAction(props.label, formName, props.action);
        }}
      >
        {props.label}
      </OpenUIButton>
    );
  },
});

defineComponent 内部机制

// library.ts 中的核心逻辑
function defineComponent<T extends z.$ZodObject, C>(config: {
  name: string;
  props: T;
  description: string;
  component: C;
}): DefinedComponent<T, C> {
  // 1. 用 WeakMap 给 schema 打标签,让 prompt 生成器知道这个 schema 叫 "Button"
  schemaIdTags.set(config.props, config.name);

  // 2. 创建 ref,允许其他组件引用它作为子组件
  return { ...config, ref: config.props as unknown as z.$ZodType<SubComponentOf<...>> };
}

两个关键点:

  1. schemaIdTags(WeakMap)让后续的 Zod 自省能将匿名 schema 关联回组件名
  2. ref 属性允许在父组件的 schema 中引用子组件,形成嵌套类型约束

复杂组件示例:Card 的嵌套组合

packages/react-ui/src/genui-lib/Card/schema.ts

import { ContentChildUnion } from "../unions";

// CardChildUnion 定义了 Card 能包含哪些子组件
export const CardChildUnion = z.union([
  ...ContentChildUnion.options,  // 继承基础内容组件(TextContent, Image, CodeBlock 等)
  Tabs.ref,                       // Tab 面板
  Carousel.ref,                   // 轮播
  Stack.ref,                      // 布局容器
]);

export const CardSchema = z
  .object({
    children: z.array(CardChildUnion),  // 子组件数组,类型由 union 约束
    variant: z.enum(["card", "sunk", "clear"]).optional(),
  })
  .merge(FlexPropsSchema);  // 继承 flex 布局属性(direction, gap, align, justify)

Card 的渲染函数:

export const Card = defineComponent({
  name: "Card",
  props: CardSchema,
  description: 'Styled container. variant: "card" | "sunk" | "clear"',
  component: ({ props, renderNode }) => (
    <OpenUICard
      variant={props.variant ?? "card"}
      style={{
        display: "flex",
        flexDirection: props.direction || "column",
        gap: gapMap[props.gap || "m"],
      }}
    >
      {renderNode(props.children)}  {/* renderNode 递归渲染子组件 */}
    </OpenUICard>
  ),
});

renderNode 是递归渲染的关键——它接收一个值(ElementNode、数组、或 AST 表达式),求值并渲染为 ReactNode。

createLibrary — 注册组件库

packages/react-ui/src/genui-lib/openuiLibrary.tsx

export const openuiLibrary = createLibrary({
  root: "Stack",              // 根容器组件(LLM 输出必须以 root = Stack(...) 开头)
  componentGroups: [          // 分组信息,影响 prompt 中的组件排列
    { name: "Layout",    components: ["Stack", "Tabs", "Accordion", ...] },
    { name: "Charts",    components: ["BarChartCondensed", "LineChartCondensed", ...] },
    { name: "Forms",     components: ["Form", "Input", "Select", ...] },
    { name: "Buttons",   components: ["Button", "Buttons"] },
  ],
  components: [               // 所有组件实例
    Button, Card, Table, Form, Input, Select,
    AreaChartCondensed, BarChartCondensed, ...55+ 个组件
  ],
});

createLibrary 内部发生了什么:

  1. 创建 Zod registry,注册每个组件 schema 的 ID(ButtonButtonSchema
  2. Schema 自省:遍历每个组件的 props.shape,提取:
    • 字段名和类型(label: string, variant: enum(...))
    • 是否可选(optional()
    • 是否数组(z.array(...)
    • 枚举值(z.enum(["a", "b"]))
    • 是否响应式(reactive() 包装)
  3. 生成组件签名Button(label, action?, variant?, type?, size?) — Clickable button
  4. 提供三个方法
    • library.prompt() — 生成 LLM 系统提示词
    • library.toSpec() — 获取组件规格(JSON 可序列化)
    • library.toJSONSchema() — 生成标准 JSON Schema

第二步:Prompt 生成

核心文件: packages/lang-core/src/parser/prompt.ts

调用 library.prompt(options) → 内部调用 generatePrompt(spec) → 生成完整的 LLM 系统提示词。

Prompt 结构

生成的 prompt 包含以下章节(按顺序拼接):

┌─────────────────────────────────────────────┐
│ 1. PREAMBLE                                 │
│    "You are an AI assistant that responds    │
│     using openui-lang..."                   │
├─────────────────────────────────────────────┤
│ 2. Syntax Rules                             │
│    - 语句格式: identifier = Expression       │
│    - root 必须是第一个语句                    │
│    - 位置参数(不是命名参数)                  │
│    - $variable 声明和自动创建                 │
│    - 表达式: +, -, *, /, ==, ?:, &&, ||     │
├─────────────────────────────────────────────┤
│ 3. Component Signatures                     │
│    Button(label, action?, variant?)          │
│    Card(children, variant?)                  │
│    Stack(children, direction?, gap?)         │
│    BarChartCondensed(labels, values, ...)    │
│    ...55+ 组件签名                           │
├─────────────────────────────────────────────┤
│ 4. Built-in Functions                       │
│    @Count(data) — 计数                       │
│    @Sum(data) — 求和                         │
│    @Each(data, "var", template) — 循环       │
│    @Filter(data, field, op, value) — 过滤    │
│    @Sort(data, field, direction) — 排序      │
├─────────────────────────────────────────────┤
│ 5. Query — 数据获取                         │
│    metrics = Query("tool", {arg}, {default}) │
├─────────────────────────────────────────────┤
│ 6. Mutation — 数据写入                      │
│    result = Mutation("tool", {arg})          │
├─────────────────────────────────────────────┤
│ 7. Action — 按钮行为                        │
│    Action([@Run(mutation), @Set($var, val)]) │
├─────────────────────────────────────────────┤
│ 8. Interactive Filters                      │
│    $variable + Select 绑定 + Query 自动刷新   │
├─────────────────────────────────────────────┤
│ 9. Streaming Rules                          │
│    - hoisting(前向引用)                     │
│    - 语句顺序: root → $vars → Query → 组件   │
├─────────────────────────────────────────────┤
│ 10. Examples                                │
│     完整的示例代码                           │
└─────────────────────────────────────────────┘

组件签名的自动生成过程

以 Button 为例,从 Zod schema 到签名:

Zod Schema:
  label: z.string()                              → label: string (必填)
  variant: z.enum(["primary","secondary"]).optional() → variant?: "primary" | "secondary"
  size: z.enum(["small","medium","large"]).optional() → size?: "small" | "medium" | "large"

自动生成的签名:
  Button(label, action?, variant?, type?, size?) — Clickable button

这就是 OpenUI 比 JSON 节省 67% token 的原因:

JSON 输出(~180 tokens):

{"type": "Button", "props": {"label": "Submit", "variant": "primary", "size": "medium"}}

OpenUI Lang 输出(~8 tokens):

Button("Submit", null, "primary", null, "medium")

第三步:LLM 输出 → 词法分析 → AST

核心文件:

  • packages/lang-core/src/parser/lexer.ts — 词法分析器
  • packages/lang-core/src/parser/expressions.ts — 表达式解析器
  • packages/lang-core/src/parser/statements.ts — 语句拆分
  • packages/lang-core/src/parser/parser.ts — 主解析器

LLM 输出示例

一个典型的 LLM 响应(OpenUI Lang 文本):

root = Card([header, body])
header = CardHeader("My Dashboard", "Sales overview")
$filterStatus = "all"
data = Query("getSales", {status: $filterStatus}, {rows: []})
filterRow = Select("status", [SelectItem("all", "All"), SelectItem("active", "Active")], null, null, $filterStatus)
body = Stack([filterRow, kpiRow, chart], "column", "m")
kpiRow = Stack([totalCard, avgCard], "row")
totalCard = TextContent("Total: " + @Count(data.rows))
avgCard = TextContent("Avg: " + @Round(@Avg(data.rows.amount), 1))
chart = BarChartCondensed(data.rows.name, data.rows.amount, null, null, "Sales Trend")

词法分析(Lexer)

将源文本拆分为 token 流:

输入: 'root = Card([header, body])'

输出 tokens:
  ┌──────────────────────┐
  │ { t: Ident, v: "root" }   │
  │ { t: Equals }              │
  │ { t: Ident, v: "Card" }    │
  │ { t: LParen }              │
  │ { t: LBrack }              │
  │ { t: Ident, v: "header" }  │
  │ { t: Comma }               │
  │ { t: Ident, v: "body" }    │
  │ { t: RBrack }              │
  │ { t: RParen }              │
  │ { t: Newline }             │
  └──────────────────────┘

设计特点:

  • 换行符是有意义的 token(语句分隔符),不像 JSON 用逗号/花括号
  • $ 前缀 → StateVar token(状态变量)
  • @ 前缀 → 内置函数标识
  • 空格和 tab 被跳过(不影响语法)

语句拆分 + 分类

语句拆分(按换行符 + 等号):
  "root"    = Card([header, body])           → value 类型
  "$filter" = "all"                          → state 类型($前缀)
  "data"    = Query("getSales", ...)         → query 类型(Query 保留调用)
  "chart"   = BarChartCondensed(...)         → value 类型

分类逻辑:

function classifyStatement(raw, expr) {
  if (expr.k === "Comp" && expr.name === "Query")    return { kind: "query", ... };
  if (expr.k === "Comp" && expr.name === "Mutation") return { kind: "mutation", ... };
  if (raw.idTokenType === T.StateVar)                return { kind: "state", ... };
  return { kind: "value", ... };
}

表达式解析(Pratt Parser)

使用 Pratt 优先级解析器处理嵌套表达式:

输入: '"Total: " + @Count(data.rows)'

解析过程:
  1. "Total: "          → Str("Total: ")
  2. +                  → BinOp(+, left, right)
  3. @Count             → Comp("Count", [...])
  4. (data.rows)        → Comp("Count", [DotAccess(Ref("data"), "rows")])

AST 结果:
  BinOp("+",
    Str("Total: "),
    Comp("Count", [DotAccess(Ref("data"), "rows")])
  )

支持的运算符(优先级从低到高):

  • ||, && — 逻辑运算
  • ==, !=, >, <, >=, <= — 比较
  • +, - — 加减(+ 同时用于字符串拼接)
  • *, /, % — 乘除取模
  • !, -(前缀)— 一元运算
  • ?: — 三元条件

引用解析 + 孤立检测

解析完成后,parser 执行两个后处理步骤:

  1. 引用解析:遍历所有 Ref("name") 节点,在符号表中查找 name 的定义。未找到的标记为 unresolved

  2. 孤立检测:从 root 出发做可达性分析,标记不可达的语句为 orphaned

引用解析:
  Card([Ref("header"), Ref("body")])
         ↓                    ↓
  找到 header 的 AST    找到 body 的 AST

孤立检测:
  root → Card → [header, body]
  header → CardHeader → OK (从 root 可达)
  unused = TextContent("...") → ORPHANED (从 root 不可达)

第四步:物化(Materialization)

核心文件: packages/lang-core/src/parser/materialize.ts

物化是 AST → 运行时 ElementNode 树 的转换过程。在单次递归遍历中完成五件事:

物化流程

AST:
  Comp("Card", [
    Ref("header"),
    Ref("body")
  ])

     ↓ materializeValue()

ElementNode:
  {
    typeName: "Card",
    props: {
      children: [            // 位置参数映射到 props.children
        ElementNode { typeName: "CardHeader", props: { title: "My Dashboard" } },
        ElementNode { typeName: "Stack", props: { children: [...], direction: "row" } }
      ]
    },
    statementId: "root"      // 来源语句名
  }

五个关键步骤

1. 引用解析

function resolveRef(name, ctx) {
  // 在符号表中查找
  const target = ctx.syms.get(name);
  // 递归物化
  return materializeValue(target, ctx);
}

递归过程:

root = Card([header, body])
  → Card 的物化需要 children 参数
  → children 包含 Ref("header")
  → 查找 header = CardHeader("My Dashboard", "Sales overview")
  → 递归物化 CardHeader
  → 返回 ElementNode { typeName: "CardHeader", ... }

2. 位置参数映射

OpenUI Lang 使用位置参数(不是命名参数),物化时根据组件 schema 映射:

LLM 输出: Card([header, body], "card")
Schema:   { children: z.array(...), variant: z.enum([...]) }
映射:     args[0] → children, args[1] → variant

3. 必填校验

// 检查 schema 中 required 字段是否都有值
if (isRequired && value === undefined) {
  ctx.errors.push({ message: `Missing required prop "${propName}"` });
}

4. 默认值填充

// 未提供的可选参数从 schema 获取默认值
if (value === undefined && schema._zod.def.type === "default") {
  value = schema._zod.def.defaultValue;
}

5. 动态值保留

遇到以下情况时,不立即求值,保留为 AST 节点(留给运行时求值器):

$filterStatus → StateRef 节点(运行时从 store 读取)
data = Query(...) → RuntimeRef 节点(运行时异步获取)
@Count(data.rows) → Comp 节点(运行时执行内置函数)

第五步:运行时求值(Evaluator)

核心文件: packages/lang-core/src/runtime/evaluator.ts

求值器负责在运行时动态计算那些在物化阶段被保留的 AST 表达式。

求值逻辑

function evaluate(node: ASTNode, context: EvaluationContext): unknown {
  switch (node.k) {
    // 字面量 → 直接返回值
    case "Str":   return node.v;                        // "hello" → "hello"
    case "Num":   return node.v;                        // 42 → 42
    case "Bool":  return node.v;                        // true → true

    // 状态引用 → 从 store 读取当前值
    case "StateRef": return context.getState(node.n);   // $filterStatus → "all"

    // 变量引用 → 解析到另一个声明的求值结果
    case "Ref":     return context.resolveRef(node.n);  // data → Query 结果

    // 数组 → 递归求值每个元素
    case "Arr":   return node.els.map(el => evaluate(el, context));

    // 对象 → 递归求值每个值
    case "Obj":   return Object.fromEntries(
                     node.entries.map(([k, v]) => [k, evaluate(v, context)])
                   );

    // 二元运算
    case "BinOp":
      const left = evaluate(node.left, context);
      const right = evaluate(node.right, context);
      return applyOp(node.op, left, right);             // "Total: " + @Count(...) → "Total: 3"

    // 成员访问(dot pluck)
    case "DotAccess":
      const obj = evaluate(node.obj, context);
      return obj?.[node.field];                         // data.rows → [{name:"A", amount:12}, ...]

    // 组件调用(内置函数 或 用户组件)
    case "Comp":
      if (BUILTINS[node.name]) return BUILTINS[node.name].fn(...args);
      return { typeName: node.name, props: mappedProps }; // ElementNode
  }
}

核心特性详解

1. Dot Pluck(数组字段提取)

data.rows = [{name: "Jan", amount: 120}, {name: "Feb", amount: 85}]

data.rows.name   → ["Jan", "Feb"]     // 自动提取每个元素的 name 字段
data.rows.amount → [120, 85]          // 自动提取每个元素的 amount 字段

这在图表绑定中非常有用:BarChartCondensed(data.rows.name, data.rows.amount) 直接把两个数组传给图表。

2. 响应式绑定

当 prop 标记了 reactive() 且值为 $variable 引用时:

// 求值器检测到 reactive schema + StateRef
if (isReactiveSchema(propSchema) && node.k === "StateRef") {
  // 生成 ReactiveAssign 标记,而非直接值
  return { __reactive: "assign", target: "filterStatus", expr: node };
}

当用户在 Select 组件中更改 $filterStatus 的值时:

  1. Store 更新 $filterStatus = "active"
  2. 所有引用 $filterStatus 的组件重新求值
  3. Query 自动重新发起请求 Query("getSales", {status: "active"}, ...)
  4. 新数据到达后,图表和 KPI 自动更新

3. @Each 循环渲染

@Each(data.rows, "item", TextContent(item.name + ": " + item.amount))

求值过程:

data.rows = [{name: "Jan", amount: 120}, {name: "Feb", amount: 85}]

迭代 1: item = {name: "Jan", amount: 120} → TextContent("Jan: 120")
迭代 2: item = {name: "Feb", amount: 85}  → TextContent("Feb: 85")

结果: [ElementNode, ElementNode]

4. Query 异步数据

data = Query("getSales", {status: $filterStatus}, {rows: []})

求值过程:

  1. 初始:返回默认值 {rows: []}(立即渲染,UI 不空白)
  2. 异步:调用 toolProvider.callTool("getSales", {status: $filterStatus})
  3. 数据到达:更新 store 中的 data
  4. 触发重新求值:所有依赖 data 的组件自动更新

第六步:React 渲染(Renderer)

核心文件: packages/react-lang/src/Renderer.tsx

Renderer API

<Renderer
  response={llmOutput}          // LLM 输出的 OpenUI Lang 文本(流式或完整)
  library={openuiLibrary}       // createLibrary() 返回的组件库
  isStreaming={true}             // 是否正在流式接收
  onAction={handleAction}       // 按钮点击回调
  toolProvider={mcpClient}      // MCP 客户端 或 函数映射,用于 Query/Mutation
  onStateUpdate={saveState}     // 状态变更回调(持久化用)
  initialState={savedState}     // 初始状态(从持久化恢复)
  onError={handleErrors}        // 解析错误回调(可用于 LLM 自动修正循环)
/>

Renderer 内部工作流

┌─────────────────────────────────────────────────────┐
│ Renderer.tsx                                         │
│                                                      │
│  1. useOpenUIState hook 管理全局状态                   │
│     ├─ 调用 parse(response) 解析 LLM 输出            │
│     ├─ 解析结果变化时触发重新渲染                      │
│     └─ 流式模式下,每个 token chunk 都重新解析         │
│                                                      │
│  2. ElementErrorBoundary 包裹渲染                     │
│     ├─ 捕获渲染错误                                  │
│     ├─ 显示"最后一次成功渲染"的结果                   │
│     └─ 新的有效输出到达时自动恢复                     │
│                                                      │
│  3. 递归渲染组件树                                   │
│     ElementNode { typeName: "Card", props: {...} }   │
│       ↓                                              │
│     library.components["Card"].component({           │
│       props,                                         │
│       renderNode  ← 递归渲染函数                     │
│     })                                               │
│       ↓                                              │
│     <OpenUICard>                                     │
│       {renderNode(props.children)}                   │
│         ↓                                            │
│       子 ElementNode 递归渲染...                      │
│     </OpenUICard>                                    │
│                                                      │
│  4. 渲染结束 → 完整可交互的 React UI                  │
└─────────────────────────────────────────────────────┘

递归渲染的完整过程

以一个简单的仪表板为例:

LLM 输出:
  root = Card([header, body])
  header = CardHeader("Dashboard", "Overview")
  body = Stack([kpi, chart], "row")
  kpi = TextContent("Total: 3")
  chart = BarChartCondensed(["A","B","C"], [12, 8, 15])

渲染过程:

1. root = Card([header, body])
   → library.components["Card"].component({
       props: { children: [ElementNode, ElementNode], variant: "card" },
       renderNode
     })
   → <OpenUICard variant="card">
       {renderNode([ElementNode(header), ElementNode(body)])}

2. header = CardHeader("Dashboard", "Overview")
   → library.components["CardHeader"].component({
       props: { title: "Dashboard", subtitle: "Overview" },
       renderNode
     })
   → <OpenUICardHeader title="Dashboard" subtitle="Overview" />

3. body = Stack([kpi, chart], "row")
   → library.components["Stack"].component({
       props: { children: [ElementNode, ElementNode], direction: "row" },
       renderNode
     })
   → <div style="display:flex; flex-direction:row">

4. kpi = TextContent("Total: 3")
   → <TextContentContainer>Total: 3</TextContentContainer>

5. chart = BarChartCondensed(["A","B","C"], [12, 8, 15])
   → <Recharts.BarChart data={[{name:"A",value:12},...]} />

最终 React 树:
  <Card>
    <CardHeader title="Dashboard" />
    <Stack direction="row">
      <TextContent>Total: 3</TextContent>
      <BarChart data={...} />
    </Stack>
  </Card>

ElementErrorBoundary — 容错机制

class ElementErrorBoundary extends Component {
  private lastValidChildren: ReactNode = null;

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  render() {
    if (this.state.hasError) {
      // 渲染错误时显示上一次成功的结果
      return this.lastValidChildren;
    }
    // 成功时缓存结果
    this.lastValidChildren = this.props.children;
    return this.props.children;
  }
}

设计目的: 流式过程中,某个 chunk 可能导致暂时的解析错误(比如组件名截断)。ErrorBoundary 保证不会白屏——显示上一次成功的 UI,等新的有效 chunk 到达后自动恢复。


流式渲染的关键:Hoisting(前向引用)

OpenUI Lang 支持前向引用——变量可以在定义之前使用:

token chunk 1: root = Card([body])
                                        ← body 此时未定义
token chunk 2: body = Stack([chart])
                                        ← chart 此时未定义
token chunk 3: chart = BarChart(...)
                                        ← 定义到达,触发冒泡渲染

createStreamParser 的工作方式

// parser.ts
function createStreamParser() {
  let buffer = "";

  return {
    feed(chunk: string): ParseResult {
      buffer += chunk;
      return parse(buffer);  // 每次重新解析完整 buffer
    }
  };
}

每个 token chunk 到达时:

  1. 追加到 buffer
  2. 重新解析完整 buffer
  3. 未定义的引用标记为 unresolved
  4. 一旦定义到达,引用自动解析,触发重新渲染

流式体验

时间线:
  t=0ms   → root = Card([body])           → 显示空 Card 容器
  t=50ms  → body = Stack([kpi, chart])     → Card 内显示 Stack 布局
  t=100ms → kpi = TextContent("Total: 3")  → KPI 区域出现文字
  t=150ms → chart = BarChart(...)          → 图表出现

用户体验: 结构先行,内容逐步填充("渐进式渲染")

完整数据流总览

┌──────────────────────────────────────────────────────────────┐
│ 开发时(一次)                                                 │
│                                                               │
│   Zod Schema ──→ defineComponent() ──→ DefinedComponent       │
│        ↓                                    ↓                 │
│   createLibrary() ←── 55+ 组件               ↓                 │
│        ↓                                    ↓                 │
│   library.prompt()             library.components             │
│        ↓                                    ↓                 │
│   系统提示词                            组件注册表              │
└───────┬──────────────────────────────┬────────────────────────┘
        │                              │
        ▼                              ▼
┌──────────────────────────────────────────────────────────────┐
│ 运行时(每轮对话)                                             │
│                                                               │
│   系统提示词 + 用户消息 ──→ LLM ──→ OpenUI Lang 文本(流式)    │
│                                                               │
│   示例 LLM 输出:                                              │
│   root = Card([header, body])                                 │
│   header = CardHeader("Dashboard")                            │
│   $filter = "all"                                             │
│   data = Query("getSales", {status: $filter}, {rows: []})     │
│   kpi = TextContent("Total: " + @Count(data.rows))            │
│   chart = BarChartCondensed(data.rows.name, data.rows.amount) │
└───────┬──────────────────────────────────────────────────────┘
        │ 流式 token chunks
        ▼
┌──────────────────────────────────────────────────────────────┐
│ 解析管线(每个 chunk)                                         │
│                                                               │
│   Lexer ──→ tokens ──→ Parser ──→ AST                        │
│                                    ↓                          │
│                           语句分类                             │
│                           ├─ value:    root, header, kpi      │
│                           ├─ state:    $filter                │
│                           ├─ query:    data                   │
│                           └─ mutation: result                 │
│                                    ↓                          │
│                           Materialize ──→ ElementNode 树       │
│                           (引用解析、参数映射、校验、默认值)      │
└───────┬──────────────────────────────────────────────────────┘
        │ ElementNode 树
        ▼
┌──────────────────────────────────────────────────────────────┐
│ 运行时求值 + React 渲染                                        │
│                                                               │
│   Evaluator                                                   │
│   ├─ 字面量 → 直接值                                          │
│   ├─ $filter → store.getState("filter") → "all"              │
│   ├─ data.rows.amount → dot pluck → [120, 85, 15]            │
│   ├─ @Count(data.rows) → 3                                   │
│   └─ "Total: " + 3 → "Total: 3"                              │
│                                                               │
│   Renderer                                                    │
│   ├─ library.components["Card"].component({props, renderNode})│
│   ├─   → <OpenUICard>                                        │
│   ├─     {renderNode(children)}                               │
│   ├─       → <CardHeader>Dashboard</CardHeader>              │
│   ├─       → <Stack>                                         │
│   ├─         → <TextContent>Total: 3</TextContent>           │
│   ├─         → <BarChart data={[...]} />                     │
│   └─ ElementErrorBoundary 包裹(错误容错)                    │
│                                                               │
│   结果: 完整可交互的 React 仪表板                              │
│   用户操作 Select → $filter 变化 → Query 重发 → 图表自动更新   │
└──────────────────────────────────────────────────────────────┘

增量编辑(Edit Mode)

除了初次生成,OpenUI 还支持增量编辑——用户修改需求时,LLM 只输出变更的语句。

merge.ts — 编辑合并引擎

原始程序:
  root = Card([header, body])
  header = CardHeader("Dashboard")
  body = Stack([chart], "column")
  chart = BarChart(...)

LLM 编辑输出(只改 header 的标题):
  header = CardHeader("New Title")

合并结果:
  root = Card([header, body])      ← 不变
  header = CardHeader("New Title") ← 覆盖
  body = Stack([chart], "column")  ← 不变
  chart = BarChart(...)            ← 不变

合并规则:

  • 同名覆盖:语句名相同则替换
  • 新增追加:新语句名则添加到程序中
  • null 删除:赋值 null 则删除该语句
  • 自动 GC:从 root 出发做可达性分析,不可达的语句自动垃圾回收

序列化(serialize.ts)— 逆向操作

serialize.ts 是 parse + materialize 的逆过程,将 ElementNode 树转回 OpenUI Lang 源码:

ElementNode { typeName: "Card", props: { children: [...], variant: "card" } }
     ↓ serialize
root = Card([header, body], "card")
header = CardHeader("Dashboard")
body = Stack([chart], "column")
chart = BarChart(...)

用于:

  • 将用户手动修改的 UI 状态序列化回文本
  • 在 edit mode 中生成原始代码供 LLM 参考
  • 调试和日志记录

复杂布局与表单交互

OpenUI 的布局系统基于 Stack(flex 容器)作为核心,配合 TabsAccordionCarousel 等交互容器,通过 Union 类型约束嵌套规则。表单系统由三层组成:FormFormControlInput/Select/TextArea,配合 reactive() 实现双向数据绑定。


Stack — Flex 布局容器

核心文件: packages/react-ui/src/genui-lib/Stack/schema.ts

Stack 是所有布局的基础,Schema 定义了完整的 flex 属性:

export const FlexPropsSchema = z.object({
  direction: z.enum(["row", "column"]).optional(),
  gap: z.enum(["none", "xs", "s", "m", "l", "xl", "2xl"]).optional(),
  align: z.enum(["start", "center", "end", "stretch", "baseline"]).optional(),
  justify: z.enum(["start", "center", "end", "between", "around", "evenly"]).optional(),
  wrap: z.boolean().optional(),
});

LLM 输出示例:

root = Stack([header, body, footer], "column", "m")
header = Stack([logo, nav], "row", "between")
body = Stack([sidebar, content], "row", "l")

渲染时的 CSS 映射:

direction: "row"    → flexDirection: "row"
gap: "m"            → gap: "12px"(通过 gapMap 映射)
align: "center"     → alignItems: "center"
justify: "between"  → justifyContent: "space-between"

Stack 可以嵌套使用,实现任意复杂度的二维布局。Card 组件也继承了 FlexPropsSchema,所以 Card 本身就是一个带样式的 flex 容器:

root = Card([toolbar, content], "card")
toolbar = Stack([searchBtn, filterSelect], "row", "between")
content = Stack([table, pagination], "column", "m")

Union 类型 — 组件嵌套约束

核心文件: packages/react-ui/src/genui-lib/unions.ts

ContentChildUnion(24 种基础内容组件)定义了哪些组件可以出现在容器内部:

export const ContentChildUnion = z.union([
  TextContent.ref, Image.ref, CodeBlock.ref,
  BarChartCondensed.ref, LineChartCondensed.ref,
  Input.ref, Select.ref, TextArea.ref,
  Button.ref, Buttons.ref,
  // ...24 种组件
]);

CardChildUnion 在此基础上扩展:

export const CardChildUnion = z.union([
  ...ContentChildUnion.options,  // 继承基础内容
  Tabs.ref,                      // 添加 Tab 面板
  Carousel.ref,                  // 添加轮播
  Stack.ref,                     // 添加布局容器
]);

LLM 不能在容器里放任意组件——只有 union 列表中的组件才会通过物化校验。这种约束让 LLM 输出更可预测。

Tabs — 标签页与流式自动切换

核心文件: packages/react-ui/src/genui-lib/Tabs/index.tsx

Tabs 在流式渲染中有一个特殊行为:自动切换到最新正在接收内容的 Tab

const [activeIndex, setActiveIndex] = useState(0);
const userHasInteracted = useRef(false);
const contentSizeRef = useRef<number[]>([]);

useEffect(() => {
  if (!isStreaming || userHasInteracted.current) return;

  // 找到内容增长最快的 Tab(正在流式接收的那个)
  let maxSize = 0, maxIdx = 0;
  children.forEach((child, i) => {
    const size = measureContent(child);
    if (size > maxSize) { maxSize = size; maxIdx = i; }
  });

  setActiveIndex(maxIdx);  // 自动切换
}, [isStreaming, children]);

// 用户手动点击后锁定
const handleTabClick = (index) => {
  userHasInteracted.current = true;
  setActiveIndex(index);
};

用户体验:LLM 流式输出 Tab 内容时,Tab 自动切换到正在生成的那一页,生成完毕后停留在最后一页。用户一旦手动点击过,自动切换就会停止。

LLM 输出示例:

tabs = Tabs([tab1, tab2, tab3], "Sales|Orders|Customers")
tab1 = Card([salesChart, salesKPI])
tab2 = Card([ordersTable])
tab3 = Card([customerList])

Accordion — 手风琴与流式自动展开

核心文件: packages/react-ui/src/genui-lib/Accordion/index.tsx

与 Tabs 类似,Accordion 也会自动展开正在接收内容的 Item

const [expandedItems, setExpandedItems] = useState<Set<number>>(new Set());
const userHasInteracted = useRef(false);

useEffect(() => {
  if (!isStreaming || userHasInteracted.current) return;

  const newExpanded = new Set<number>();
  children.forEach((child, i) => {
    if (hasNewContent(child)) newExpanded.add(i);
  });
  setExpandedItems(newExpanded);
}, [isStreaming, children]);

核心文件: packages/react-ui/src/genui-lib/Carousel/index.tsx

Carousel 的 Schema 使用数组的数组结构——每个子元素本身就是一个内容数组(一页):

children: z.array(z.array(ContentChildUnion))

LLM 输出:

carousel = Carousel([[slide1Content], [slide2Content], [slide3Content]])
slide1Content = Stack([image1, caption1], "column")

表单系统架构

表单系统由三层组成:

Form (创建 FormValidationContext)
  └─ FormControl (读取 errors, 显示 label/hint/error)
       └─ Input / Select / TextArea
            ├─ useStateField(name, value) — 状态绑定
            ├─ parseStructuredRules(rules) — 校验规则
            ├─ formValidation.registerField() — 注册到上下文
            ├─ onBlur → validateField()
            └─ onChange → clearFieldError()

Form — 校验上下文容器

核心文件: packages/react-ui/src/genui-lib/Form/index.tsx

export const Form = defineComponent({
  name: "Form",
  props: FormSchema,
  component: ({ props, renderNode }) => {
    const formValidation = useCreateFormValidation();
    const formName = useMemo(() => `form-${idCounter++}`, []);

    return (
      <FormValidationContext.Provider value={formValidation}>
        <FormNameContext.Provider value={formName}>
          <formElement>
            {renderNode(props.children)}
          </formElement>
        </FormNameContext.Provider>
      </FormValidationContext.Provider>
    );
  },
});

FormValidationContext 是整个表单系统的核心——它提供了 registerFieldvalidateFielderrors 等方法,让嵌套的 FormControl 和 Input 协同工作。

FormControl — 字段包装器

核心文件: packages/react-ui/src/genui-lib/FormControl/index.tsx

export const FormControl = defineComponent({
  name: "FormControl",
  props: FormControlSchema,
  component: ({ props, renderNode }) => {
    const formValidation = useContext(FormValidationContext);
    const fieldName = extractFieldName(props.children);  // 从子 Input 的 props.name 提取
    const error = formValidation?.errors?.[fieldName];

    return (
      <div className="form-control">
        {props.label && <Label>{props.label}</Label>}
        {renderNode(props.children)}
        {error && <ErrorMessage>{error}</ErrorMessage>}
        {props.hint && !error && <HintText>{props.hint}</HintText>}
      </div>
    );
  },
});

Input — 带校验的输入框

核心文件: packages/react-ui/src/genui-lib/Input/index.tsx

Input 的完整生命周期包含四个阶段:注册 → 输入 → 失焦校验 → 提交校验

export const Input = defineComponent({
  name: "Input",
  props: InputSchema,
  component: ({ props }) => {
    const formValidation = useContext(FormValidationContext);
    const formName = useContext(FormNameContext);

    // 1. 状态绑定
    const [fieldValue, setFieldValue] = useStateField(props.name, props.value);

    // 2. 解析校验规则
    const rules = parseStructuredRules(props.rules);

    // 3. 注册到表单上下文
    useEffect(() => {
      if (!formValidation) return;
      formValidation.registerField(props.name, {
        validate: () => validateValue(fieldValue, rules),
      });
      return () => formValidation.unregisterField(props.name);
    }, [props.name, fieldValue, rules]);

    // 4. 事件处理
    const handleBlur = () => formValidation?.validateField(props.name);
    const handleChange = (e) => {
      setFieldValue(e.target.value);
      formValidation?.clearFieldError(props.name);
    };

    return (
      <OpenUIInput
        value={fieldValue}
        onChange={handleChange}
        onBlur={handleBlur}
        disabled={isStreaming}
        placeholder={props.placeholder}
        error={!!formValidation?.errors?.[props.name]}
      />
    );
  },
});

校验规则 — parseStructuredRules

核心文件: packages/react-ui/src/genui-lib/rules.ts

export const rulesSchema = z.object({
  required: z.boolean().optional(),
  email: z.boolean().optional(),
  url: z.boolean().optional(),
  numeric: z.boolean().optional(),
  min: z.number().optional(),
  max: z.number().optional(),
  minLength: z.number().optional(),
  maxLength: z.number().optional(),
  pattern: z.string().optional(),
}).optional();

parseStructuredRules 将 Schema 解析为校验函数数组:

function validateValue(value, rules) {
  const errors: string[] = [];
  if (rules.required && !value) errors.push("此字段必填");
  if (rules.email && !isEmail(value)) errors.push("请输入有效的邮箱地址");
  if (rules.minLength && value.length < rules.minLength)
    errors.push(`最少 ${rules.minLength} 个字符`);
  if (rules.pattern) {
    const regex = new RegExp(rules.pattern);
    if (!regex.test(value)) errors.push("格式不正确");
  }
  return errors[0] || null;
}

Select — 下拉选择与 Factory Schema

核心文件: packages/react-ui/src/genui-lib/Select/index.tsx

Select 使用工厂模式创建 Schema,因为 SelectItem 的选项是动态的:

export const createSelectSchema = (SelectItemComponent) => {
  return z.object({
    name: z.string(),
    options: z.array(SelectItemComponent.ref),
    value: z.string().optional(),
    rules: rulesSchema,
  });
};

SelectItem 是声明式子组件——渲染时输出 null(它只是 Schema 约束,不是真正的 UI 元素):

export const SelectItem = defineComponent({
  name: "SelectItem",
  props: z.object({ value: z.string(), label: z.string() }),
  component: () => null,
});

LLM 输出示例:

statusSelect = Select("status", [
  SelectItem("active", "Active"),
  SelectItem("inactive", "Inactive"),
  SelectItem("all", "All"),
], null, null, $filterStatus)

reactive() — 双向数据绑定

reactive() 是 Zod schema 的包装器,标记某个属性支持与 $variable 双向绑定:

// 在 Schema 中使用 reactive
value: reactive(z.string().optional())

当 Input/Select 的 value 属性引用 $variable 时,求值器检测到 reactive schema + StateRef,生成 ReactiveAssign 标记:

// evaluator.ts 中的处理
if (isReactiveSchema(propSchema) && node.k === "StateRef") {
  return { __reactive: "assign", target: "filterStatus", expr: node };
}

交互流程:

  1. 用户在 Select 中选择 “Active”
  2. useStateField 调用 store.setState("filterStatus", "active")
  3. 所有引用 $filterStatus 的组件重新求值
  4. data = Query("getSales", {status: $filterStatus}, ...) 自动重新请求
  5. 新数据到达后,图表和 KPI 自动更新

Button — 表单提交与 Action

核心文件: packages/react-ui/src/genui-lib/Button/index.tsx

const triggerAction = useTriggerAction();

<OpenUIButton
  onClick={() => {
    if (props.variant === "primary" && formValidation) {
      const isValid = formValidation.validateAll();
      if (!isValid) return;
    }
    triggerAction(props.label, formName, props.action);
  }}
  disabled={isStreaming}
>
  {props.label}
</OpenUIButton>

Action 表达式示例:

submitBtn = Button("Submit", Action([@Run(saveData), @Set($submitted, true)]))

Action 支持:

  • @Run(mutation) — 执行 Mutation(调用外部 API 写数据)
  • @Set($var, value) — 设置状态变量
  • 组合使用 — 可以在 Action 数组中串联多个操作

完整交互示例

一个带筛选的仪表板,展示布局和表单如何协同工作:

root = Card([header, body], "card")
header = CardHeader("Sales Dashboard", "Real-time filtered data")
$filterStatus = "all"
$filterYear = "2024"
data = Query("getSales", {status: $filterStatus, year: $filterYear}, {rows: []})

body = Stack([filterRow, tabs], "column", "m")

filterRow = Stack([statusSelect, yearSelect], "row", "m")
statusSelect = FormControl(Select("status", [
  SelectItem("all", "All"),
  SelectItem("active", "Active"),
  SelectItem("closed", "Closed")
], null, null, $filterStatus), "Status")
yearSelect = FormControl(Select("year", [
  SelectItem("2024", "2024"),
  SelectItem("2023", "2023")
], null, null, $filterYear), "Year")

tabs = Tabs([tab1, tab2], "Overview|Details")
tab1 = Card([kpiRow, chart], "sunk")
kpiRow = Stack([totalCard, avgCard], "row", "m")
totalCard = TextContent("Total: " + @Count(data.rows))
avgCard = TextContent("Avg: " + @Round(@Avg(data.rows.amount), 1))
chart = BarChartCondensed(data.rows.name, data.rows.amount)

tab2 = Card([dataTable], "sunk")
dataTable = Table(data.rows)

交互数据流:

用户选择 "Active"
  → $filterStatus = "active"(reactive 更新)
    → Query("getSales", {status: "active", year: "2024"}, ...) 重新请求
      → data.rows 更新为筛选后的数据
        → @Count(data.rows) 重新计算
        → BarChartCondensed(data.rows.name, data.rows.amount) 重新渲染
        → Table(data.rows) 重新渲染

数据绑定机制 — 省市区联动筛选

OpenUI 的数据绑定由四个子系统协同完成:Store(状态仓库)reactive()(双向绑定标记)Query(声明式数据获取)collectQueryDeps(依赖追踪)

以省市区联动筛选为例,LLM 输出的 OpenUI Lang 代码:

$province = ""
$city = ""
$district = ""

provinces = Query("get_provinces", {}, {items: []})
cities = Query("get_cities", {province: $province}, {items: []})
districts = Query("get_districts", {province: $province, city: $city}, {items: []})

root = Card([
  FormControl(provinceSelect, "省份"),
  FormControl(citySelect, "城市"),
  FormControl(districtSelect, "区县")
])

provinceSelect = Select("province", provinces.items, null, null, $province)
citySelect = Select("city", cities.items, null, null, $city)
districtSelect = Select("district", districts.items, null, null, $district)

collectQueryDeps — 静态提取依赖

核心文件: packages/lang-core/src/parser/parser.ts

解析器在遇到 Query(...) 语句时,遍历其参数 AST,提取所有 $variable 引用:

export function collectQueryDeps(node: unknown): string[] {
  if (!isASTNode(node)) return [];
  const refs = new Set<string>();
  walkAST(node, (current) => {
    if (current.k === "StateRef") refs.add(current.n);
  });
  return [...refs];
}

对于省市区例子,解析结果:

provinces = Query("get_provinces", {}, {items: []})
  → deps: []                           ({} 中没有 $variable)

cities = Query("get_cities", {province: $province}, {items: []})
  → deps: ["$province"]                ({province: $province} 中有 $province)

districts = Query("get_districts", {province: $province, city: $city}, {items: []})
  → deps: ["$province", "$city"]       (两个 $variable)

这是静态分析——在 parse 阶段就确定了每个 Query 依赖哪些状态变量,不需要运行时追踪。

Store — 响应式状态仓库

核心文件: packages/lang-core/src/runtime/store.ts

Store 是一个 key-value 仓库,使用 subscribe / getSnapshot 模式与 React 的 useSyncExternalStore 对接:

export function createStore(): Store {
  const state = new Map<string, unknown>();
  const listeners = new Set<() => void>();

  function set(name: string, value: unknown): void {
    const existing = state.get(name);
    if (Object.is(existing, value)) return;  // 原始类型用 Object.is 比较
    // 对象做浅比较,防止不必要的重渲染
    // ... 浅比较逻辑 ...
    state.set(name, value);
    rebuildSnapshot();
    notify();  // 通知所有订阅者
  }

  function initialize(defaults, persisted): void {
    // 保留用户已修改的值,不被默认值覆盖
    for (const key of Object.keys(defaults)) {
      if (!state.has(key)) {
        state.set(key, defaults[key]);
      }
    }
  }
}

初始化时,$province="", $city="", $district="" 被写入 Store。

reactive() + evaluate-prop — 双向绑定标记

核心文件: packages/lang-core/src/runtime/evaluate-prop.ts

当求值器处理 Select 组件的 value 属性时:

Select("province", provinces.items, null, null, $province)
                                      ↑ 参数名是 value
                                      ↑ $province 是 StateRef 节点

求值器检测到 reactive schema + StateRef 的组合:

if (value.k === "StateRef" && isReactiveSchema(reactiveSchema)) {
  return {
    __reactive: "assign",
    target: value.n,     // "province"(去掉 $ 前缀)
    expr: { k: "StateRef", n: "$value" },
  };
}

这个 ReactiveAssign 标记不是最终值,而是一个绑定指令——告诉运行时”这个属性的值应该从 Store 的 province 读取,修改时写回 Store 的 province”。

resolveStateField — 组件侧绑定解析

核心文件: packages/lang-core/src/runtime/state-field.ts

Select 组件内部调用 useStateField(props.name, props.value)resolveStateField 识别 ReactiveAssign 标记:

export function resolveStateField<T>(
  name: string,
  bindingValue: unknown,
  store: Store | null,
  evaluationContext: EvaluationContext | null,
  fieldGetter: (fieldName: string) => unknown,
  fieldSetter: (fieldName: string, value: unknown) => void,
): StateField<T> {
  // 检测到 ReactiveAssign → 双向绑定模式
  if (isReactiveAssign(bindingValue) && store && evaluationContext) {
    const { target, expr } = bindingValue;
    return {
      name,
      value: store.get(target) as T,          // 从 Store 读取当前值
      setValue: (value: T) => {                // 写回 Store
        const extraScope = { $value: value };
        const nextValue = evaluate(expr, { ...evaluationContext, extraScope });
        store.set(target, nextValue);          // → 触发 notify()
      },
      isReactive: true,
    };
  }

  // 非 reactive → 普通表单字段,用 fieldGetter/fieldSetter
  return {
    name,
    value: (fieldGetter(name) ?? bindingValue) as T,
    setValue: (value: T) => fieldSetter(name, value),
    isReactive: false,
  };
}

关键区别

  • isReactive: true — 值直接读写 Store 的 $province,变化会触发全局响应式更新
  • isReactive: false — 值读写表单局部状态,不会触发 Query 重发

useOpenUIState — 联动触发核心

核心文件: packages/react-lang/src/hooks/useOpenUIState.ts

这是联动的核心链条,用 React 的 useSyncExternalStore 监听 Store 变化:

// 1. 订阅 Store 变化
const storeSnapshot = useSyncExternalStore(
  store.subscribe, store.getSnapshot, store.getSnapshot
);

// 2. Store 变化时,重新求值所有 Query
useEffect(() => {
  if (isStreaming) return;  // 流式期间不发请求

  const queryStmts = result?.queryStatements ?? [];
  const evaluatedNodes = queryStmts.map((qn) => {
    // 收集当前 Query 的依赖值
    const relevantDeps: Record<string, unknown> = {};
    if (qn.deps) {
      for (const ref of qn.deps) {
        relevantDeps[ref] = storeSnapshot[ref];  // 读取最新的 $province 值
      }
    }
    return {
      statementId: qn.statementId,
      toolName: evaluate(qn.toolAST, evaluationContext),
      args: evaluate(qn.argsAST, evaluationContext),  // 用新值重新求值参数
      defaults: evaluate(qn.defaultsAST, evaluationContext),
      deps: relevantDeps,  // deps 变化 → cache key 变化 → 触发重新请求
    };
  });

  queryManager.evaluateQueries(evaluatedNodes);
}, [isStreaming, result?.queryStatements, evaluationContext, queryManager, storeSnapshot]);

QueryManager — 缓存键驱动的智能请求

核心文件: packages/lang-core/src/runtime/queryManager.ts

缓存键包含 deps 值:

function buildCacheKey(toolName: string, args: unknown, deps: unknown): string {
  const depsKey = deps != null ? "::" + stableStringify(deps) : "";
  return toolName + "::" + stableStringify(args) + depsKey;
}

$province"" 变为 "Zhejiang" 时:

cities 的旧 cacheKey: "get_cities::{province:''}::{province:''}"
cities 的新 cacheKey: "get_cities::{province:'Zhejiang'}::{province:'Zhejiang'}"
                                    ↑ 变了 → 新缓存键 → 触发重新请求

evaluateQueries 的处理逻辑:

function evaluateQueries(queryNodes: QueryNode[]) {
  for (const node of queryNodes) {
    const cacheKey = buildCacheKey(node.toolName, node.args, node.deps);
    const existing = queries.get(node.statementId);

    if (existing) {
      // 缓存键变了 → 保存旧键作为 fallback
      if (existing.cacheKey !== cacheKey) {
        existing.prevCacheKey = existing.cacheKey;
      }
      existing.cacheKey = cacheKey;
    }

    // 没有已缓存的数据且没有正在进行的请求 → 发起新请求
    const entry = cache.get(cacheKey);
    const hasSettledData = entry && entry.data !== undefined && !entry.inFlight;
    if (toolProvider && !hasSettledData && !entry?.inFlight) {
      executeFetch(cacheKey, node.statementId);
    }
  }
}

prevCacheKey 优雅降级——请求新数据时,UI 不会空白。getResult 方法有三级 fallback:

function getResult(statementId: string): unknown {
  const q = queries.get(statementId);

  // 1. 当前缓存有数据 → 用最新的
  const entry = cache.get(q.cacheKey);
  if (entry && entry.data !== undefined) return entry.data;

  // 2. 正在加载 → 显示上一次的数据(不白屏)
  if (q.prevCacheKey) {
    const prev = cache.get(q.prevCacheKey);
    if (prev && prev.data !== undefined) return prev.data;
  }

  // 3. 都没有 → 用默认值 {items: []}
  return q.defaults;
}

过期请求自动丢弃——executeFetch 检查缓存键是否还在有效:

async function executeFetch(cacheKey: string, statementId: string) {
  const fetchKey = cacheKey;  // 记住发起时的缓存键

  // ... 发起请求 ...

  // 请求回来后检查:如果用户已经又选了别的省,这个结果就过期了
  const current = queries.get(statementId);
  if (!current || current.cacheKey !== fetchKey) {
    entry.inFlight = false;
    return;  // 丢弃过期结果
  }
  // ... 应用数据 ...
}

这防止了竞态条件——用户快速切换省份时,只有最后一次请求的结果会被应用。

省市区联动完整时序

用户选择省份 "浙江"
  │
  ├─ Select 组件: field.setValue("浙江")
  │    └─ resolveStateField → store.set("$province", "浙江")
  │         └─ Store.notify() 广播给所有订阅者
  │
  ├─ useSyncExternalStore 捕获变化 → React 重渲染
  │    └─ storeSnapshot = {"$province": "浙江", "$city": "", "$district": "", ...}
  │
  ├─ useEffect 触发 → 重新求值所有 Query
  │    ├─ provinces: deps={} → cacheKey 不变 → 不重新请求
  │    ├─ cities:    deps={"$province":"浙江"} → cacheKey 变了 → 发起 get_cities({province:"浙江"})
  │    └─ districts: deps={"$province":"浙江","$city":""} → cacheKey 变了 → 发起 get_districts({province:"浙江",city:""})
  │
  ├─ 请求进行中:
  │    ├─ cities 显示 defaults {items: []}(或上次的旧数据)
  │    └─ districts 显示 defaults {items: []}
  │
  ├─ cities 数据到达 → querySnapshot 变化 → React 重渲染
  │    └─ 城市 Select 的 options 更新为 [杭州, 宁波, 温州, ...]
  │
  └─ districts 数据到达 → querySnapshot 变化 → React 重渲染
       └─ 区县 Select 的 options 更新为 [西湖区, 上城区, ...]

用户继续选择城市 "杭州"
  │
  ├─ store.set("$city", "杭州") → notify()
  │
  ├─ useEffect 重新求值 Query:
  │    ├─ provinces: 不变,不请求
  │    ├─ cities:    不变(deps 只含 $province,值没变),不请求
  │    └─ districts: deps={"$province":"浙江","$city":"杭州"} → cacheKey 变了 → 发起请求
  │
  └─ districts 数据到达 → 区县 Select 更新为杭州的区县列表