
去年我在跑LLM生成UI的工作流时踩了不少坑。一开始让模型直接输出HTML渲染出来样式稀烂而且根本没法跟现有组件库对接。后来转向JSON描述UI情况立刻好转——大语言模型对结构化JSON的掌控力比自由式HTML强太多了。而要把这套JSON描述真正落地成可维护、可扩展、有类型保障的工程系统就绕不开Vercel Labs的json-render这类方案。它是把Generative UI从“能跑”推向“工程化”的典型实现。这里说的Generative UI核心思路很简单让AI生成的不再是像素级代码而是结构化的UI描述数据前端拿到这份数据后按约定渲染成真实组件。json-render在中间扮演“翻译官”的角色把JSON节点映射成组件树。本文我会基于这类项目的通用工程实现结合源码级别的拆解思路把节点模型、组件注册表、递归渲染器、类型安全、性能优化、RSC集成这些关键链路逐一展开适合正在做AI生成界面、低代码平台、或者准备自建动态渲染引擎的团队参考。1. 项目定位与整体设计思路1.1 Generative UI 需要解决的三个问题先说清楚Generative UI到底解决了什么。传统开发流程里界面由开发者手写代码定义组件和数据之间的绑定关系在编译期就确定了。但到了AI生成界面场景你在模型调用时根本不知道它会产出什么界面可能是一个表单可能是一个数据可视化面板也可能是一个多步骤引导流程。这时候还按老思路走是不行的你没法让AI直接写业务组件代码。于是行业里逐渐收敛出一个比较一致的解法让AI输出一份结构化的UI描述这份描述不包含业务逻辑只表达“界面长什么样、由哪些组件构成、属性是什么”。前端拿到这个描述后查找组件注册表把每个描述节点实例化为真正的组件对象。这套机制天然适合大模型生成因为JSON是语言模型最擅长的输出格式之一可控性比自由文本高很多。json-render这类项目正好卡在这个核心位置上。它要解决三个主要问题第一结构问题怎么用一套稳定的JSON Schema表达任意复杂的界面层级第二安全问题AI生成的描述只能触发白名单能力不能执行任意逻辑第三工程问题这套机制必须能接入现有组件体系不破坏类型安全、不牺牲渲染性能、能在服务端组件和客户端组件之间顺畅流动。1.2 整体架构分层拆源码之前建议先在脑子里建立一张分层图。json-render的架构可以分成五层每一层职责单一换掉哪一层都不影响其他层层级职责对应模块关键产出Schema层定义JSON节点结构、约束合法形态schema/validatorZod schema、运行时校验器注册层维护type到组件实现的映射关系registry组件注册表、名称冲突策略解析层将JSON节点解析为React元素描述parser递归遍历逻辑、类型归一化渲染层将React元素描述渲染为真实组件rendererJsonRender组件、错误边界、memo缓存能力层提供可扩展的自定义组件和action处理器extension/actionMap组件包、动作绑定器这五层是渐进依赖的。Schema层定义“什么是合法的”注册层定义“type能对应哪些组件”解析层决定“怎么从JSON走到组件”渲染层真正产出UI能力层把业务自定义能力注入进来。源码阅读顺序建议也按这个层级走从内向外看。1.3 为什么选择JSON而不是DSL或JSX这个问题我被人问过很多次既然最终都是渲染成React组件为什么不直接用JSX描述或者自研一套模板DSL原因有三条。第一JSON的生成稳定性远超其他格式。让LLM输出JSX容易产生不闭合标签、错误属性、夹杂Markdown而JSON有严格语法模型对它的输出概率分布更集中解析失败率低一两个数量级。实际跑下来用JSON描述UI后的无效输出率可以从20%-30%降到5%以内。第二JSON天然可序列化。AI生成的UI描述可以落库、可以回放、可以做A/B测试、可以流式传输。JSX本质是函数调用不具备这种数据属性。如果你做的是低代码平台需要用户拖拽产生一份可保存的界面配置JSON几乎是唯一选择。第三JSON能与类型系统优雅结合。Zod、io-ts这类库可以直接用Schema推出TypeScript类型让“动态数据”和“静态类型”在编译期达成契约。而DSL要么需要额外构建插件要么就跑在运行时做黑盒解析调试成本高得多。当然JSON方案也有代价比如表达能力比JSX弱、写起来比模板代码啰嗦但为了换取生成稳定性和工程可控性这笔交易非常划算。2. 核心源码解析JSON到组件树的推导过程2.1 节点模型一切从INode开始打开源码第一眼看到的基本就是节点类型定义。这个设计是整个渲染系统的基础所有后续逻辑都围绕这份类型展开。一个通用的JSON节点可以这样定义export interface JsonNode { // 组件类型标识渲染时从注册表查找 type: string; // 可选的重渲染key类似React的key key?: string; // 传给组件的属性集合值可以是任意可序列化数据 props?: Recordstring, unknown; // 子节点可以是嵌套节点数组也可以是纯文本字符串 children?: JsonNode[] | string; }这份定义虽然极简但信息量很大。type是“组件注册名”不是组件类本身解析层不关心这个type背后是什么只负责查表。children的联合类型设计得很关键它同时支持“结构化嵌套”和“文本内容”两种场景比如一个标题组件要显示动态文本直接用字符串children即可不需要包一层Text组件。补充一点常见的工程化处理为了支持多根节点顶层解析函数通常还会接收一个JsonNode[]类型也就是一次渲染一整段UI片段而不是单个节点。这种设计对LLM生成场景特别实用因为模型经常输出一整个界面而不仅仅是单组件。还要注意props的边界。props在JSON里只能放可序列化数据函数、类实例、Symbol这类值不可能出现在JSON里。这意味着组件间通信只能走两条路要么把数据放到props里往下传要么用外部状态容器在渲染层之外共享数据。这一点约束了组件设计的上限业务组件必须遵守“props可序列化”这个约定。2.2 组件注册表type与组件之间的约定注册表是运行时的核心数据结构。它的作用是把JSON里的type字符串映射到一个真实的组件实现上。我见过很多粗糙实现直接写一个{ [type]: Component }对象了事但工程化方案至少要处理三件事注册覆盖警告、校验函数关联、可链式调用API。一个典型的注册表实现长这样import type { ComponentType } from react; import type { JsonNode } from ./types; export interface RegisteredNode { component: ComponentTypeRecordstring, unknown; validate?: (props: Recordstring, unknown) string | null; fallback?: ComponentType{ error: string }; } export class ComponentRegistry { private slots new Mapstring, RegisteredNode(); register(type: string, entry: RegisteredNode) { if (this.slots.has(type)) { console.warn( [json-render] component type ${type} already registered, overwriting previous entry ); } this.slots.set(type, entry); return this; } resolve(type: string): RegisteredNode | undefined { return this.slots.get(type); } }这里有几个工程细节值得注意。第一个是覆盖警告。在大型工程里不同模块可能注册同名组件如果不做提示线上环境很容易出现“我明明注册了新组件但渲染的却是旧组件”的诡异问题。在开发环境打印警告日志能快速暴露这种冲突。第二个是validate字段。注册的不是裸组件而是“组件校验器降级组件”的组合体。这个设计的直接好处是解析层可以在实例化组件之前先校验props合法性不合法就直接走fallback渲染错误提示而不是让React在渲染阶段抛异常。第三个是链式register返回this。这个看似微小的决定让初始化代码变得非常优雅可以把一串注册调用连在一起也可以分别放在不同模块里独立执行。实际项目中每个业务模块都能拿到同一个registry实例并注册自己的组件互不干扰这是可扩展性的基础。关于type命名强烈建议引入命名空间约定。比如layout.card、form.input、chart.bar比直接用card、input清晰得多。LLM生成时更容易遵循这样的命名模式代码提示也更友好。命名空间还能规避第三方组件库之间的名称冲突这个习惯越早建立省的事越多。2.3 递归渲染器最主要的算法渲染器是整个json-render里最核心的部分几十行代码就能串起整条渲染链路。核心思想就是深度优先遍历JSON树每个节点都做同样三件事查注册表、解析props、递归处理children。import { memo } from react; import type { JsonNode } from ./types; import type { ComponentRegistry } from ./registry; interface SingleNodeProps { node: JsonNode; registry: ComponentRegistry; actionMap: ActionMap; path: number[]; } function SingleNode({ node, registry, actionMap, path }: SingleNodeProps) { const registered registry.resolve(node.type); if (!registered) { throw new Error( [json-render] unknown component type ${node.type} at path ${path.join(.)} ); } // 解析子节点字符串当作文本数组递归渲染 let childContent; if (typeof node.children string) { childContent node.children; } else if (Array.isArray(node.children)) { childContent node.children.map((child, index) ( SingleNode key{getPathKey([...path, index])} node{child} registry{registry} actionMap{actionMap} path{[...path, index]} / )); } // 事件解析把action描述符转为函数 const eventProps resolveActions(node, actionMap); const Component registered.component; return ( Component {...node.props} {...eventProps} {childContent} /Component ); } export const JsonRender memo(SingleNode);这个实现最关键的地方在于JsonRender组件本身也是递归渲染的目标。每个子节点都生成一个新的SingleNode实例而不是把整个渲染逻辑写在一个巨大的递归函数里。这种“组件即递归单元”的写法有几个隐性好处的可以利用React的元素树做错误隔离、每个节点都能被memo命中独立跳过渲染、React DevTools里可以直接看到每一层节点的渲染栈。路径key的生成策略也很讲究。我用的是路径数组比如[0, 1, 2]表示根节点的第二个子节点的第三个孙子节点。这样生成的key在列表前插或者中间插入时不会变化而如果用index索引作keyLLM流式生成时一旦前面插入了节点后面所有节点都会被判定为“新组件”导致整段重挂载性能损失不可接受。props的展开用{...node.props}是顺手操作但这里面有个容易被忽略的陷阱node.props里混有key、ref这类React保留字段。实际项目中需要在解析层做一次过滤把React保留字段从props里剥离掉再传给组件否则React会抛出一堆警告还会出现奇怪的渲染行为。2.4 属性与事件声明式交互JSON里不能传函数但界面不可能没有交互。这是Generative UI绕不开的第二道坎。json-render的答案是“action描述符”。JSON只描述“我想要这个组件触发什么动作”具体怎么执行由渲染层的actionMap决定。interface ActionDescriptor { // 动作类型例如 navigate / openModal / submit action: string; // 动作参数任意可序列化数据 params?: Recordstring, unknown; }在组件props里任何值只要满足“是一个对象且有action字段”就算作action描述符。解析层在渲染前统一扫描props把匹配的字段替换成真实函数function isActionDescriptor(value: unknown): value is ActionDescriptor { return typeof value object value ! null action in value; } export function resolveActions(node: JsonNode, actionMap: ActionMap) { const resolvedProps: Recordstring, unknown {}; const rawProps node.props ?? {}; for (const key of Object.keys(rawProps)) { const value rawProps[key]; if (isActionDescriptor(value)) { resolvedProps[key] () actionMap[value.action]?.(value.params ?? {}); } else { resolvedProps[key] value; } } return resolvedProps; }这套机制的工程价值非常大。第一actionMap是注入的意味着你可以针对不同环境配置不同处理策略。在服务端预览场景navigate动作可能只是返回一个链接在客户端交互场景navigate动作才是Router跳转。第二动作类型天然形成白名单AI生成的JSON只能触发你预先定义的动作集不可能执行任意代码。实际项目中还有一个升级技巧给ActionDescriptor加一个可选的label字段用来做动作可读性的展示。调试时能看到“这个按钮的onClick是navigate到/dashboard”排查问题效率提升一大截。此外action参数建议统一包一层params而不是摊开在顶层这样后续增加元数据字段如权限token、埋点信息不需要改动actionMap的函数签名。3. 工程化实现的关键链路3.1 类型安全让JSON也有类型契约动态JSON进系统最担心的就是类型崩坏。页面渲染到一半抛undefined is not a function比编译期启动就报错难受得多。json-render的工程化实现里类型安全是两条腿走路编译期类型推导加运行时Schema校验。运行时校验通常用Zod来做。先定义JsonNode本身的Schema再允许注册组件时挂上自己的props校验器import { z } from zod; const JsonNodeSchema z.lazy(() z.object({ type: z.string().min(1, type is required), key: z.string().optional(), props: z.record(z.unknown()).optional(), children: z .union([ z.string(), z.array(JsonNodeSchema), ]) .optional(), }) ); export function validateJsonNode(input: unknown): JsonNode { const parsed JsonNodeSchema.safeParse(input); if (!parsed.success) { throw new Error([json-render] invalid node: ${parsed.error.message}); } return parsed.data; }这段代码里用到z.lazy自引用技巧是为了支持递归嵌套类型。如果不加lazyZod定义children数组时会因为立即展开导致无限递归栈溢出。这个是写树状Schema的必备知识点。编译期类型说的是另一层你的业务组件需要通过一个帮助函数包装让它自动推导出props类型和JSON结构类型之间的关系。简单场景可以直接用ComponentTypeProps作为注册表类型进阶一点的方案是用z.infer把Schema类型导出为TS类型再强制业务组件props满足这个类型const UserCardPropsSchema z.object({ name: z.string(), avatar: z.string().url().optional(), role: z.enum([admin, editor, viewer]).default(viewer), }); type UserCardProps z.infertypeof UserCardPropsSchema; function UserCard({ name, avatar, role }: UserCardProps) { return ( div classNamerounded-lg border p-4 div classNameflex items-center gap-2 {avatar img src{avatar} alt{name} classNameh-8 w-8 rounded-full /} span{name}/span span classNametext-xs text-gray-500{role}/span /div /div ); }这样的写法等于把Schema当作一个“前后端共享的接口文档”组件代码里出现的每个字段都是被Schema定义过的不会出现“组件需要a字段但JSON只给b字段”的错位。3.2 运行时校验与兜底降级即使有了类型防御运行时数据仍可能不合规毕竟AI的输出有时候就是不按套路出牌。所以渲染链路里必须设计兜底策略。我用过比较完善的一套方案是三层降级第一层Schema校验失败整个节点标记为invalid并渲染fallback组件第二层组件注册表查不到type渲染UnknownType组件并显示type名第三层组件渲染过程抛异常外层ErrorBoundary捕获并局部降级。Fallback组件的设计也很有讲究function RenderedNode({ node, registry, ... }) { const schema JSONSchemaFor(node.type); if (schema) { const error validateNodeProps(node, schema); if (error) { return DefaultError message{${node.type}: ${error}} /; } } const registered registry.resolve(node.type); if (!registered) { return UnknownType type{node.type} path{getPathKey(node.path)} /; } try { return ActuallyRender node{node} registered{registered} /; } catch (error) { return DefaultError message{${node.type} render error: ${error.message}} /; } }这个方案的工程价值是整个渲染树不会因为某一个节点挂了而全部白屏。模型一旦生成一个未知组件最多那块区域显示一个错误卡其他部分正常展示。用户感知上比整页崩溃好太多。线上排查也简单错误卡上直接有type名和路径甚至可以做成可点击复制方便直接丢给模型做自纠错。3.3 渲染性能递归树节点的反复渲染问题动态渲染树有一个天然的性能隐患父节点props一变整个递归子树全部重渲。在AI流式生成场景下这个隐患会被放大到难以容忍——模型每吐出几个token就更新一次JSON如果不做任何优化每次更新都是一整棵树的重新渲染。解决思路分三步。第一步是用memo包裹每个节点组件前提是props引用不变时直接跳过渲染export const JsonRender memo(SingleNode);但memo默认是浅比较而node.props作为多层嵌套对象浅比较基本次次不相等memo形同虚设。所以第二步是自定义比较函数传入isEqual这类深比较工具。深比较成本不低所以第三步更重要控制数据流更新的粒度。父组件不直接把整棵JSON树传给每个子节点而是先把JSON解析成稳定的引用结构让每个节点的对象在内容没变时保持同一个引用这样浅比较就能命中。实操方案是引入一层“归一化缓存”const normalized useMemo( () normalizeTree(rawJson), // 只有在JSON文本真正变化时才重新归一化。 // LLM流式场景建议用文本哈希做依赖只更新新增部分对应的节点引用。 [rawJson] );最后关键性能技巧是给高消耗节点加渲染槽位抽象。比如数据表格组件JSON更新时如果表格数据没变只换了外层标题应该让表格子节点跳过解析。这需要业务组件层面配合做好memo让props浅比较能够生效所以注册组件时尽量遵循“一组相关字段放在同一个props对象下、不频繁变更引用”的写法。3.4 与React Server Components的配合Vercel Labs的项目天然绕不开RSC和Streaming。Generative UI最理想的运转现场是这样的LLM在服务端生成JSON通过RSC边界流式传输到客户端组件客户端渐进式渲染出UI。json-render在服务端和客户端各扮演不同角色。在服务端JSON可以直接作为Server Component的props传给一个客户端包装组件。这个包装组件只在客户端真正实例化组件树。服务端负责生成、校验、归一化JSON但不执行客户端组件渲染避免把整个组件库打进服务端bundle。// server-component-wrapper.tsx (Server Component) import { JsonClientRenderer } from ./json-client-renderer; export async function AiGeneratedPanel({ dataPromise }) { const json await dataPromise; // 可能是从LLM API拿到的JSON return JsonClientRenderer json{json} /; }// json-client-renderer.tsx (Client Component) use client; import { JsonRender } from json-render/client; import { registry } from ./registry; export function JsonClientRenderer({ json }) { return JsonRender value{json} registry{registry} /; }这样做还有一个附带好处RSC流式传输时页面可以先看到一个骨架或者部分内容JSON完整到达后再补全组件。配合Loading Boundary还能做出“生成中”的渐进展示效果用户等待体感好了很多。如果JSON是分片到达的建议在服务端做缓冲区聚合一次性下发完整片段给客户端避免每toke都触发一次客户端重渲染。控制台里观察网络面板生成的支数一般控制在个位数比较理想。4. 实战往注册表里加一个业务组件4.1 注册三步定义、校验、注册讲完理论必须上一段可以直接抄的实战。假设要给团队组件库加一个UserCard用于展示用户信息卡片。整个过程分三步。第一步定义组件本体注意props务必使用可序列化类型interface UserCardProps { name: string; avatarUrl?: string; bio?: string; verified?: boolean; } function UserCard({ name, avatarUrl, bio, verified }: UserCardProps) { return ( div classNamerounded-xl border bg-white p-4 shadow-sm div classNameflex items-center gap-3 {avatarUrl ( img src{avatarUrl} alt{name} classNameh-10 w-10 rounded-full / )} div div classNameflex items-center gap-1 span classNamefont-medium{name}/span {verified span classNametext-blue-500已认证/span} /div {bio p classNamemt-1 text-sm text-gray-600{bio}/p} /div /div /div ); }第二步定义props校验Schemaconst UserCardPropsSchema z.object({ name: z.string().min(1), avatarUrl: z.string().url().optional(), bio: z.string().max(200).optional(), verified: z.boolean().optional(), });第三步在初始化入口注册进registryregistry.register(user.card, { component: UserCard, validate: (props) { const result UserCardPropsSchema.safeParse(props); return result.success ? null : result.error.message; }, });三步走完之后LLM要生成这张卡片只需输出{ type: user.card, props: { name: 张三, verified: true, bio: 全栈工程师专注前端工程化 } }渲染层会先校验props校验通过后实例化组件。校验失败则走fallback不会出现组件拿到错误数据类型后抛出晦涩异常的情况。4.2 处理带复杂children的组件带children的容器组件注册起来比纯展示组件麻烦一点主要是children结构可能混合多种类型。比如要给评论区做一个CommentSectionJSON结构可能是{ type: comments.section, props: { title: 讨论区 }, children: [ { type: comments.item, props: { author: 李四, content: 这篇分析很有帮助 } }, { type: comments.item, props: { author: 王五, content: 想问一下递归渲染器的key策略 } } ] }注册时容器组件的children类型应声明为JsonNode[]渲染层会把children解析成React元素数组传给组件。真实的嵌套场景里会出现的坑是外层组件的props里也定义了children字段。这时候要约定优先级JSON节点的children字段优先级高于props里的children字段渲染层在合并时需要强制覆盖。组件内部处理children的方式没有特殊要求当成普通React children使用即可。唯一要注意的是如果容器组件需要对children做CloneElement注入额外props要在子组件key稳定之后再做否则React会警告重复key。4.3 一组可直接抄的注册表配置最后给一套完整可跑的示例配置覆盖了页面中常见的栅格容器、按钮、输入框、数据表格四类组件并绑定了actionsconst registry new ComponentRegistry() .register(layout.grid, { component: GridContainer, validate: (props) GridContainerSchema.safeParse(props).success ? null : invalid grid props, }) .register(layout.card, { component: Card, validate: (props) CardSchema.safeParse(props).success ? null : invalid card props, }) .register(ui.button, { component: Button, validate: (props) ButtonSchema.safeParse(props).success ? null : invalid button props, }) .register(form.input, { component: FormInput, validate: (props) FormInputSchema.safeParse(props).success ? null : invalid input props, }) .register(data.table, { component: DataTable, validate: (props) DataTableSchema.safeParse(props).success ? null : invalid table props, });ActionMap配置const actionMap { navigate: ({ to }: { to: string }) { navigate(/app/${to}); }, openModal: ({ name }: { name: string }) { modalStore.open(name); }, submitForm: ({ formId }) { formStore.submit(formId); }, refreshData: () { queryClient.invalidateQueries(); }, };在这套配置下LLM输出一个带按钮的卡片按钮点击触发跳转整条链路从JSON描述到真实交互全部打通。这就是Generative UI落地到业务里的最小可复现闭环。5. 常见问题与排查技巧实录5.1 “Unknown component type”与命名空间设计跑起来之后最先遇到的报错基本就是[json-render] unknown component type xxx。这个报错看着简单但原因会有好几种。最常见的是注册顺序问题使用registry的模块在组件注册执行前就发起了渲染请求导致查表时空集。这不是并发bug而是模块初始化顺序依赖没处理好。解决方案是做一个惰性useEffect渲染组件挂载阶段先确保所有注册调用完成。第二常见的原因是命名不一致。模型输出的type和注册的type差一个字母比如注册了user.card但模型输出userCard。建议在注册层做一个别名表把历史命名和新命名映射起来。同时给模型prompt里带上“type必须是已注册的名称”的明确约束双重保障。还有一个容易忽略的点大小写敏感性。组件注册表逻辑上应当是大小写敏感的查询但prompt里的模型输出很容易把驼峰写成全小写。在resolve函数里加一个字符串小写归一化然后要求注册阶段统一用小写命名能消掉一大批这类问题。5.2 props与children的边界覆盖问题注册的容器组件经常出现“children不显示”的现象。排查后发现根因是组件调用时同时传入{...props}和children而props里也恰好带了一个children字段。两套children一覆盖内容就丢了。这个问题的最佳处理时机是在解析层对节点props做一次清理删除掉名为children的键。因为JSON节点规范里children有专门槽位不该出现在props里。规范定了之后所有组件都统一遵守就永远不会踩这个坑。另一个相关坑是React保留字段。typeof、key、ref这些字段如果出现在props里传给组件React会针对rcss特殊处理或直接告警。我在解析层加了白名单过滤const RESERVED_PROPS [__self, __source, key, ref]; function sanitizeProps(props: Recordstring, unknown) { const clean {}; for (const key of Object.keys(props)) { if (!RESERVED_PROPS.includes(key)) clean[key] props[key]; } return clean; }5.3 渲染重复执行如何定位动态渲染树一旦卡顿第一怀疑对象就是非必要的重复渲染。我用React Profiler确认热点后习惯按三个层次逐个排查。第一层memo有没有生效。节点组件如果没有被memo包裹父级任何状态变化都会逐层传导这个最直观。第二层比较函数是不是默认浅比较。props对象每次解析都生成新引用浅比较永远不相等优化等于白做。我遇到的情况是深层数据没变但父组件setState了导致整棵子树重渲。解决方案就是前面提过的“归一化缓存”加“引用稳定”让内容没变的节点props保持同一对象引用。第三层业务组件内部状态。如果在渲染函数里直接调用了Math.random()或Date.now()赋值那每次渲染都会产出新值外部优化全部白费这种属于组件设计问题只能改组件。排查工具上直接在渲染函数入口打印debug日志是最快的土办法但生产环境要关掉。团队里我会推荐用React DevTools的Highlight Updates开关能直观看到哪些组件在闪闪的部分就是重渲的部分。5.4 安全边界JSON注入与XSS防护这个一定要单独拎出来说。动态渲染意味着不可信数据进入组件树安全护栏必须有。第一道防线是action白名单机制只允许预先定义的action执行杜绝模型输出任意代码路径。第二道防线是对children的文本内容做编码处理组件层面统一使用安全字符串渲染方式不用dangerouslySetInnerHTML渲染模型生成的HTML内容。第三道防线是URL校验模型生成的链接属性如果是javascript:协议开头坚决拦截。注册表里对URL类props加一个校验函数确保只放行http/https协议。这里想强调一点JSON描述UI不等于JSON是安全的它只是降低了风险面。真正的安全边界还是要靠在解析层和组件层的约束来实现。所以校验器不是可选项而是核心安全能力的一部分。写在最后的一点体会把json-render这套思路摸熟之后我再做AI生成界面的时候就再也没碰过“让模型直接输出HTML”的老路子。每次看LLM吐出结构化JSON、再看到组件树丝滑渲染出来都会觉得这种“描述式UI生成”才是工程上真正靠谱的方向。实际项目里建议团队先小范围试点挑一个业务面板把几个核心组件注册进registry让模型只负责生成这些组件组合出来的界面。跑稳之后再把组件面铺开你会发现注册表里的每个组件都成了AI的“能力边界”谁可被生成、谁不可被生成一目了然。如果后面要继续做深还可以考虑接入schema驱动的自适应表单生成或者用同一套JSON渲染逻辑直接输出到小程序端工作量不大扩展性却极好。这大概就是Generative UI的魅力所在一套数据描述多处界面复用从“画界面”彻底变成“描述界面”。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。