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<...>> };
}
两个关键点:
schemaIdTags(WeakMap)让后续的 Zod 自省能将匿名 schema 关联回组件名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 内部发生了什么:
- 创建 Zod registry,注册每个组件 schema 的 ID(
Button→ButtonSchema) - Schema 自省:遍历每个组件的
props.shape,提取:- 字段名和类型(
label: string,variant: enum(...)) - 是否可选(
optional()) - 是否数组(
z.array(...)) - 枚举值(
z.enum(["a", "b"])) - 是否响应式(
reactive()包装)
- 字段名和类型(
- 生成组件签名:
Button(label, action?, variant?, type?, size?) — Clickable button - 提供三个方法:
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 用逗号/花括号
$前缀 →StateVartoken(状态变量)@前缀 → 内置函数标识- 空格和 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 执行两个后处理步骤:
-
引用解析:遍历所有
Ref("name")节点,在符号表中查找name的定义。未找到的标记为unresolved。 -
孤立检测:从
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 的值时:
- Store 更新
$filterStatus = "active" - 所有引用
$filterStatus的组件重新求值 - Query 自动重新发起请求
Query("getSales", {status: "active"}, ...) - 新数据到达后,图表和 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: []})
求值过程:
- 初始:返回默认值
{rows: []}(立即渲染,UI 不空白) - 异步:调用
toolProvider.callTool("getSales", {status: $filterStatus}) - 数据到达:更新 store 中的
data值 - 触发重新求值:所有依赖
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 到达时:
- 追加到 buffer
- 重新解析完整 buffer
- 未定义的引用标记为
unresolved - 一旦定义到达,引用自动解析,触发重新渲染
流式体验
时间线:
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 容器)作为核心,配合 Tabs、Accordion、Carousel 等交互容器,通过 Union 类型约束嵌套规则。表单系统由三层组成:Form → FormControl → Input/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]);
Carousel — 横向轮播
核心文件: 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 是整个表单系统的核心——它提供了 registerField、validateField、errors 等方法,让嵌套的 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 };
}
交互流程:
- 用户在 Select 中选择 “Active”
useStateField调用store.setState("filterStatus", "active")- 所有引用
$filterStatus的组件重新求值 data = Query("getSales", {status: $filterStatus}, ...)自动重新请求- 新数据到达后,图表和 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 更新为杭州的区县列表