Generative UI新思路:用JSON描述驱动前端渲染的工程实践
接触过AI生成UI这块的朋友应该都清楚过去一年多大家一直在折腾“用大模型直接生成页面”但真正落地的方案少之又少。大多数Demo都是让模型吐出一段HTML或React代码然后塞进沙箱里渲染听着很酷可真上了生产环境安全性、可维护性、流式渲染、组件复用这些问题全冒出来了。Vercel Labs开源的json-render给出的是一条完全不同的路子既然模型直接写代码不可控那不如让模型只输出结构化的JSON描述由客户端渲染器负责把JSON翻译成真实UI。这篇文章我想从一个工程实践者的角度把json-render的源码设计逻辑拆开来讲。它不是把JSON渲染成网页那么简单里面涉及协议设计、组件注册表、解析器、渲染管线、状态同步、扩展机制等一系列工程化问题。如果你也在做Generative UI、Agent交互界面或者只是想找个方案让动态数据安全可控地变成页面这篇内容应该能给你不少可以直接抄作业的思路。整篇文章会沿着一条主线走先从“为什么Vercel选择JSON而非代码生成”这个源头问题切入然后依次拆解render协议的数据结构、源码里的解析与渲染链路、在React环境下的工程化集成方式最后给出一个完整的schema到UI的实现示例和我在实战中踩过的几个坑。1. 为什么Generative UI选择JSON描述而不是直接生成代码1.1 “让模型写代码再运行”这条路为什么走不通很多人第一反应是现在模型写代码能力已经很强了让它直接输出React组件不行吗理论上可以工程上很痛。先看安全。让大模型生成的代码在用户浏览器里直接执行等于把任意代码执行权限交给了一个可能产生幻觉的系统。即便加了沙箱也需要处理CSP、iframe隔离、资源加载白名单一大堆问题而且沙箱和外层应用通信的边界很容易被绕过。国内很多团队试过这条路最后都卡在安全评审这一关。再看可控性。模型生成代码同一个prompt换一次温度参数产出的组件结构可能完全不一样。Class组件、函数组件、样式方案、状态管理方式全都在变前端工程团队根本没法做代码审查和性能优化。这种不可预测性在To B产品里尤其致命——客户会问你要一份“这次更新到底改了什么”的变更说明。最后是迭代成本。HTML字符串或代码生成没法做增量更新和状态比对。用户和AI助手对话过程中UI需要频繁微调如果每次都是“重新生成一整个组件树”那页面会不停闪烁、滚动位置丢失、输入框焦点重置体验极其糟糕。1.2 json-render的反直觉设计用“UI协议”取代“UI代码”json-render换了个思路模型不生成代码只生成一份结构化的UI描述类似于把页面用“设计稿标注”的方式输出。渲染的职责全部交回给前端运行时运行时里每一个组件都是经过审查、测试、注册过的真实组件。这种设计最关键的收益在于AI只负责“决策”前端只负责“执行”。模型决定这里应该展示一个表格、那里应该有一个可折叠面板但表格组件到底长什么样、交互逻辑怎么写完全由工程团队决定。这有点像后端的模板引擎——模板是模型生成的但模板里的标签库是开发人员手工维护的。所以json-render本质上是一套生成式UI的渲染协议不是一个开箱即用的组件库。它定义了“JSON该怎么描述UI”并提供了一套解析、渲染、更新这套JSON的引擎。1.3 这套方案的适用边界也不是所有场景都适合用json-render。我的判断是它最适合的是结构性内容、数据展示型页面、表单类交互、多步骤流程这类场景。模型和用户的对话流里需要动态生成面板、卡片、图表、表单这些内容天然适合用JSON树描述。但如果你要生成的是高度定制、强交互的复杂页面——比如一个代码编辑器、一个三维场景或者需要精确像素级布局的品牌首页——那直接用代码生成或者预置模板更现实。JSON渲染的意义在于“结构化的可控动态”而不在于“像素级的精准还原”。2. 拆解json-render协议一张JSON如何描述整个页面2.1 JSON即组件树的映射规则json-render的核心协议很简单整体是一棵嵌套的JSON树。每个节点有一个核心类型字段比如div、text、img、button、table以及一个属性字段props里面都是可直接转手给真实组件的键值对。{ type: div, props: { className: hero-container, style: { padding: 24px, borderRadius: 12px }, children: [ { type: text, props: { content: 欢迎使用智能助手 } }, { type: button, props: { label: 开始配置, onClick: startConfig } } ] } }看到这个结构做前端的同事大概率会想到React的createElement——事实上json-render的解析器做的事就是把每一层节点转换成对应的React元素。这里有个隐藏的设计细节children也是放在props里的。这样做的原因是让整个协议保持统一解析器在遍历时只要统一处理props对象就可以了逻辑会简单很多。2.2 布局、样式、动作的三层表达实际使用中JSON需要表达的信息远不止“有什么组件”还包括组件怎么排、长什么样、点了之后干什么。json-render把这三层责任分得很清楚。第一层是结构层描述组件的嵌套关系用children递归实现。第二层是表现层描述样式和布局。常见的做法是支持style对象内联样式和className字符串进阶一点可以支持className引用预置的design token。第三层是行为层描述交互动作用一个字符串事件名或带参对象来表示事件回调比如onClick: startConfig。实际点击时运行时再根据事件名去路由到具体的业务逻辑。这样分层的直接好处是模型不需要理解复杂的CSS规则它只需要输出结构化的属性甚至可以让模型从设计令牌列表里“选”样式而不是自由发挥。2.3 动态绑定让JSON可以引用实时数据如果协议只有静态结构那和写死JSON没什么区别。json-render真正有生产力的是动态绑定能力节点属性可以引用外部的运行时数据源。{ type: chart, props: { type: line, data: { $ref: revenue.byMonth } } }$ref的意思是revenue.byMonth这个值不是硬编码在JSON里的而是从渲染上下文的数据源里动态取。这个设计非常像OpenAPI里的$ref只不过它的引用对象是运行时内存里的数据对象不是文档里的schema定义。这意味着模型或服务端生成的JSON可以保持“模板化”具体数据由业务层下发同一个模板可以套用完全不同的数据。这也是JSON描述和代码生成相比的核心优势之一结构可变数据更可变但两者可以分离管理。3. 从JSON到真实UI源码中的解析与渲染核心链路3.1 入口组件JsonRender树的递归遍历源码里最核心的组件通常叫JsonRender或者Renderer它是一个接受JSON schema的React组件。其内部实现逻辑本质上是一个递归函数拿到当前节点根据type去注册表中找到对应的组件映射然后递归处理children。function JsonRender({ node, context }) { if (!node || typeof node ! object) { return null } const Component resolveComponent(node.type) if (!Component) { return FallbackComponent type{node.type} / } const resolvedProps resolveDynamicProps(node.props, context) const children Array.isArray(node.props?.children) ? node.props.children.map((child) ( JsonRender key{child.id || Math.random()} node{child} context{context} / )) : null return Component {...resolvedProps}{children}/Component }这是一段高度简化的伪码但它已经能反映整个渲染机制的核心递归、注册表查找、动态属性解析、上下文传递。3.2 组件注册表扩展就是往里塞组件任何一个通用渲染器都必须回答一个关键问题type字段到底如何映射到真正的组件json-render用的是组件注册表Component Registry。注册表是一个对象映射type字符串是键React组件是值。const registry { text: Text, button: Button, card: Card, table: DataTable, chart: ChartWrapper, form: FormContainer } export function resolveComponent(type) { return registry[type] }工程化上最重要的一个设计思路是注册表应该可插拔、可覆盖。基础组件库由json-render自带但业务组件由使用方自行注册。甚至可以做组件版本控制——比如同一个type在A租户和B租户下解析到完全不同的实现这就满足了To B产品里常见的多租户定制诉求。代码层面还可以配合Webpack或Vite的动态导入实现按需加载type对应的组件只有在首次渲染到时才加载对应chunk。这在大应用里非常关键因为一个注册表里可能挂了几百个业务组件一次性全部加载会让首屏直接垮掉。3.3 解析器与渲染管线的分层阅读源码时可以把json-render的实现拆成三个独立阶段解析、增强、渲染。解析阶段把JSON字符串变成JSON对象同时做基本的合法性校验——比如节点是否缺少type是否存在循环引用。这个阶段纯数据不碰DOM。增强阶段是json-render最有特色的地方。它会遍历整棵节点树完成几件事给没有id的节点补上稳定ID供后续更新时定位解析所有$ref动态属性从上下文中取出真实数据替换占位符合并默认样式和节点自定义样式。这个阶段的产物是一棵“可渲染的Schema树”里面的所有属性都已经准备好被组件消费。渲染阶段才是真正调用React组件树进行挂载和更新。把增强后的Schema映射为React元素树然后交给React Reconciliation机制去处理diff和dom更新。export function renderJsonToReact(schema, context) { const validated validateSchema(schema) const enriched enrichSchemaWithData(validated, context) const elementTree createElementTree(enriched) return elementTree }分层的一个巨大好处是可测试性。解析和增强都是纯函数输入JSON输出JS对象完全可以做单元测试覆盖渲染层的React组件逻辑也可以单独做快照测试。3.4 缓存与优化策略动态UI研发过程中大家最担心的就是性能。模型可能每秒推送多次schema更新如果每次都全量递归渲染整棵树再交给React diff页面会明显卡顿。源码里一般会有几个专门做的优化手段。第一个是节点级缓存。每个节点根据id type 关键props的hash计算缓存键。如果节点在上一次渲染中已经出现过且本次props没有变化则直接复用上一次的React元素跳过组件渲染。这本质上是在React自身的memo之外再架设一层面向动态schema的memo。第二个是增量更新队列。服务端推送的schema变化先进入一个队列当节点key发生改变时只对发生变化的子树做重渲染其他子树保持不变。这个过程利用的其实是key在React中的识别机制——只要key没变React会认为节点是同一实例不会去动它。但json-render变聪明的地方在于它把“该给哪个节点分配什么key”也纳入了schema协议里服务端可以直接指定而不是靠客户端猜。{ type: div, id: main-panel, props: { children: [ { type: chart, id: chart-a, props: { type: bar } } ] } }如果后续schema更新时main-panel下面新增了一个chart-b而chart-a的配置没变React会自然复用chart-a实例不会从头渲染。这个设计极大缓解了对话式UI的卡顿问题。4. 工程化集成在真实React项目中落地json-render4.1 状态管理schema该由谁来驱动在真实项目里json-render的状态管理往往比渲染本身更复杂。你需要想清楚一个核心问题JSON schema数据是放在全局store里还是放在组件内部。我的实践建议是schema作为受控数据源存在全局store渲染器作为纯展示组件。什么意思呢即JsonRender组件接收schema和更新函数作为props自身不维护schema状态。这样做的原因有几个一是schema通常来自服务端推送和Agent对话状态强关联必须全局可访问二是用户操作比如点击按钮之后可能会修改schema结构这些修改要能发回服务端决定下一步动作三是多组件共享同一份schema时不会出现状态不同步的问题。const [schema, setSchema] useState(null) useEffect(() { const unsubscribe onSchemaUpdate((newSchema) { setSchema(newSchema) }) return unsubscribe }, []) return JsonRender schema{schema} context{context} /4.2 事件桥接JSON里的按钮怎么触发真实业务逻辑上文提到JSON描述里有个onClick: startConfig这样的字符串。工程化落地时这个字符串必须在运行时被桥接到真实函数。json-render内部会有一个ActionRegistry——类似组件注册表但存的是回调函数。const actions { startConfig: (payload, context) { context.aiClient.send({ type: START_CONFIG, payload }) }, submitForm: (payload, context) { api.submitForm(payload) } } export function resolveAction(name) { return actions[name] }事件桥接层要解决的问题不只是“找到函数”还包括参数格式如何标准化、调用结果如何反馈给schema、异步操作期间UI如何显示loading态。我建议的做法是让所有action回调返回一个Promisejson-render统一处理pending、fulfilled、rejected三态并且把状态映射回schema中的某个节点属性比如表单的提交状态。这样交互模型就变得统一了用户点按钮 → action执行 → 返回结果 → 服务端更新schema → UI自动刷新。4.3 数据注入context里的业务数据如何给到组件组件所需的业务数据不能全部塞进JSON树里——一个是JSON会变得庞大另一个是很多数据本就不可能序列化到字符串里比如组件实例方法、日期对象、函数引用。json-render的做法是维护一个独立的context对象在渲染时作为第二参数传给所有组件。JsonRender schema{schema} context{{ user: currentUser, revenue: revenueData, utils: { formatPrice }, }} /节点里的$ref引用数据源时实际上就是从context这个对象里取值。这种“上下文注入”模式和React的Context很相似但json-render在协议层面定义了数据引用的规范。这样做的直接好处是模型生成JSON时同样可以通过$ref引用上下文里的数据而不会把敏感数据本身直接写进schema里——尤其适合Agent场景整个会话的用户数据都存在context里每次schema更新都不必重复下发大段数据。5. 完整实战从AI对话流到动态UI面板的一次实现5.1 场景定义一个电商数据助手的动态面板我们在自己的项目里用json-render做过一个电商数据助手。用户对AI说“帮我看下上周的销售额、订单量和退货率”AI不直接回复一段文字而是动态生成一个包含三个统计卡片和一个趋势图的Dashboard面板。整个过程AI只下发JSON schema渲染完全由json-render完成。首次schema大概是这样的{ type: grid, props: { columns: 2, gap: 16px, children: [ { type: card, id: stat-sales, props: { title: 上周销售额, content: { $ref: ai.stats.sales }, footer: 较前周 12% } }, { type: card, id: stat-orders, props: { title: 上周订单量, content: { $ref: ai.stats.orders } } } ] } }5.2 渲染层的代入与数据流服务端拿到用户请求后调用大模型生成上面这份JSON附加一个ai.stats的数据源对象放进context。客户端通过WebSocket收到新的schema后直接setSchema更新全局状态json-render便完成了整块面板的动态渲染。关键点在于AI的模型逻辑只负责“生成结构”不负责“拼接数据”。JSON里用$ref引用ai.stats.sales实际数值由服务端查询数据库后放进context。这保证了任何一方出现偏差都不会导致页面直接崩溃——模型就算把字段名写错了也只是那一个卡片显示不出来而不是整块UI崩溃。解析和增强阶段所以建议做严格校验也是为了让这类错误可控、可观测。5.3 用户交互后schema的动态更新用户在面板里点了一个“查看明细”按钮json-render的事件桥接捕捉到这个action把它发给后端。后端再让模型基于用户意图生成新的schema把统计卡片替换成一个表格同时保持网格布局和标题不变。因为节点ID保持稳定React在diff时只会更新新增的表格区域不会导致整个面板闪一下。{ type: grid, props: { columns: 1, gap: 16px, children: [ { type: card, id: stat-sales, props: { title: 上周销售额, content: { $ref: ai.stats.sales } } }, { type: table, id: detail-table, props: { columns: [日期, 销售额, 订单数], rows: { $ref: ai.detailRows } } } ] } }整个过程没有一行代码是“模型写出来再执行的”模型只负责生成JSON和业务决策前端所有行为都在工程团队的掌控之中。6. 源码阅读中需要注意的边界与坑6.1 递归深度与循环引用JSON schema是有可能被模型生成出很深嵌套的理论上是递归无上限。但浏览器渲染层面是有栈溢出的风险。源码里应该对最大深度做个限制比如默认15层超过了就渲染一个“嵌套过深”的占位组件。另外如果服务端不小心在JSON里塞了循环引用比如一个节点的children里引用了自己JSON.parse就会直接抛错。增强阶段建议用WeakSet或者索引标记做循环检测。6.2 节点ID的稳定性比想象中重要首屏渲染时节点可能没有ID源码会随机生成一个。但问题在于后续schema更新时如果节点在首屏是随机ID而在更新时服务端指定了确定性ID那React会把它们当成完全不同的节点导致组件的内部状态比如滚动位置、输入框内容全部丢失。我的建议是服务端生成schema时必须为每个节点带上稳定ID并且约定ID在会话期间不可变。如果是纯客户端动态生成就用路径来做ID比如root.children[0].props.children[2]保证每次计算一致。生成方ID策略风险建议服务端模型UUID/语义ID每次更新可能变化约定“会话内ID不可变”客户端生成随机数更新后无法匹配用路径作为ID混合服务端定时下发首屏无ID首屏先路径兜底收到服务端ID后替换6.3 流式渲染时的视觉闪烁处理大模型的schema生成是流式的也就是先吐出一个框架、再逐层补充细节。直接把这个增量的schema stream拿来渲染页面会经历“先空白、再部分内容、再完整内容”的过程观感很糟糕。源码层面对这个问题常见的做法是维护一个“提交延迟”在接收schema更新时不立即渲染而是等350毫秒左右没有新消息再执行一次完整渲染。这个延迟恰好能吸收大模型多次token生成造成的抖动又不至于让用户明显感觉有卡顿。另外还可以做增量节点渲染当前端收到schema的增量patch时尽量做到只渲染新增的子树已存在的节点不动。这就是前面说到的ID稳定性为什么如此重要——没有稳定ID增量更新就无从谈起。6.4 权限与安全schema本身也是攻击面json-render把“JSON描述UI”做成了通用能力这也就意味着一旦schema来源被攻破攻击者可以在页面上注入任意类型的节点。因此工程化时必须在json-render外层建立schema校验白名单——只能使用注册表里有的type不认识的类型一律渲染失败态不能回退到“动态创建任意DOM”。属性字段也要做类型校验比如onClick只能是字符串白名单里的事件名不能接收函数表达式。常见的几个坑按优先级排列 - type字段未白名单校验 → 可能导致运行任意逻辑 - props里混入__proto__等原型污染字段 → 需要做对象冻结或JSON.parse防护 - 事件名不校验 → 可能导致意外触发内部action - 数据引用的路径未做访问控制 → 可能读取到context里的敏感字段虽然这些不是json-render本身的问题但作为使用方不建这道防线等于裸奔。6.5 调试技巧视觉化traverse schema最后一个实操建议。调试json-render同步的schema时别直接打印那个巨大的JSON对象肉眼找问题效率太低。我一般会在增强阶段挂一个开发用的日志钩子输出每个节点的“类型 ID 是否有$ref 是否有children”的扁平列表一眼就能看出哪一层的数据引用没有解析成功、哪个节点缺少ID、结构是否有异常嵌套。等跑通了再把日志关掉。持续做Generative UI这块我越来越觉得json-render的路线才是真正能进入生产环境的方案。模型的创造力在“决策层”发挥工程的安全性在“执行层”兜底两者各司其职。如果你也正在做类似的方向不妨直接去翻翻源码里renderer和registry这些模块比看文档高效得多——因为这套系统真正的设计精髓全都藏在那些递归以及增量的diff逻辑里。