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 是整个系统的起点,它定义了两件事:

  1. Spec 结构:AI 输出的 UI 树长什么样
  2. 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.tsdefineCatalog 函数)

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 提供两个核心能力:

  1. catalog.prompt():生成 AI 系统提示词
  2. 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 组合

支持的比较运算符:eqneqgtgteltltenot

// 示例:当 /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
StackFlex 容器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):

组件绑定字段说明
Inputvalue ($bindState)文本/邮箱/密码/数字输入
Textareavalue ($bindState)多行文本
Selectvalue ($bindState)下拉选择
Radiovalue ($bindState)单选按钮组
Checkboxchecked ($bindState)复选框
Switchchecked ($bindState)开关
Slidervalue ($bindState)滑块
DropdownMenuvalue ($bindState)下拉菜单
ButtonGroupselected ($bindState)分段按钮组

表单验证

每个表单组件支持 checksvalidateOn 属性(源码位置: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:验证规则数组,每条包含 typemessage、可选 args
  • validateOn:触发时机
    • 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/coreSchema、Catalog、Spec 类型、Props 解析、状态管理、动作系统
@json-render/reactReact 渲染器、Hooks(useUIStream、useChatUI)、Context Providers
@json-render/svelteSvelte 渲染器、Stores
@json-render/vueVue 渲染器、Composables
@json-render/solidSolid 渲染器、Signals

状态管理也有多个适配包:@json-render/jotai@json-render/redux@json-render/react-state


关键设计亮点

  1. JSONL 流式传输:使用 RFC 6902 JSON Patch 逐行构建 UI,用户可以看到界面逐步填充
  2. 扁平化 Spec 结构:key-value 映射而非嵌套树,让 JSON Patch 操作简单高效
  3. 不可变状态 + 结构共享:状态更新只克隆修改路径上的节点,性能高效
  4. 表达式系统:8 种动态值类型让 AI 生成的 UI 具备完整的交互能力
  5. 错误隔离:每个元素独立捕获错误,单个组件崩溃不影响全局
  6. 框架无关核心:核心逻辑与 UI 框架解耦,支持 React、Svelte、Vue、Solid 等

本文基于 json-render v0.19.0 源码分析,commit 91833e9。