json-render:生成式 UI 的实现原理
json-render 是一个开源的生成式 UI 框架,它让 AI 能够通过流式输出 JSON 规格来动态生成界面。本文基于 json-render 源码,详细拆解其核心架构与数据流。
核心思路
传统 AI 聊天返回纯文本,而 json-render 让 AI 返回结构化的 UI 规格(Spec)。客户端收到后,通过组件注册表将其渲染为真实的 React/Svelte/Vue 组件。
用户输入 → AI 模型 → JSONL 流 → 客户端解析 → Spec 树 → 渲染引擎 → 真实 UI
整个流程分为 7 个关键步骤。
步骤 1:Schema 定义——告诉 AI 输出什么
Schema 是整个系统的起点,它定义了两件事:
- Spec 结构:AI 输出的 UI 树长什么样
- Catalog 需求:运行时需要哪些组件和动作
源码位置:packages/core/src/schema.ts
export interface SchemaDefinition<TSpec, TCatalog> {
/** AI 生成的 spec 结构 */
spec: TSpec;
/** 运行时 catalog 必须提供的组件/动作 */
catalog: TCatalog;
}
Schema 通过类型安全的构建器定义原始类型:
export interface SchemaBuilder {
string(): SchemaType<"string">;
number(): SchemaType<"number">;
boolean(): SchemaType<"boolean">;
array<T>(item: T): SchemaType<"array", T>;
object<T>(shape: T): SchemaType<"object", T>;
ref(path: string): SchemaType<"ref", string>; // 引用 catalog 中的条目
propsOf(path: string): SchemaType<"propsOf", string>; // 引用 catalog 组件的 props
map<T>(entryShape: T): SchemaType<"map", T>; // 命名条目映射
}
数据流
SchemaBuilder.string()
↓
SchemaType<"string">
↓
SchemaDefinition.spec / SchemaDefinition.catalog
↓
传递给 Catalog 创建和 Prompt 生成
步骤 2:Catalog 创建——注册组件和动作
Catalog 是 Schema 的具象化,它把抽象的类型定义映射为实际可用的组件和动作。
源码位置:packages/core/src/schema.ts(defineCatalog 函数)
const catalog = schema.createCatalog({
components: {
Card: {
props: z.object({
title: z.string().optional(),
description: z.string().optional(),
}),
},
Button: {
props: z.object({
label: z.string(),
variant: z.enum(["primary", "secondary"]).optional(),
}),
},
// ... 更多组件
},
actions: {
setState: {
params: z.object({
statePath: z.string(),
value: z.unknown(),
}),
},
// ... 更多动作
},
});
每个 Catalog 条目包含:
- 组件:名称 + Zod props schema
- 动作:名称 + Zod params schema
Catalog 提供两个核心能力:
catalog.prompt():生成 AI 系统提示词catalog.componentNames/catalog.actionNames:列出所有可用组件和动作
数据流
Schema.createCatalog(concreteDefinitions)
↓
Catalog { data, componentNames, actionNames, schema, prompt() }
↓
catalog.prompt() → AI 系统提示词
→ 组件注册表 (用于渲染)
步骤 3:AI 流式生成——Prompt 构建与 JSONL 输出
3.1 Prompt 构建
catalog.prompt() 方法根据 Catalog 数据生成完整的系统提示词。它告诉 AI:
- 有哪些可用组件及其 props
- 有哪些可用动作及其参数
- 输出格式要求(JSONL + RFC 6902 JSON Patch)
- 自定义规则
源码位置:packages/core/src/schema.ts 第 582 行 generatePrompt() 函数
function generatePrompt<TDef, TCatalog>(
catalog: Catalog<TDef, TCatalog>,
options: PromptOptions,
): string {
const { system, customRules = [], mode } = options;
const lines: string[] = [];
lines.push(system);
lines.push("");
lines.push("OUTPUT FORMAT (JSONL, RFC 6902 JSON Patch):");
lines.push("Output JSONL using RFC 6902 JSON Patch operations to build a UI tree.");
// ... 组件列表、动作列表、规则等
}
3.2 输出格式:JSONL + RFC 6902 JSON Patch
AI 输出的是逐行的 JSON Patch 操作,不是一次性输出完整 JSON。这种设计的优势:
- 流式渲染:每收到一行就能立即更新 UI
- 渐进式构建:先建树结构,再填充内容
{"op":"add","path":"/root","value":"main"}
{"op":"add","path":"/elements/main","value":{"type":"Card","props":{"title":"天气概览"},"children":["weather-info"]}}
{"op":"add","path":"/elements/weather-info","value":{"type":"Metric","props":{"label":"温度","value":28}}}
支持的操作:
| 操作 | 说明 | 示例 |
|---|---|---|
add | 添加节点 | {"op":"add","path":"/elements/card1","value":{...}} |
replace | 替换节点 | {"op":"replace","path":"/elements/card1/props/title","value":"新标题"} |
remove | 删除节点 | {"op":"remove","path":"/elements/card1"} |
move | 移动节点 | {"op":"move","from":"/elements/a","path":"/elements/b"} |
copy | 复制节点 | {"op":"copy","from":"/elements/a","path":"/elements/b"} |
3.3 服务端管线
在 Next.js API 路由中,完整的流式管线如下:
源码位置:examples/chat/app/api/generate/route.ts
export async function POST(req: Request) {
const body = await req.json();
const uiMessages: UIMessage[] = body.messages;
// 1. 转换消息格式
const modelMessages = await convertToModelMessages(uiMessages);
// 2. AI Agent 流式调用(catalog.prompt() 已内嵌到 agent instructions 中)
const result = await agent.stream({ messages: modelMessages });
// 3. pipeJsonRender 将 AI 流转换为 JSONL UI 流
const stream = createUIMessageStream({
execute: async ({ writer }) => {
writer.merge(pipeJsonRender(result.toUIMessageStream()));
},
});
// 4. 返回流式响应
return createUIMessageStreamResponse({ stream });
}
Agent 配置(examples/chat/lib/agent.ts):
export const agent = new ToolLoopAgent({
model: gateway(DEFAULT_MODEL),
instructions: AGENT_INSTRUCTIONS + explorerCatalog.prompt({
mode: "inline",
customRules: [
"NEVER use viewport height classes",
"Prefer Grid for side-by-side layouts",
// ... 更多规则
],
}),
tools: { getWeather, getGitHubRepo, webSearch, /* ... */ },
stopWhen: stepCountIs(5),
});
数据流
catalog.prompt(options)
↓
系统提示词字符串
↓ (与 Agent instructions 合并)
Agent.stream({ messages })
↓
AI 模型流式输出(文本 + JSONL 混合)
↓
pipeJsonRender(aiStream)
↓
提取并标记 JSONL 行为 SpecDataPart
↓
createUIMessageStreamResponse → HTTP SSE 响应
步骤 4:客户端解析——从 JSONL 到 Spec 树
客户端通过 useUIStream Hook 接收流并逐步构建 Spec 树。
源码位置:packages/react/src/hooks.ts
4.1 逐行解析
function parseLine(line: string): ParsedLine {
const parsed = JSON.parse(trimmed);
if (parsed.__meta === "usage") {
return { type: "usage", usage: { /* token 统计 */ } };
}
return { type: "patch", patch: parsed as JsonPatch };
}
4.2 应用 Patch 到 Spec
每收到一个 JSON Patch 操作,就增量更新 Spec:
function applyPatch(spec: Spec, patch: JsonPatch): Spec {
const newSpec = {
...spec,
elements: { ...spec.elements },
...(spec.state ? { state: { ...spec.state } } : {}),
};
switch (patch.op) {
case "add":
case "replace":
setSpecValue(newSpec, patch.path, patch.value);
break;
case "remove":
removeSpecValue(newSpec, patch.path);
break;
// ... move, copy, test
}
return newSpec;
}
Patch 路由规则:
| 路径 | 处理 |
|---|---|
/root | 设置根元素 key |
/state | 设置全局状态 |
/state/xxx | 设置状态中的某个路径 |
/elements/xxx | 添加/替换元素 |
/elements/xxx/props/yyy | 更新元素的某个 prop |
4.3 Spec 数据结构
interface Spec {
root: string; // 根元素 key
elements: Record<string, UIElement>; // 扁平化的元素映射
state?: Record<string, unknown>; // 全局状态
}
interface UIElement {
type: string; // 组件类型(来自 Catalog)
props: Record<string, unknown>; // 组件属性
children?: string[]; // 子元素 key 列表
visible?: VisibilityCondition; // 可见性条件
on?: Record<string, ActionBinding>; // 事件绑定
repeat?: { statePath: string }; // 列表重复
watch?: Record<string, ActionBinding>; // 状态监听器
}
关键设计决策:Spec 使用扁平化结构(key → element 映射),而非嵌套树。这让 JSON Patch 操作简单高效——只需要 add/remove/replace 单个 key,不需要深层路径遍历。
数据流
SSE 流事件到达
↓
parseLine() → JsonPatch 对象
↓
applyPatch(currentSpec, patch) → newSpec
↓
React setState(newSpec) → 触发重新渲染
↓
随着更多 patch 到达,Spec 树逐渐完整
步骤 5:渲染引擎——Spec 到真实组件
渲染引擎是 json-render 的核心运行时,它将 Spec 树递归渲染为真实的 UI 组件。
源码位置:packages/react/src/renderer.tsx
5.1 组件注册表
首先需要建立组件类型到 React 组件的映射:
const registry = defineRegistry({
components: {
Card: MyCardComponent,
Button: MyButtonComponent,
Metric: MyMetricComponent,
// ... 每种 type 对应一个真实组件
},
});
5.2 Provider 链
渲染引擎通过嵌套的 Context Provider 提供各种运行时能力:
StateProvider → 状态管理(get/set/update)
VisibilityProvider → 可见性条件求值
ValidationProvider → 规格验证
ActionProvider → 动作执行
FunctionsContext → $computed 函数注册
DirectivesContext → 自定义指令注册
5.3 元素渲染
核心渲染逻辑是一个递归组件 ElementRenderer:
const ElementRenderer = React.memo(function ElementRenderer({
element,
elementKey,
spec,
registry,
}) {
// 1. 可见性检查
const isVisible = useIsVisible(element.visible);
// 2. 获取注册的组件
const Component = registry[element.type];
// 3. 解析 props(处理动态值)
const resolvedProps = resolveElementProps(element.props, context);
// 4. 解析事件绑定
const emit = (event: string) => { /* 触发 action */ };
// 5. 处理子元素
const children = element.children?.map(childKey => {
const childElement = spec.elements[childKey];
return <ElementRenderer key={childKey} element={childElement} ... />;
});
// 6. 渲染组件
return (
<ElementErrorBoundary elementType={element.type}>
<Component element={element} emit={emit} {...resolvedProps}>
{children}
</Component>
</ElementErrorBoundary>
);
});
错误隔离:每个元素都被 ElementErrorBoundary 包裹,单个组件出错不会影响整棵树。
数据流
Spec { root, elements, state }
↓
找到 root element
↓
ElementRenderer(element)
├── 可见性检查 (evaluateVisibility)
├── Props 解析 (resolveElementProps)
├── 事件绑定解析 (resolveBindings)
├── Repeat 处理(如果有 repeat 字段)
└── 递归渲染 children
↓
<Component ...props> {children} </Component>
步骤 6:运行时引擎——动态值与状态系统
这是 json-render 最精妙的部分,它让 AI 生成的 UI 具备动态交互能力。
6.1 动态值系统(Props 解析)
源码位置:packages/core/src/props.ts
AI 生成的 props 可以是静态值,也可以是动态表达式。resolveElementProps 递归遍历所有 props,将表达式解析为实际值。
8 种表达式类型:
| 表达式 | 语法 | 说明 | 示例 |
|---|---|---|---|
$state | { $state: "/path" } | 读取全局状态 | { $state: "/weather/temp" } → 28 |
$item | { $item: "field" } | 读取 repeat 当前项 | { $item: "name" } → “Alice” |
$index | { $index: true } | 获取 repeat 当前索引 | { $index: true } → 0 |
$bindState | { $bindState: "/path" } | 双向绑定到全局状态 | 用于输入框 |
$bindItem | { $bindItem: "field" } | 双向绑定到 repeat 项 | 用于列表中的输入框 |
$cond | { $cond, $then, $else } | 条件表达式 | { $cond: { $state: "/ok" }, $then: "✓", $else: "✗" } |
$computed | { $computed: "fn", args } | 调用注册函数 | { $computed: "format", args: { v: { $state: "/val" } } } |
$template | { $template: "Hello ${/name}" } | 模板字符串插值 | { $template: "Hello ${/user/name}" } → “Hello Alice” |
export type PropExpression<T = unknown> =
| T // 静态值
| { $state: string } // 状态读取
| { $item: string } // 列表项读取
| { $index: true } // 列表索引
| { $bindState: string } // 双向绑定
| { $bindItem: string } // 列表项双向绑定
| { $cond: Condition; $then: Expr; $else: Expr } // 条件
| { $computed: string; args?: Record<string, unknown> } // 计算
| { $template: string }; // 模板
解析上下文:
interface PropResolutionContext extends VisibilityContext {
state: StateModel; // 全局状态
repeatBasePath?: string; // 当前 repeat 项的绝对路径(如 "/todos/0")
functions?: Record<string, ComputedFunction>; // $computed 可用函数
directives?: DirectiveRegistry; // 自定义指令
}
6.2 状态管理
源码位置:packages/core/src/state-store.ts
状态是不可变的,使用结构共享(structural sharing)高效更新:
// 不可变更新,只克隆路径上的节点
function immutableSetByPath(root: StateModel, path: string, value: unknown): StateModel {
const result = { ...root }; // 浅克隆根
let current = result;
for (const seg of segments) {
current[seg] = Array.isArray(child) ? [...child] : { ...child }; // 只克隆路径上的节点
current = current[seg];
}
current[lastSeg] = value;
return result; // 返回新对象,未修改的分支保持原引用
}
StateStore 接口(框架无关):
interface StateStore {
get: (path: string) => unknown; // JSON Pointer 读取
set: (path: string, value: unknown) => void; // JSON Pointer 写入 + 通知
update: (updates: Record<string, unknown>) => void; // 批量更新
subscribe: (listener: () => void) => () => void; // 订阅变更
getState: () => StateModel; // 获取完整状态快照
}
多框架适配:通过 createStoreAdapter 可以将 Zustand、Jotai、Redux、XState 等外部状态库适配为 StateStore 接口。
6.3 可见性条件
源码位置:packages/core/src/visibility.ts
支持复杂的条件组合来控制元素的显隐:
type VisibilityCondition =
| boolean // 始终显示/隐藏
| { $state: "/path", eq: "value" } // 状态比较
| { $state: "/count", gt: 5 } // 数值比较
| [cond1, cond2] // AND 组合
| { $and: [...] } // 显式 AND
| { $or: [...] }; // OR 组合
支持的比较运算符:eq、neq、gt、gte、lt、lte、not
// 示例:当 /submitted 为 true 且 /answer 等于 "correct" 时显示
"visible": [
{ "$state": "/submitted", "eq": true },
{ "$state": "/answer", "eq": "correct" }
]
6.4 动作系统
源码位置:packages/core/src/actions.ts
事件触发动作,动作参数支持动态值:
interface ActionBinding {
action: string; // 动作名称
params?: Record<string, DynamicValue>; // 参数(支持 $state)
confirm?: ActionConfirm; // 执行前确认
onSuccess?: ActionOnSuccess; // 成功回调
onError?: ActionOnError; // 失败回调
}
// 动态值:字面量 或 状态引用
type DynamicValue<T = unknown> = T | { $state: string };
使用示例:
{
"on": {
"press": {
"action": "setState",
"params": {
"statePath": "/submitted",
"value": true
}
}
}
}
6.5 Repeat(列表渲染)
UIElement 的 repeat 字段实现列表渲染:
{
"type": "Card",
"repeat": { "statePath": "/todos" },
"children": ["todo-text"]
}
当 repeat 存在时,渲染引擎会遍历 /todos 数组,为每个元素创建一个独立的渲染上下文,其中:
$item指向当前项$index指向当前索引$bindItem可以双向绑定到当前项的字段
数据流
UIElement.props 中的表达式
↓
resolveElementProps(props, context)
↓ (递归遍历每个 prop 值)
↓
┌─────────────────────────────────┐
│ PropExpression 类型分发 │
│ $state → store.get(path) │
│ $item → getByPath(item, key) │
│ $index → currentIndex │
│ $cond → evaluateVisibility │
│ → then / else 分支 │
│ $computed→ functions[name](args)│
│ $template→ 字符串插值 │
│ $bindState → get + 记录路径 │
│ $bindItem → get + 记录路径 │
│ 其他 → 字面值直接返回 │
└─────────────────────────────────┘
↓
解析后的 props 对象
↓
传递给 React/Svelte/Vue 组件
步骤 7:完整数据流总览
下面是用户发送一条消息到 UI 渲染完成的完整数据流:
┌──────────────────────────────────────────────────────┐
│ 用户发送消息 │
│ "今天北京天气如何?" │
└──────────────────────┬───────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ 客户端 POST /api/generate │
│ { messages: [...] } │
└──────────────────────┬───────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ 服务端 API Route │
│ 1. convertToModelMessages(uiMessages) │
│ 2. agent.stream({ messages }) │
│ → AI 调用 getWeather("北京") 工具 │
│ → 获取天气数据 │
│ → 生成 JSONL 输出 │
│ 3. pipeJsonRender(aiStream) → 提取 JSONL 行 │
│ 4. createUIMessageStreamResponse → SSE 流 │
└──────────────────────┬───────────────────────────────┘
↓ SSE 逐行推送
┌──────────────────────────────────────────────────────┐
│ AI 输出的 JSONL │
│ {"op":"add","path":"/root","value":"main"} │
│ {"op":"add","path":"/state/weather", │
│ "value":{"temp":28,"city":"北京",...}} │
│ {"op":"add","path":"/elements/main", │
│ "value":{"type":"Card","props":{ │
│ "title":"北京天气"},"children":["metric"]}} │
│ {"op":"add","path":"/elements/metric", │
│ "value":{"type":"Metric","props":{ │
│ "label":"温度","value":{ │
│ "$state":"/weather/temp"}}}}} │
└──────────────────────┬───────────────────────────────┘
↓ 逐行到达客户端
┌──────────────────────────────────────────────────────┐
│ useUIStream Hook │
│ 1. parseLine(line) → JsonPatch │
│ 2. applyPatch(spec, patch) → newSpec │
│ 3. React setState(newSpec) │
└──────────────────────┬───────────────────────────────┘
↓ 触发渲染
┌──────────────────────────────────────────────────────┐
│ Renderer 组件 │
│ 1. 读取 spec.root → 找到根元素 "main" │
│ 2. 查找 spec.elements["main"] │
│ 3. registry["Card"] → MyCardComponent │
│ 4. resolveElementProps: │
│ "title": "北京天气" (静态值,直接使用) │
│ 5. 处理 children: ["metric"] │
│ → 递归渲染 spec.elements["metric"] │
│ 6. resolveElementProps: │
│ "value": { $state: "/weather/temp" } │
│ → store.get("/weather/temp") → 28 │
│ → 解析为 28 │
│ 7. registry["Metric"] → MyMetricComponent │
│ → 渲染 <Metric label="温度" value={28} /> │
└──────────────────────┬───────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ 最终渲染结果 │
│ ┌──────────────────────┐ │
│ │ 📋 北京天气 │ │
│ │ 🌡️ 温度: 28°C │ │
│ └──────────────────────┘ │
└──────────────────────────────────────────────────────┘
复杂布局
json-render 的布局能力取决于你在 Catalog 中注册的布局组件。@json-render/shadcn 包预置了一套完整的布局组件,AI 通过 children 字段实现任意嵌套组合。
布局组件一览
| 组件 | 用途 | 关键 Props |
|---|---|---|
Stack | Flex 容器 | direction (horizontal/vertical), gap, align, justify |
Grid | 网格布局 | columns (1-6), gap |
Card | 内容卡片 | title, description, maxWidth, centered |
Tabs / TabContent | 标签页 | tabs, defaultValue, 支持 $bindState 绑定当前 tab |
Dialog | 模态框 | openPath 指向布尔状态路径,用 setState 控制 |
Drawer | 底部抽屉 | 同 Dialog,通过 openPath 控制 |
Collapsible | 可折叠区域 | title, defaultOpen |
Accordion | 手风琴 | items: [{title, content}], type (single/multiple) |
Separator | 分隔线 | orientation (horizontal/vertical) |
嵌套组合示例:两列表单布局
{"op":"add","path":"/root","value":"root"}
{"op":"add","path":"/elements/root","value":{
"type":"Card","props":{"title":"用户注册"},
"children":["form-grid"]
}}
{"op":"add","path":"/elements/form-grid","value":{
"type":"Grid","props":{"columns":2,"gap":"md"},
"children":["name-col","email-col"]
}}
{"op":"add","path":"/elements/name-col","value":{
"type":"Stack","props":{"direction":"vertical","gap":"sm"},
"children":["name-label","name-input"]
}}
{"op":"add","path":"/elements/email-col","value":{
"type":"Stack","props":{"direction":"vertical","gap":"sm"},
"children":["email-label","email-input"]
}}
渲染效果:
┌─────────────────────────────────┐
│ 用户注册 │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ 姓名 │ │ 邮箱 │ │
│ │ [输入框] │ │ [输入框] │ │
│ └──────────┘ └──────────┘ │
└─────────────────────────────────┘
条件显隐实现动态布局
用 visible 字段控制元素的显隐,可以实现”选择’其他’才显示额外输入框”等动态行为:
{
"type": "TextInput",
"props": { "label": "请说明", "value": { "$bindState": "/otherReason" } },
"visible": { "$state": "/reason", "eq": "other" }
}
Tabs 动态切换
{"op":"add","path":"/state/activeTab","value":"overview"}
{"op":"add","path":"/elements/tabs","value":{
"type":"Tabs","props":{
"defaultValue":"overview",
"tabs":[
{"value":"overview","label":"概览"},
{"value":"detail","label":"详情"}
]
},
"children":["tab-overview","tab-detail"]
}}
{"op":"add","path":"/elements/tab-overview","value":{
"type":"TabContent","props":{"value":"overview"},
"children":["overview-text"]
}}
{"op":"add","path":"/elements/tab-detail","value":{
"type":"TabContent","props":{"value":"detail"},
"children":["detail-text"]
}}
表单交互
表单交互是 json-render 表达式系统的核心应用场景,通过 $bindState 双向绑定 + checks 验证 + 内置/自定义动作实现完整的表单能力。
双向绑定($bindState)
表单组件通过 $bindState 实现双向绑定——用户输入自动写入状态,状态变化自动更新输入框:
{
"type": "TextInput",
"props": {
"label": "姓名",
"value": { "$bindState": "/form/name" },
"placeholder": "请输入姓名"
}
}
数据流:
用户在输入框中输入 "张三"
↓
$bindState 解析到绑定路径 "/form/name"
↓
store.set("/form/name", "张三")
↓
状态更新通知所有订阅者
↓
resolveElementProps 重新解析 → value = "张三"
↓
输入框显示 "张三"
表单组件
@json-render/shadcn 预置了完整的表单组件(源码位置:packages/shadcn/src/catalog.ts):
| 组件 | 绑定字段 | 说明 |
|---|---|---|
Input | value ($bindState) | 文本/邮箱/密码/数字输入 |
Textarea | value ($bindState) | 多行文本 |
Select | value ($bindState) | 下拉选择 |
Radio | value ($bindState) | 单选按钮组 |
Checkbox | checked ($bindState) | 复选框 |
Switch | checked ($bindState) | 开关 |
Slider | value ($bindState) | 滑块 |
DropdownMenu | value ($bindState) | 下拉菜单 |
ButtonGroup | selected ($bindState) | 分段按钮组 |
表单验证
每个表单组件支持 checks 和 validateOn 属性(源码位置:packages/react/src/contexts/validation.tsx):
{
"type": "Input",
"props": {
"label": "邮箱",
"name": "email",
"type": "email",
"value": { "$bindState": "/form/email" },
"checks": [
{ "type": "required", "message": "邮箱不能为空" },
{ "type": "email", "message": "请输入有效的邮箱地址" },
{ "type": "minLength", "message": "至少5个字符", "args": { "min": 5 } }
],
"validateOn": "blur"
}
}
checks:验证规则数组,每条包含type、message、可选argsvalidateOn:触发时机blur— 失焦时验证(默认)change— 值变化时验证submit— 提交时验证
内置的 validateForm 动作可以一次性触发所有注册字段的验证,并将结果写入状态:
{
"type": "Button",
"props": { "label": "提交", "variant": "primary" },
"on": {
"press": {
"action": "validateForm",
"params": { "statePath": "/formValidation" }
}
}
}
验证结果结构:
{
"valid": false,
"errors": {
"/form/email": ["邮箱不能为空"],
"/form/password": ["至少6位"]
}
}
结合 visible 条件显示错误提示:
{
"type": "Alert",
"props": { "title": "表单有误", "type": "error" },
"visible": { "$state": "/formValidation/valid", "eq": false }
}
内置动作
json-render 预置了以下表单相关动作,无需注册自定义 handler(源码位置:packages/react/src/contexts/actions.tsx):
| 动作 | 参数 | 说明 |
|---|---|---|
setState | { statePath, value } | 设置状态值 |
pushState | { statePath, value, clearStatePath? } | 向数组追加元素 |
removeState | { statePath, index } | 按索引删除数组元素 |
validateForm | { statePath? } | 触发表单验证 |
push | { screen } | 导航到新屏幕(屏幕导航栈) |
pop | 无 | 返回上一屏幕 |
pushState 支持在 value 中使用动态值($state 引用、$id 自动生成唯一 ID):
{
"action": "pushState",
"params": {
"statePath": "/todos",
"value": { "text": { "$state": "/newTodo" }, "done": false, "id": "$id" },
"clearStatePath": "/newTodo"
}
}
自定义动作
内置动作不够用时(比如调 API、支付),可以在 Catalog 中注册自定义 handler:
// 1. 在 Catalog 中声明动作的参数 schema
const catalog = defineCatalog(schema, {
actions: {
submitLogin: {
params: z.object({
email: z.string(),
password: z.string(),
}),
},
},
});
// 2. 在渲染器中注册 handler
<ActionProvider handlers={{
submitLogin: async ({ params, setState }) => {
const result = await api.login(params.email, params.password);
setState("/user", result.user);
},
}}>
动作还支持确认对话框、成功/失败回调链:
{
"action": "deleteAccount",
"confirm": {
"title": "确认删除",
"message": "此操作不可撤销,确定要删除账号吗?",
"variant": "danger"
},
"onSuccess": { "action": "push", "params": { "screen": "goodbye" } },
"onError": { "set": { "/errorMessage": "删除失败,请重试" } }
}
完整表单示例
一个带验证和提交的登录表单,AI 生成的完整 JSONL:
{"op":"add","path":"/root","value":"form-card"}
{"op":"add","path":"/state/form/email","value":""}
{"op":"add","path":"/state/form/password","value":""}
{"op":"add","path":"/state/form/submitted","value":false}
{"op":"add","path":"/elements/form-card","value":{
"type":"Card","props":{"title":"登录","maxWidth":"sm","centered":true},
"children":["form-stack"]
}}
{"op":"add","path":"/elements/form-stack","value":{
"type":"Stack","props":{"direction":"vertical","gap":"md"},
"children":["email-input","password-input","submit-btn","success-msg"]
}}
{"op":"add","path":"/elements/email-input","value":{
"type":"Input","props":{
"label":"邮箱","name":"email","type":"email",
"value":{"$bindState":"/form/email"},
"placeholder":"you@example.com",
"checks":[
{"type":"required","message":"请输入邮箱"},
{"type":"email","message":"邮箱格式不正确"}
],
"validateOn":"blur"
}
}}
{"op":"add","path":"/elements/password-input","value":{
"type":"Input","props":{
"label":"密码","name":"password","type":"password",
"value":{"$bindState":"/form/password"},
"placeholder":"请输入密码",
"checks":[
{"type":"required","message":"请输入密码"},
{"type":"minLength","message":"至少6位","args":{"min":6}}
],
"validateOn":"blur"
}
}}
{"op":"add","path":"/elements/submit-btn","value":{
"type":"Button","props":{"label":"登录","variant":"primary"},
"on":{"press":{
"action":"validateForm",
"params":{"statePath":"/formValidation"},
"onSuccess":{"action":"submitLogin"}
}}
}}
{"op":"add","path":"/elements/success-msg","value":{
"type":"Alert","props":{"title":"登录成功","type":"success"},
"visible":{"$state":"/form/submitted","eq":true}
}}
渲染效果:
┌──────────────────────┐
│ 登录 │
│ │
│ 邮箱 │
│ [you@example.com ] │
│ │
│ 密码 │
│ [•••••••• ] │
│ │
│ [登录] │
│ │
│ ┌──────────────────┐ │ ← 仅 submitted=true 时显示
│ │ ✓ 登录成功 │ │
│ └──────────────────┘ │
└──────────────────────┘
自定义后端接入
json-render 的服务端只做一件事——把 AI 的流式输出转成 JSONL 推给客户端。你可以自由选择 AI 提供商、后端框架和传输协议。
接入方式
方式 1:使用 AI SDK(推荐)
适用于 Next.js、SvelteKit 等支持 ai SDK 的框架,只需 pipeJsonRender 做流式转换:
import { pipeJsonRender } from "@json-render/core";
// 可以用任何 AI 提供商:OpenAI、Anthropic、Google、Groq...
const result = await agent.stream({ messages });
const stream = createUIMessageStream({
execute: async ({ writer }) => {
writer.merge(pipeJsonRender(result.toUIMessageStream()));
},
});
return createUIMessageStreamResponse({ stream });
方式 2:使用 createSpecStreamCompiler(任意后端)
适用于不用 ai SDK 的场景(Python FastAPI、Go server、Spring Boot 等):
import { createSpecStreamCompiler } from "@json-render/core";
const compiler = createSpecStreamCompiler();
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const { result, newPatches } = compiler.push(decoder.decode(value));
if (newPatches.length > 0) {
setSpec(result); // 更新 React 状态
}
}
方式 3:使用 createMixedStreamParser(文本 + JSONL 混合)
聊天场景中 AI 输出混合了文本和 JSONL:
import { createMixedStreamParser } from "@json-render/core";
const parser = createMixedStreamParser({
onText: (text) => appendToMessage(text),
onPatch: (patch) => applySpecPatch(spec, patch),
});
for await (const chunk of stream) {
parser.push(chunk);
}
parser.flush();
接入要求
唯一不能换的是客户端的 Spec 格式(JSONL + RFC 6902 JSON Patch)——这是框架的基础协议。其他全部可替换:
- AI 提供商:OpenAI、Anthropic、Google、本地模型(Ollama)等
- 后端框架:Next.js、Express、Fastify、Python FastAPI、Go net/http 等
- 传输协议:SSE、WebSocket、长轮询等
- 状态库:内置 store、Zustand、Jotai、Redux(通过
createStoreAdapter适配)
数据联动(级联选择)
省市区级联联动是表单场景的典型需求,通过 watch + $computed + $bindState 三者配合实现。
核心机制
省份 Select (watch: "/form/province")
│ 用户选择"浙江"
↓ watch 检测到 /form/province 变化
├─ action 1: $computed("citiesFor", {province}) → setState("/cities", [...])
└─ action 2: setState("/form/city", "") → 清空城市选择
→ 清空区县选择
城市 Select (watch: "/form/city")
│ 用户选择"杭州"
↓ watch 检测到 /form/city 变化
├─ action 1: $computed("districtsFor", {city}) → setState("/districts", [...])
└─ action 2: setState("/form/district", "") → 清空区县选择
区县 Select
options: { $state: "/districts" }
value: { $bindState: "/form/district" }
watch 的工作原理
UIElement 的 watch 字段监听指定状态路径的变化,变化时按顺序触发绑定的动作(源码位置:packages/react/src/renderer.tsx 第 282 行):
{
"type": "Select",
"props": { "options": [...], "value": { "$bindState": "/form/province" } },
"watch": {
"/form/province": [
{ "action": "setState", "params": { "statePath": "/cities", "value": [...] } },
{ "action": "setState", "params": { "statePath": "/form/city", "value": "" } }
]
}
}
watch的 key 是状态路径,值是一个或一组ActionBinding- 路径值变化时按顺序执行所有绑定动作
- 动作参数支持动态值(
$state、$computed等) - 初始挂载时不触发,只在后续变化时触发
方式 A:watch + $computed(同步,本地数据)
数据量小或可内嵌时,用 $computed 函数同步计算:
1. Catalog 声明函数(告诉 AI 有哪些函数可用)
// lib/render/catalog.ts
export const catalog = defineCatalog(schema, {
components: { ...shadcnComponentDefinitions },
functions: {
citiesForProvince: {
description: "根据省份名称返回城市名称数组",
},
districtsForCity: {
description: "根据城市名称返回区县名称数组",
},
},
});
2. 前端注册计算函数
// lib/render/registry.ts
const cityData: Record<string, string[]> = {
浙江: ["杭州", "宁波", "温州", "嘉兴", "绍兴"],
江苏: ["南京", "苏州", "无锡", "常州", "扬州"],
广东: ["广州", "深圳", "珠海", "佛山", "东莞"],
};
const districtData: Record<string, string[]> = {
杭州: ["西湖区", "上城区", "拱墅区", "滨江区", "余杭区"],
南京: ["玄武区", "秦淮区", "鼓楼区", "建邺区", "栖霞区"],
广州: ["天河区", "越秀区", "海珠区", "荔湾区", "番禺区"],
};
export const computedFunctions: Record<string, ComputedFunction> = {
citiesForProvince: (args) => cityData[args.province as string] ?? [],
districtsForCity: (args) => districtData[args.city as string] ?? [],
};
3. 渲染时传入函数
<JSONUIProvider registry={registry} functions={computedFunctions}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
4. AI 输出的 JSONL
{"op":"add","path":"/root","value":"card"}
{"op":"add","path":"/state/form/province","value":""}
{"op":"add","path":"/state/form/city","value":""}
{"op":"add","path":"/state/form/district","value":""}
{"op":"add","path":"/state/cities","value":[]}
{"op":"add","path":"/state/districts","value":[]}
{"op":"add","path":"/elements/card","value":{
"type":"Card","props":{"title":"收货地址"},
"children":["stack"]
}}
{"op":"add","path":"/elements/stack","value":{
"type":"Stack","props":{"direction":"vertical","gap":"md"},
"children":["province-select","city-select","district-select","preview"]
}}
{"op":"add","path":"/elements/province-select","value":{
"type":"Select",
"props":{
"label":"省份","name":"province",
"options":["浙江","江苏","广东"],
"placeholder":"请选择省份",
"value":{"$bindState":"/form/province"}
},
"watch":{
"/form/province":[
{"action":"setState","params":{
"statePath":"/cities",
"value":{"$computed":"citiesForProvince","args":{"province":{"$state":"/form/province"}}}
}},
{"action":"setState","params":{"statePath":"/form/city","value":""}},
{"action":"setState","params":{"statePath":"/form/district","value":""}}
]
}
}}
{"op":"add","path":"/elements/city-select","value":{
"type":"Select",
"props":{
"label":"城市","name":"city",
"options":{"$state":"/cities"},
"placeholder":"请先选择省份",
"value":{"$bindState":"/form/city"}
},
"visible":{"$state":"/form/province","neq":""},
"watch":{
"/form/city":[
{"action":"setState","params":{
"statePath":"/districts",
"value":{"$computed":"districtsForCity","args":{"city":{"$state":"/form/city"}}}
}},
{"action":"setState","params":{"statePath":"/form/district","value":""}}
]
}
}}
{"op":"add","path":"/elements/district-select","value":{
"type":"Select",
"props":{
"label":"区县","name":"district",
"options":{"$state":"/districts"},
"placeholder":"请先选择城市",
"value":{"$bindState":"/form/district"}
},
"visible":{"$state":"/form/city","neq":""}
}}
{"op":"add","path":"/elements/preview","value":{
"type":"Text",
"props":{
"text":{"$template":"收货地址:${/form/province} ${/form/city} ${/form/district}"},
"variant":"muted"
},
"visible":[
{"$state":"/form/province","neq":""},
{"$state":"/form/city","neq":""},
{"$state":"/form/district","neq":""}
]
}}
方式 B:通用 callApi 动作(异步,从后端 API 获取)
数据量大或实时获取时,注册一个通用的 callApi handler,AI 传 URL 和参数即可。
1. Catalog 声明
actions: {
callApi: {
params: z.object({
url: z.string(),
method: z.enum(["GET", "POST"]).optional(),
body: z.record(z.unknown()).optional(),
statePath: z.string().describe("将结果写入此状态路径"),
}),
description: "调用后端 API 并将结果写入状态",
},
},
2. 前端注册一次(所有接口通用)
const actionHandlers = {
callApi: async ({ url, method, body, statePath }) => {
const res = await fetch(url, {
method: method ?? "GET",
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json();
if (statePath) {
setState(statePath, data);
}
return data;
},
};
3. Prompt 中告诉 AI 接口地址
catalog.prompt({
mode: "inline",
customRules: [
"省份列表: GET /api/provinces → string[]",
"城市列表: GET /api/cities?province=xxx → string[]",
"区县列表: GET /api/districts?city=xxx → string[]",
],
})
也可以在用户对话中动态告诉 AI:“省份接口是 GET /api/a/b/c”,AI 会自动生成对应的 callApi 调用。
4. AI 输出的 JSONL(使用 callApi)
{"op":"add","path":"/elements/province-select","value":{
"type":"Select",
"props":{
"options":{"$state":"/provinces"},
"value":{"$bindState":"/form/province"}
},
"watch":{
"/form/province":[{
"action":"callApi",
"params":{
"url":{"$template":"/api/cities?province=${/form/province}"},
"method":"GET",
"statePath":"/cities"
}
},{
"action":"setState","params":{"statePath":"/form/city","value":""}
}]
}
}}
{"op":"add","path":"/elements/city-select","value":{
"type":"Select",
"props":{
"options":{"$state":"/cities"},
"value":{"$bindState":"/form/city"}
},
"watch":{
"/form/city":[{
"action":"callApi",
"params":{
"url":{"$template":"/api/districts?city=${/form/city}"},
"method":"GET",
"statePath":"/districts"
}
},{
"action":"setState","params":{"statePath":"/form/district","value":""}
}]
}
}}
完整数据流图
用户选择"浙江"
↓
$bindState → store.set("/form/province", "浙江")
↓
watch 检测到 /form/province 变化
↓ (按顺序执行绑定动作)
├── action 1: $computed("citiesForProvince", {province: "浙江"})
│ ↓
│ ["杭州","宁波","温州","嘉兴","绍兴"]
│ ↓
│ setState("/cities", ["杭州","宁波","温州","嘉兴","绍兴"])
│ ↓
│ 城市 Select 的 options ($state "/cities") 自动更新
│
├── action 2: setState("/form/city", "")
│ ↓
│ 城市选择清空
│
└── action 3: setState("/form/district", "")
↓
区县选择清空
用户选择"杭州"
↓
$bindState → store.set("/form/city", "杭州")
↓
watch 检测到 /form/city 变化
├── action 1: $computed("districtsForCity", {city: "杭州"})
│ ↓
│ setState("/districts", ["西湖区","上城区","拱墅区","滨江区","余杭区"])
│
└── action 2: setState("/form/district", "")
↓
区县选择清空
用户选择"西湖区"
↓
$bindState → store.set("/form/district", "西湖区")
↓
preview 元素 visible 条件全部满足 → 显示
$template 解析: "收货地址:浙江 杭州 西湖区"
动作机制对比
| 方面 | $computed 函数 | 自定义 handler | 通用 callApi |
|---|---|---|---|
| 数据来源 | 前端本地 | 任意(API、SDK 等) | 后端 API |
| 同步/异步 | 同步 | 都可以 | 异步 |
| 前端代码量 | 每个函数写一次 | 每个 handler 写一次 | 只写一次 |
| AI 需要知道 | 函数名和参数 | 动作名和参数 | API URL 和参数 |
| 适用场景 | 固定小数据、格式化 | 复杂逻辑、第三方 SDK | 大部分 API 调用 |
典型比例:80% 的动作用内置动作(setState/pushState),15% 用通用 callApi,5% 需要特定 handler(支付、第三方 SDK 等)。前端实际上只需要注册一个 callApi,后续所有新接口只需在 Prompt 或对话里告诉 AI 地址即可。
多框架支持
json-render 的核心逻辑在 @json-render/core 中,与框架无关。框架特定的包只负责:
| 包 | 职责 |
|---|---|
@json-render/core | Schema、Catalog、Spec 类型、Props 解析、状态管理、动作系统 |
@json-render/react | React 渲染器、Hooks(useUIStream、useChatUI)、Context Providers |
@json-render/svelte | Svelte 渲染器、Stores |
@json-render/vue | Vue 渲染器、Composables |
@json-render/solid | Solid 渲染器、Signals |
状态管理也有多个适配包:@json-render/jotai、@json-render/redux、@json-render/react-state。
关键设计亮点
- JSONL 流式传输:使用 RFC 6902 JSON Patch 逐行构建 UI,用户可以看到界面逐步填充
- 扁平化 Spec 结构:key-value 映射而非嵌套树,让 JSON Patch 操作简单高效
- 不可变状态 + 结构共享:状态更新只克隆修改路径上的节点,性能高效
- 表达式系统:8 种动态值类型让 AI 生成的 UI 具备完整的交互能力
- 错误隔离:每个元素独立捕获错误,单个组件崩溃不影响全局
- 框架无关核心:核心逻辑与 UI 框架解耦,支持 React、Svelte、Vue、Solid 等
本文基于 json-render v0.19.0 源码分析,commit 91833e9。