去年我在跑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/validator | Zod schema、运行时校验器 |
| 注册层 | 维护type到组件实现的映射关系 | registry | 组件注册表、名称冲突策略 |
| 解析层 | 将JSON节点解析为React元素描述 | parser | 递归遍历逻辑、类型归一化 |
| 渲染层 | 将React元素描述渲染为真实组件 | renderer | JsonRender组件、错误边界、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?: Record<string, 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: ComponentType<Record<string, unknown>>; validate?: (props: Record<string, unknown>) => string | null; fallback?: ComponentType<{ error: string }>; } export class ComponentRegistry { private slots = new Map<string, 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索引作key,LLM流式生成时一旦前面插入了节点,后面所有节点都会被判定为“新组件”导致整段重挂载,性能损失不可接受。
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?: Record<string, 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: Record<string, 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自引用技巧,是为了支持递归嵌套类型。如果不加lazy,Zod定义children数组时会因为立即展开导致无限递归栈溢出。这个是写树状Schema的必备知识点。
编译期类型说的是另一层:你的业务组件需要通过一个帮助函数包装,让它自动推导出props类型和JSON结构类型之间的关系。简单场景可以直接用ComponentType<Props>作为注册表类型,进阶一点的方案是用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.infer<typeof UserCardPropsSchema>; function UserCard({ name, avatar, role }: UserCardProps) { return ( <div className="rounded-lg border p-4"> <div className="flex items-center gap-2"> {avatar && <img src={avatar} alt={name} className="h-8 w-8 rounded-full" />} <span>{name}</span> <span className="text-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 className="rounded-xl border bg-white p-4 shadow-sm"> <div className="flex items-center gap-3"> {avatarUrl && ( <img src={avatarUrl} alt={name} className="h-10 w-10 rounded-full" /> )} <div> <div className="flex items-center gap-1"> <span className="font-medium">{name}</span> {verified && <span className="text-blue-500">已认证</span>} </div> {bio && <p className="mt-1 text-sm text-gray-600">{bio}</p>} </div> </div> </div> ); }第二步,定义props校验Schema:
const UserCardPropsSchema = z.object({ name: z.string().min(1), avatarUrl: z.string().url().optional(), bio: z.string().max(200).optional(), verified: z.boolean().optional(), });第三步,在初始化入口注册进registry:
registry.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结构可能混合多种类型。比如要给评论区做一个CommentSection,JSON结构可能是:
{ "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 一组可直接抄的注册表配置
最后给一套完整可跑的示例配置,覆盖了页面中常见的栅格容器、按钮、输入框、数据表格四类组件,并绑定了actions:
const 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: Record<string, 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的魅力所在:一套数据描述,多处界面复用,从“画界面”彻底变成“描述界面”。