JSON Schema驱动的Generative UI:React组件动态渲染实战 1. 整体设计与思路拆解1.1 它到底解决什么问题最近在做 AI 应用落地发现一个特别容易被低估的环节LLM 已经把意图理解清楚了该调的接口也调了但最后一步——把动态内容变成用户界面——反而成了最容易翻车的地方。你当然可以用预设模板AI 识别出“天气查询”前端就渲染天气卡片。但真实场景远比这个复杂。用户让 AI “做一个三栏对比表格附带两个图表再加一个折叠面板”模板怎么写模板写不出来因为组合是无限的。Vercel Labs 的 json-render 就是冲着这个场景来的。它的思路很直接用 JSON 描述界面结构前端拿到 JSON 后动态渲染成 React 组件树。LLM 只负责生成 JSON界面渲染完全由前端运行时接管。这就是 Generative UI 的工程化雏形——不是让 AI 写代码而是让 AI 写数据。这个方案适合谁来参考如果你在做 LLM 原生应用、Agent 工作台、低代码页面动态化或者想实现 server-driven UI服务端下发界面配置json-render 这套设计都会很有价值。它要解决的核心命题不是“能不能把 JSON 变成组件”——这个三天就能写出来——而是这个过程中怎么保证类型安全、怎么应对非法结构、怎么让业务组件无缝注入、怎么在不可信输入面前不出安全事故。1.2 为什么是 JSON而不是代码生成一开始我也想过另一条路让 LLM 直接生成 TSX 代码片段前端动态编译执行。方向上听着更“智能”但工程上全是坑。第一是安全。动态执行 LLM 生成的代码等于把 XSS 从“看看能不能绕过过滤”升级成“欢迎直接注入”。你需要沙箱、需要白名单、需要处理各种边界情况。而 JSON 是数据天然没有执行能力只要做好 schema 校验风险就低一个量级。第二是可控性和可迭代性。代码生成结果是字符串不好做结构化校验JSON 则可以用 JSON Schema 或 zod 做严格约束。渲染出错时你可以把校验错误信息原样返回给 LLM 让它自我修正这个“校验反馈闭环”是 Generative UI 工程化里最核心的机制之一。第三是跨端能力。React、Vue、小程序、iOS、Android 都能消费同一份 JSON 描述只是各自实现渲染层。json-render 的价值不只是“把 JSON 渲染成 React”而是定义了一种跨端通用的界面协议。如果你以后要接移动端核心 schema 不用改。需要强调一点json-render 的设计并不是传统意义上“数据驱动 UI”的简单复刻。它的关键是引入了一层“组件注册表”registry让核心渲染器与具体业务组件完全解耦。渲染器不认识任何业务组件只认识name字符串。这种面向协议的设计让它天然适合 AI 生成场景——AI 只需要知道你注册了哪些组件以及它们的 props 长什么样。1.3 三条核心设计原则我读源码时花了很长时间才从“它写了什么”里跳出来看到“它为什么这么写”。概括下来是三条原则。原则一结构即数据。界面是一棵树每个节点要么是基础节点文本、标题、列表要么是组件节点引用了注册表里的组件。树本身就是纯 JSON可以被序列化、存储、回放。这意味着 AI 生成的结果可以被记录、审计出问题可以直接定位到具体节点。原则二渲染与组件解耦。核心渲染器只做一件事遍历节点找到对应组件把 props 传进去。它不认识业务组件不直接依赖任何 UI 库。新增一种组件不需要改渲染器只需要注册。这个边界让核心代码可以保持稳定和极度精简。原则三约束优先于能力。很多人以为 Generative UI 应该“什么都能生成”恰恰相反真正工程化的思路是“限制能生成什么”。组件注册表本身就是约束props 的 schema 定义也是约束。约束越强LLM 生成的稳定性越高渲染成功率越好。行业内现在普遍认同 2026 年是智能体从概念演示走向工程化落地的分水岭而工程化的意思就是给想象力装上可控的轨道。2. 核心细节解析与实操要点2.1 数据模型UI 描述即树json-render 里最难理解也最值得抄的数据结构是它的节点类型定义。我用 TypeScript 重新表达一下源码里的核心模型type SchemaNode | { type: text; value: string } | { type: heading; level: 1 | 2 | 3; children: SchemaNode[] } | { type: paragraph; children: SchemaNode[] } | { type: list; ordered: boolean; items: SchemaNode[] } | { type: component; name: string; props: Recordstring, unknown; children?: SchemaNode[] } | { type: fragment; key: string; children: SchemaNode[] }这套设计的精巧之处是把“基础结构”和“业务组件”做了明确的区分。text、heading、list这些是平台级节点任何场景都通用由渲染器原生实现component节点则只携带name和props渲染器不认识它只负责去找注册表匹配。这里有个关键设计取舍props 的类型是Recordstring, unknown。为什么不用具体的 TypeScript 类型因为 JSON 从 network 或 LLM 过来时本身就是 unknown。源码选择在运行时再做校验而不是假装静态类型能保护运行时数据。这是很务实的做法——你可以用 zod 来校验每个组件的 props但核心渲染器本身保持宽容。另外一个容易忽略的细节是key字段。递归渲染列表时没有稳定 key 会导致 React 状态错乱。我会在第四章细讲这个坑这里先记住结论凡是 children 数组里的节点都应该生成或要求携带唯一 key。2.2 组件注册表整个库的心脏注册表是 json-render 最值得抄的部分。它的本质非常简单一个name - component definition的映射表。export interface ComponentDefinition { name: string component: React.ComponentTypeany schema?: JSONSchema // 用于校验 props loading?: React.ComponentType fallback?: React.ComponentType } export class Registry { private items new Mapstring, ComponentDefinition() register(defs: ComponentDefinition[]) { for (const def of defs) { if (this.items.has(def.name)) { throw new Error([json-render] Duplicate component: ${def.name}) } this.items.set(def.name, def) } } get(name: string): ComponentDefinition | undefined { return this.items.get(name) } }为什么用字符串名字寻址而不是直接传组件引用两个原因。第一LLM 是文本生成模型字符串是它最擅长输出的 token 序列。你让 AI 输出一个组件引用它是做不到的但你让它输出chart或form稳定性极高。字符串名字就是 AI 与运行时之间的协议。第二延迟解析。在实际场景里组件包可能是异步加载的code splittingname - definition的映射天然支持懒加载——注册表里可以先存一个 loader 函数真正渲染时才加载组件代码。源码里虽然没做微内核级别的动态加载但这个数据结构完全能承载这样的扩展。自定义组件时最容易被忽略的是fallback。AI 生成的 JSON 里可能引用了未注册的组件此时不要直接抛异常把整个界面炸掉而是渲染一个统一的“未找到组件”占位并附带错误信息。我实际落地时还会在 fallback 里显示一个“重试”按钮把错误上下文返回给 LLM让 AI 自己修正 schema。2.3 渲染器递归才是核心算法渲染器是 json-render 的运行时主循环。核心代码可以精简为这样一个递归函数function JsonRender({ root, registry }: { root: SchemaNode | SchemaNode[]; registry: Registry }) { if (Array.isArray(root)) { return {root.map((node, i) renderNode(node, registry, arr-${i}))}/ } return {renderNode(root, registry, root)}/ } function renderNode(node: SchemaNode, registry: Registry, key: string): React.ReactNode { switch (node.type) { case text: return node.value case heading: return Heading level{node.level}{node.children.map((c, i) renderNode(c, registry, ${key}-h-${i}))}/Heading case paragraph: return p{node.children.map((c, i) renderNode(c, registry, ${key}-p-${i}))}/p case list: return List ordered{node.ordered}{node.items.map((c, i) ListItem key{${key}-li-${i}}{renderNode(c, registry, ${key}-li-${i})}/ListItem)}/List case component: { const def registry.get(node.name) if (!def) return MissingComponent name{node.name} / const Comp def.component const children node.children?.map((c, i) renderNode(c, registry, ${key}-c-${i})) const props { ...node.props, key, children } return Comp {...props} / } default: return UnknownNode node{node} / } }注意几个细节。第一这个函数是纯函数——同样的 node 输入必然得到同样的输出。这让它天然适合 SSR 和测试。渲染期间不产生副作用副作用应该放在事件回调里。第二key生成使用的是从根到节点的完整路径比如root-c-0-c-1。这条路是我踩过几次坑后刻意采用的。如果使用数组 index 直接做 key当 AI 生成内容前后两次发生变化时React 会复用错误组件的状态。路径 key 可以保证每个节点的身份是稳定的。第三递归要有边界。AI 生成的 JSON 完全可能有环虽然 JSON 本身不支持循环引用但嵌套深度可以非常大。源码里建议加一个maxDepth配置超过深度上限时渲染一个截断提示而不是让 JS 引擎栈溢出。2.4 action让生成的界面“能动”如果只是展示数据这个库的价值会少一大半。真正的 Generative UI 必须支持交互——用户点击按钮、提交表单、切换 Tab这些动作要能触发后续逻辑。json-render 对交互的处理方式很优雅UI 只负责声明意图不负责实现。交互通过action机制透传type ComponentNode { type: component name: string props: Recordstring, unknown children?: SchemaNode[] // 交互描述 actions?: { event: onClick | onSubmit | onChange handler: string payload?: Recordstring, unknown }[] }外层调用时JsonRender接受一个onAction回调统一处理所有事件JsonRender root{uiSchema} registry{registry} onAction{async ({ handler, payload }) { // 这里路由到对应的业务逻辑 const result await handleAction(handler, payload) setUiSchema(result) // 用新的 schema 更新界面 }} /这个设计让我想到一个比喻界面是“提案”业务方是“决策者”。UI 组件只报告“用户点了按钮、附带了这些数据”具体做什么由上层逻辑决定。这样做的好处是actions 可以被序列化、被记录、被回放整个交互链路是可追踪的而且 Action 层可以做成不受信任的 UI 与受信任的后端逻辑之间的安全边界。3. 实操过程与核心环节实现3.1 从零搭建最小骨架纸上谈兵没有意义我带你完整走一遍实操过程。目标是搭建一个最简、可运行的 json-render 核心不依赖任何 UI 库。第一步创建项目并安装依赖npm create vitelatest json-render-demo -- --template react-ts cd json-render-demo npm install zod第二步定义 schema 类型。为了聚焦核心我先用上面说到的 SchemaNode 类型。实际为了演示定位我加一个简单的卡片组件节点type SchemaNode | { type: text; value: string } | { type: component; name: string; props: Recordstring, unknown; children?: SchemaNode[] }第三步实现最小注册表和渲染器。为了讲解清晰我暂时把渲染器和注册表放在一个文件里// json-render.tsx import React from react interface ComponentDefinition { name: string component: React.ComponentTypeany } class Registry { private items new Mapstring, ComponentDefinition() register(def: ComponentDefinition) { this.items.set(def.name, def) } get(name: string) { return this.items.get(name) } } function renderNode(node: SchemaNode, registry: Registry, key: string): React.ReactNode { switch (node.type) { case text: return node.value as React.ReactNode case component: { const def registry.get(node.name) if (!def) return span key{key} style{{ color: red }}[未知组件: {node.name}]/span const Comp def.component const children node.children?.map((c, i) renderNode(c, registry, ${key}-${i})) return Comp key{key} {...node.props}{children}/Comp } } } export function JsonRender({ root, registry }: { root: SchemaNode | SchemaNode[]; registry: Registry }) { const nodes Array.isArray(root) ? root : [root] return {nodes.map((n, i) renderNode(n, registry, root-${i}))}/ }第四步注册一个真实的业务组件并渲染// App.tsx import { JsonRender, Registry } from ./json-render function UserCard({ name, email, children }: any) { return ( div style{{ border: 1px solid #ddd, borderRadius: 8, padding: 12 }} h3{name}/h3 p{email}/p {children} /div ) } const registry new Registry() registry.register({ name: UserCard, component: UserCard }) const uiSchema: SchemaNode[] [ { type: component, name: UserCard, props: { name: 张三, email: zhangsanexample.com } }, { type: text, value: 欢迎使用 json-render } ] function App() { return JsonRender root{uiSchema} registry{registry} / }到这里一个可运行的 json-render 就完成了。运行npm run dev你能看到卡片和文本被正确渲染。整个过程不到半小时核心代码只有几十行。3.2 接入真实业务组件与 props 校验骨架搭好后下一步是把这套东西接到你的业务里。真实业务组件通常有强类型 propsAI 生成的 JSON 里字段类型很可能不匹配。这时候 zod 就派上用场了。每个组件注册时带一个 props 校验器渲染时先校验再传值import { z } from zod const UserCardSchema z.object({ name: z.string().min(1), email: z.string().email().default(未填写), level: z.enum([admin, user]).default(user) }) export const userCardDef { name: UserCard, component: UserCard, validate(props: unknown) { return UserCardSchema.safeParse(props) } }渲染器里这样用case component: { const def registry.get(node.name) if (!def) return Warning未知组件/Warning if (def.validate) { const result def.validate(node.props) if (!result.success) { return Warningprops 校验失败: {result.error.message}/Warning } return Comp {...result.data}{children}/Comp } return Comp {...node.props}{children}/Comp }这里有几个重要的工程细节。细节一校验后要使用解析后的数据而不是原始数据。safeParse的结果会把默认值、类型转换都处理好。直接用node.props会把未校验的脏数据传给组件等于校验了个寂寞。细节二校验失败不要渲染组件这是新手最容易犯的错误——校验失败后只是 console.error 一下然后继续渲染。真实生产环境里错误 props 可能让组件直接崩溃。正确的做法是渲染一个结构化错误提示并把这个错误信息返回给上层如果是从 LLM 生成的话就能让 AI 看到错误原因并自我修正。我还见过一个比较激进的方案组件 props 不单独校验而是把整个 schema 的组件引用关系也纳入校验——引一个组件前先确认这个 name 是否存在、props 是否符合注册时声明的 JSON Schema。相当于在渲染前做一次“编译期检查”。这种方式的好处是错误能提前暴露不用等 React 渲染开始。我在生产环境里用的是两层校验结合渲染前做整体 schema 校验渲染时对每个组件做局部 props 校验。3.3 端到端让 LLM 的 JSON 走完整个链路这一步是 json-render 真正发挥威力的场景。我描述一下我实际搭建的完整链路你可以直接照搬。整体链路是用户输入 - LLM 生成 JSON结构化输出 - schema 校验 - json-render 渲染 - 用户点击触发 action - 后端结果再转成 JSON 更新界面。LLM 输出部分我用的是 OpenAI 的 function calling或者任意支持 JSON mode 的模型。关键是定义一个 output schema强制模型输出符合 json-render 节点格式的内容{ name: generate_ui, description: 根据用户需求生成 UI 描述, parameters: { type: object, properties: { root: { type: array, items: { type: object, properties: { type: { enum: [text, heading, paragraph, list, component] }, name: { type: string, description: 组件名称必须是注册表中已存在的组件 }, props: { type: object }, children: { type: array, items: { $ref: #/definitions/SchemaNode } } }, required: [type] } } }, required: [root] } }这个 JSON Schema 本身很关键。它把 AI 的输出约束在 json-render 能处理的范围内。千万不要让 LLM 自由发挥结构。generation 阶段的约束越强运行时的防御越轻松。拿到 LLM 返回的 JSON 后进入渲染前的校验环节。这里我用 ajv 做整体 schema 校验JSON Schema 原生支持递归定义再用 zod 做组件 props 校验function validateGeneratedUI(json: unknown): { ok: true; data: SchemaNode[] } | { ok: false; errors: string[] } { const ajv new Ajv({ allErrors: true, strict: false }) const validate ajv.compile(uiSchema) if (!validate(json)) { return { ok: false, errors: validate.errors!.map(e ${e.instancePath} ${e.message}) } } return { ok: true, data: json as SchemaNode[] } }校验失败的场景我把所有错误信息拼接成一条消息回传给 LLMconst result validateGeneratedUI(llmOutput) if (!result.ok) { const fixPrompt 你生成的 JSON 违反了 UI 协议请修正后再试。错误信息${result.errors.join(; )} // 重新调用 LLM }这个“校验失败 - 错误回灌 - 重新生成”的闭环是我认为 Generative UI 工程化里最核心的控制手段。它能自动修复大部分低级的 schema 错误字段名拼错、类型不对、引用了不存在的组件名大幅提升生成成功率。3.4 类型安全、SSR 与测试策略这一节是工程化升级的必选题。从一个“能跑的 demo”变成一个“能上线”的模块需要补三块拼图。类型安全部分。json-render 的价值是让 AI 生成界面但应用本身的 TS 类型不能丢。我的做法是写一个 codegen 脚本扫描注册表自动生成所有组件的 props 类型定义// 假设有个 scripts/registry.ts 脚本生成了以下类型 export type UserCardProps { name: string email?: string level?: admin | user }生成的类型和注册表里的 components 一一对应这样你写业务代码时还是完全类型安全的。我自己是在 CI 里加了一个 check任何注册表的变更都会重新生成类型如果类型不匹配就报错。SSR 部分。这是比较容易踩坑的点。json-render 的数据是纯 JSON本来很利于 SSR但坑在于组件可能在useEffect里获取数据、依赖window等浏览器对象。AI 生成的组件必须遵守一条纪律——渲染期间不要有副作用不要直接访问浏览器全局对象。否则 SSR 和 hydration 时会出现内容不一致。我在实践中给组件注册表加了一个元信息字段ssr: boolean标注哪些组件只能在客户端渲染。服务端渲染时遇到非 SSR 组件先渲染一个占位客户端再自行挂载。测试部分。json-render 有一个很大的优势因为输入是 JSON输出是组件树测试可以写成快照形式。我给生产项目写了几十组黄金文件测试golden test每组 JSON schema 配一张渲染结果的截图跑测试时做视觉回归。这比写组件单元测试快得多而且能防止不小心破坏了某个 schema 结构后没人发现。核心渲染器本身也可以做纯单测给定 mock registry 和 schema断言渲染结果包含哪些元素。4. 常见问题与排查技巧实录4.1 问题速查表实操过程中我遇到过的典型问题整理成一张速查表按我个人踩坑频率排序现象原因解决方案界面空白没有报错schema 结构不符合预期render 返回了 undefined给 renderNode 的 default 分支加日志用 ajv 提前校验 schema反复出现相同的 React key 警告数组渲染用了 index 当 key且内容动态变化用从根到节点的完整路径作为 keyAI 生成的界面引用了未注册组件注册表与 LLM 的 output schema 不同步把注册表导出为 JSON Schema直接塞进 LLM 的 function calling 定义里组件 props 校验失败但界面没崩校验失败被 console.error 吞掉了校验失败渲染错误提示组件不回退裸渲染组件渲染两次刷新时闪烁SSR 与客户端渲染结果不一致排查组件是否在渲染期访问 window / localStorage图片加载就报错、链接点击跳到奇怪页面LLM 生成的危险 URL在 props 层做协议白名单只允许 http/https深层嵌套导致页面卡顿递归深度没有限制加 maxDepth 配置超限渲染折叠节点4.2 三个容易翻车的细节细节一key 必须用路径而不是 index。很多人写递归渲染时习惯map((child, i) renderNode(child, i))。这在静态 schema 下没问题但 Generative UI 是动态的——用户点一个按钮后AI 会生成一份新 schema 替换旧的。如果两份 schema 结构相似但内容变化react 会复用同一位置的组件实例导致旧组件的 state比如表单里输入的文本被错误地带到新组件里。我用路径 key 之后这个问题彻底消失。细节二不要在 props 里直接传函数。JSON 意味着一切数据可序列化。如果需要传回调用 action 机制handler 名字 payload而不是把 JavaScript 函数塞在 props 里——因为 JSON 根本表达不了函数。如果你的注册组件需要事件回调在注册表定义里做一个 adapter 层把 props 里的 handler 字符串转成真正的回调函数。我在注册表里实现了这样的 prop transformerexport function wireActions(compProps, onAction) { return Object.fromEntries( Object.entries(compProps).map(([k, v]) k.startsWith(on) typeof v string ? [k, (payload: any) onAction({ handler: v, payload })] : [k, v] ) ) }细节三安全边界不能只在渲染器做。渲染器能做的防御很有限。真正重要的是渲染器只暴露明确有限的 props 白名单组件内部不直接渲染未加工的 HTML。尤其要阻止dangerouslySetInnerHTML和javascript:协议的链接。我的做法是在渲染器的最外层包一层 sanitize 函数把 props 里的href、src等 URL 字段全部过一遍协议白名单。不要相信 AI 生成的 JSON 里任何字符串——它只是看起来“安全”的数据该洗的还是得洗。5. 从 Demo 到生产Generative UI 的工程化落地5.1 把 LLM 输出变成可控 JSON很多人问为什么我用 function calling 还要写十几行 JSON Schema直接让模型自由输出不行吗因为“自由输出”的结果是渲染成功率不可控。工程化的衡量标准不是“成功一次”而是“一万次里成功多少次”。在我实际业务里加了结构化输出约束之后渲染成功率从 72% 提升到 96% 以上。剩下的 4% 由“校验失败 - 回灌修正”兜底。function calling 或 JSON mode 还不够我强烈建议在 prompt 里明确告诉模型三件事第一只允许输出给定组件列表里的组件第二props 必须符合 JSON Schema 定义不确定的字段别填第三用户要求的功能无法用现有组件表达时用文本说明而不是编造一个组件名。这个 prompt 规范是我在几十次迭代里总结出来的有效降低幻觉组件名的概率。5.2 监控、版本兼容与发布策略到了生产阶段有两个坑是 demo 阶段完全感知不到的监控和 Schema 演进。监控很容易做注册表里每个组件的渲染次数、校验失败率、渲染耗时都上报。我在地盘上给每个 schema 生成一个 trace id任何渲染错误都能追踪到是哪次用户会话、哪次 LLM 调用产生的。Schema 演进更复杂。你是先有注册表里的组件还是先有 AI 生成需求现实是同时演进。今天注册了 A 组件明天业务需要一个 B 组件后天 LLM 输出里已经开始用 B 了——但线上的旧版本 json-render 还不认识 B。解决思路是“宽进严出”LLM 输出可以引用未来组件渲染时遇到未知组件先渲染 fallback同时标记“客户端服务版本过低”。这比直接报错让整个页面崩掉温和得多。用户看到的是一个可读的占位而不是白屏。然后把组件缺失信息上报推动前端发版。真正能稳定上生产的 Generative UI一定不是“AI 说什么就是什么”。它应该是一套完整的链路模型约束生成 - 结构校验 - 注册表匹配 - 安全清洗 - 渲染兜底 - 反馈闭环 - 可观测。json-render 这套源码本质上不是帮你解决“渲染”这一步而是帮你把上面这些环节串起来。我自己在实际业务里看到最明显的效果是以前和产品经理对需求是“你到底要几个页面”现在变成了“你允许 AI 用哪些组件来组合页面”。这个模型转变才是 Generative UI 工程化的真正价值。最后分享一个调试技巧。我习惯把 LLM 生成的 JSON schema 原样打印出来存到日志里配上渲染结果的截图和错误信息形成一组“生成-渲染”对照样本。每次调整 prompt、组件注册表或渲染器逻辑后跑一遍这组样本做回归。这个习惯帮我避免了无数次“改了 A 组件结果 B 场景崩了”的惨剧。