☰
json-render 源码解析:把模型输出的 JSON 变成可控的 React 组件树
2026/11/2 20:20:07 网站建设 项目流程

去年开始我一直在纠结一个问题:当大模型真的能吐出一段描述界面的 JSON 时,前端到底该怎么接住它?直接跑 vdom 序列化不安全,让模型写 JSX 又没法管控,最后几乎所有讨论都指向了同一个答案——Vercel Labs 的 json-render。这个库定位非常明确:把模型生成的 JSON 描述,在 React 运行时里变成一棵稳定、可控、可校验的组件树。它不追求大而全,只解决 Generative UI 链路里最容易被拍脑袋的那一段:渲染层到底怎么工程化。

这篇文章会把 json-render 的源码拆开讲,从入口函数一路走到递归渲染、组件注册表、props 校验和错误边界。适合两类人:一类是正在做 Agent UI、AI 页面生成,想给模型输出加一道可靠渲染管线的;另一类是自己也想写一个"声明式渲染器",但不确定边界在哪、坑在哪的。看完你会发现,这个库本质上不是魔法,而是一套把"动态渲染"收敛成"受控渲染"的工程模板,里面的很多设计决策,你完全可以照着抄到自己的项目里。

1. 先搞清楚 json-render 在 Generative UI 里到底解决了哪一环

1.1 Generative UI 的完整链路,卡点其实在最后一步

Generative UI 这两年炒得很热,但拆开看,链路无非这么几段:用户输入请求、模型做推理、推理结果转成某种结构化描述、客户端把描述渲染成界面。前两段是 AI 能力的范畴,后两段说白了就是工程问题。json-render 在最后一段——它不负责模型怎么想,只负责"描述"变成"组件树"这一跳。

我见过不少团队在这一跳上反复折腾。最原始的做法是让模型直接输出 JSX 代码字符串,前端 eval 掉或者用 Babel 现场编译。快是真快,但基本没法上线:如果模型字段写错,渲染层直接崩给用户看;如果模型输出了危险的 props 或者超深的嵌套,你连拦截的时机都没有。另一种做法是让模型调前端暴露的"页面配置接口",把界面拆成 page/block/component 三层 JSON,前端拿到 JSON 后逐层 switch-case 匹配组件。这个思路安全,但每加一个组件就要改渲染逻辑,扩展性很差。

json-render 的做法是介于两者之间的一种"受控运行时":模型输出的是结构化的 JSON 节点,渲染器负责把节点映射为组件、把 props 做一层白名单校验、再把 children 递归渲染成子元素。整个过程像是一个解释器,但解释规则由前端团队自己定义。这也是我把它的源码拿出来解的原因——它不是简单的 JSON.parse,而是一整套工程化协议。

1.2 为什么不用 vdom 序列化,也不直接用 JSX

很多人会问:React 本身就有 renderToString、有 vdom,为什么不直接把 vdom 序列化给模型学?这就要说到一个核心矛盾:vdom 是给代码执行用的,里面塞了太多运行时信息(fiber 节点、事件绑定、ref 引用),你把这些序列化出去,既不利于模型学习规律,也不利于安全管控。JSON 描述是给"意图"看的,它应该只说"我要一块卡片、标题是 XX、操作是点击后弹窗",而不是说"这个 fiber 节点的 alternate 指向哪"。

同样地,直接让模型出 JSX 看起来最自然,但 JSX 是需要执行能力的。执行就有两个逃不掉的问题:一是命名空间,模型写了个<Foo>,你的应用里得有对应且可信的组件;二是副作用,JSX 的 props 里随时可以藏dangerouslySetInnerHTML,甚至可以引用任意全局变量。json-render 用一套 schema 把组件名收敛成 registry 里的 key,用一套白名单把 props 过滤成协议允许的字段,本质上是在"模型自由表达"和"前端安全执行"之间拉了一条明确的线。

2. 源码脉络:从入口函数到渲染器核心,代码量并不大但分层很讲究

2.1 包结构里的职责边界

从公开仓库的 src 目录看,这个库的源码只有几个文件,但我认为分层比很多大型框架都要清楚。入口index.ts只做两件事:导出createRenderer和类型定义;schema.ts负责 JSON 节点的规范化和校验;registry.ts实现组件注册表,也就是把组件名映射到真实组件的"索引";renderer.tsx是核心渲染逻辑,负责把规范化后的节点转换成 React 元素;hooks.ts则暴露了useRender/useRenderedNode之类的辅助 hooks,让业务方可以不用手动管生命周期。

这个分层给我的第一感觉是:它把"描述是什么"和"描述怎么变成 UI"彻底分开了。schema 层只关心 JSON 的结构对不对,比如 type 字段是不是合法、children 是不是数组、props 是不是对象;渲染层只关心组件到元素的映射;registry 层只管注册和查表。互不跨界,所以每层都能被单独测试。

我记得在看一个国产 UI 框架的源码时,最大的问题就是"解析"和"渲染"搅在一起:既要判断节点类型,又要处理事件绑定,还要塞样式,一个函数上百行。json-render 没有重蹈这个覆辙,每层的函数都不大,读起来非常顺。如果你自己也要写声明式渲染器,我建议先按这个思路把职责切开,否则后面加安全校验、加缓存、加 trace 的时候会非常痛。

2.2 核心数据结构:UiNode 就是整棵树的"语法单元"

要理解渲染器,先要理解它遍历的数据长什么样。源码里有一个核心联合类型,我根据记忆梳理如下:

type UiNode = | { type: 'component'; name: string; props?: Record<string, unknown>; children?: UiNode[]; } | { type: 'literal'; value: unknown }; interface RenderOptions { components: ComponentMap; schema?: JsonSchema; eventHandler?: (event: string, payload?: unknown) => void; maxDepth?: number; onError?: (error: RenderError, node: UiNode) => void; }

component节点代表一个组件实例,name是它在注册表里的索引;literal节点代表纯文本、数字、布尔这类直接渲染的值。这个设计很聪明——它把"组件"和"内容"分开了,模型可以在组件树里穿插任意文字内容,而渲染器只需要对component节点做组件查表,对literal节点直接渲染。

某种意义上,这个结构很像一个骨架。组件是骨架的关节,literal 是填充的血肉,children 定义嵌套关系。后面渲染器所有逻辑,都是围绕遍历这棵树展开的。

3. 渲染器的三步转换:从 JSON 节点到 React 元素,中间隔着一层可控抽象

3.1 第一步:节点规范化,容忍模型输出的"脏数据"

生成式模型的输出有个特点:大概率符合协议,但偶尔会给你夹带私货。比如 props 里出现null、多个 children 挤在对象里、或者 type 字段大小写不稳定。json-render 的第一步就是normalize,把模型可能产生的偏差收敛成标准格式。

从源码的 renderer 入口看,它的处理是先调用 schema 层做一个规范化:

function normalizeNode(raw: unknown): UiNode | null { if (raw === null || typeof raw !== 'object') { return { type: 'literal', value: raw }; } if (Array.isArray(raw)) { return { type: 'literal', value: raw }; } const node = raw as Record<string, unknown>; if (node.type === 'component' && typeof node.name === 'string') { return { type: 'component', name: node.name, props: isPlainObject(node.props) ? node.props : {}, children: normalizeChildren(node.children), }; } // 无法识别的节点,降级为 literal,而不是直接抛错 return { type: 'literal', value: raw }; }

注意这里最关键的设计是"降级"而不是"抛错"。很多没有经验的渲染器拿到一个不符合预期的节点,第一反应就是 throw new Error,但在生成式场景里,你面对的不是用户写死的 config,而是模型概率输出的结果,一个节点坏了就整棵树崩溃,体验非常糟糕。json-render 的容错策略是:尽量把不可识别的内容当作 literal 渲染出来,让用户至少能看到"模型想说但没说对"的东西。

3.2 第二步:组件解析与 props 清洗,核心是"引用"而非"执行"

节点规范化之后,渲染器进入resolveComponent阶段。这一步要做的事情很纯粹:拿着name去注册表里查组件,查到了就继续,查不到就走 onError 或降级方案。这里值得注意的是,源码里的注册表不是一个简单的Record<string, React.FC>,而是一个封装了"按需解析"能力的 Proxy Factory。

为什么用 Proxy?因为模型输出的组件名无法预测,你不可能在注册表里提前 import 所有组件。json-render 的 registry 利用 Proxy 的get拦截,把组件名解析延迟到真正渲染时,同时支持异步加载代码分割的组件。这种做法和常见依赖注入容器里的 factory 机制很像,本质是把"名字到实例"的映射做成一层可扩展的委托。如果你看过 spring 底层关于 proxy factory 的设计,再看这个 registry 会非常眼熟:都是在调用点与真实对象之间加断点。

组件解析之后还有一个关键步骤——props 清洗。模型可不会关心你的组件 props 里哪些字段是危险的,比如style对象里塞了position: fixed还好,塞了backgroundImage: url(javascript:...)就是另一个故事了。json-render 的做法是给每个注册组件附带一个可选的 propsSchema,渲染时按照 schema 做一次过滤:

function sanitizeProps( rawProps: Record<string, unknown>, schema: ComponentSchema ): Record<string, unknown> { const result: Record<string, unknown> = {}; for (const key of Object.keys(rawProps)) { if (schema.allowedProps && !schema.allowedProps.includes(key)) continue; if (schema.coerce?.style && key === 'style' && isPlainObject(rawProps[key])) { result[key] = filterSafeCss(rawProps[key] as Record<string, unknown>); continue; } result[key] = rawProps[key]; } return result; }

3.3 第三步:递归渲染与 children 注入,一棵树的成长过程

组件解析完了,props 也清洗完,剩下就是"套娃"。用一段极简伪代码可以还原渲染器核心:

function renderNode(node: UiNode, ctx: RenderContext): React.ReactNode { if (node.type === 'literal') return node.value as React.ReactNode; const Component = resolveComponent(ctx.registry, node.name); if (!Component) { ctx.onError?.(new RenderError('component not found', node.name), node); return null; } const children = (node.children ?? []) .map((child) => renderNode(child, ctx)) .filter((child) => child !== null && child !== undefined); return React.createElement(Component, { ...sanitizeProps(node.props ?? {}, getSchema(ctx.registry, node.name)), children, key: ctx.createKey(node), }); }

这段代码里,key的生成是一个容易被忽略的工程细节。因为整棵树是每次请求动态生成的,如果 key 不稳定,React 会频繁卸载重挂。json-render 的做法是基于节点在树中的路径生成 key,比如0-1-2,这样只要结构稳定,key 就稳定,Diff 性能就能保住。

另外,递归渲染并不意味着无限递归。RenderOptions 里的maxDepth会在每次进入renderNode时递减,超过阈值就直接渲染一个降级组件或者忽略子树,防止模型一不小心输出一个 1000 层的嵌套导致页面卡死。这个防护对于生成式 UI 来说不是锦上添花,是必须的。

4. 工程化设计的四个关键决策:为什么它敢把渲染逻辑交给模型

4.1 类型安全:让生成器也"受控"

很多人觉得,JSON 渲染器的类型系统不好做,因为数据来自运行时,TypeScript 只能约束"描述本身"的类型,约束不了"渲染结果"的类型。json-render 的解法是定义一套泛型的组件描述类型——ComponentSchema里不仅声明了name,还声明了props的字段类型、必填项、枚举值。渲染时虽然是在运行时校验,但开发阶段你可以satisfies的方式让自定义组件与 schema 强绑定,写错字段名直接编译报错。

这个设计在团队协作里特别有价值。做 AI 前端的人经常遇到"模型改了 prompt,前端不知道"的问题,而有了类型约束,模型产出的 props 和组件 schema 一旦对不上,开发者在联调时就能立刻发现,而不是等部署上线、用户在聊天框里点到错误才爆出来。

有一点要坦白:这类运行时校验不可能做到和 TypeScript 一样强。但我个人经验是,有了这层 schema 之后,模型输出出现越界字段的概率会大幅下降,因为每次失败的渲染都会被 onError 打点,把这些错误样本喂回 prompt 优化,等于让渲染器变成训练数据的过滤器。

4.2 安全边界:白名单、URI 协议过滤与深度上限

把渲染权交给一个大模型,最害怕的不是它渲染错,而是它渲染出不该渲染的东西。json-render 的安全体系大概是三层:

第一层是组件白名单。注册表里没注册的组件,一律不渲染,返回 null 并打错误日志。第二层是 props 协议的过滤,比如我在 3.2 里提到的filterSafeCss,它会把url()里的非 http/https 协议直接抹掉,也会把position: fixed这类可能导致遮罩霸屏的样式做可配置的移除。第三层是深度与节点数量的双重上限。

这三层不是防御一个恶意的攻击者,而是防御一个"不太聪明的模型"。模型可能只是没学明白,就把 CSS 写成了一个奇怪的字符串,或者把一个列表循环嵌套了 50 层。渲染器的职责就是把这些偏差拦截在视觉崩溃之前。

我看过一些团队的实现只做了组件白名单,其实远远不够。真正的安全边界必须延伸到 props 和样式层,比如style里可以塞background-image,配合一个data:URI 就能把你的页面搞花,而这些在 JSON 里看起来完全无辜。

4.3 性能:memo 化、浅比较与受控更新

json-render 在性能上有一个天然劣势:每次模型输出一个新的 JSON,理论上整棵树都要重新渲染。如果模型只是改了一个标题,你却让整个页面全部重跑,在长聊天场景里会非常卡。

源码里对应的处理有两处。一处是对渲染结果做 memo:useMemo的依赖是规范化后的节点引用和组件注册表的版本号;另一处是对自定义组件做一个浅层 props 比较的缓存。也就是说,如果你的组件是纯展示的,且 props 没变,即使父节点重新渲染,子组件也不会跟着重跑。

还有一点很实际:createElement的性能其实大头不在创建本身,而在 Diff。json-render 通过稳定 key(路径 key)让 Diff 范围变小,这比任何微优化都有效。你在自己的实现里如果发现渲染变慢了,第一反应不应该是加 useCallback,而应该是检查 key 是否稳定。

4.4 可观测性:错误边界与 trace,生成式渲染的可信度靠日志建立

Generative UI 的调试有一个普遍痛点:渲染出来的界面不对,你不知道是模型的问题、是 JSON 结构的问题、还是组件 props 的问题。json-render 的onError回调只是第一步,更重要的是它内部会给每个渲染任务打一个 trace id,并记录节点路径、组件名、耗时和错误信息。

这种 tracing 的思路是从后端监控系统借鉴来的。前端渲染通常没有 trace 的概念,但生成式 UI 恰恰最需要:因为每一次渲染都等于一次"线上回归测试"。配上 trace 以后,如果模型连续三次都在同一个节点上渲染失败,你就能在日志里看到明确的节点路径和 schema 校验的错误类型,直接定位。

我在自己接入的时候就吃过一个亏:最开始没配 onError,结果模型在某个节点上一直渲染 null,页面一半是空的。直到我打了日志才发现是那个组件的 propsSchema 写错了,把items写成了item。没有 trace,你根本不知道是模型的问题还是自己的问题。

5. 实际接入时绕不开的几个坑:事件绑定、schema 粒度、嵌套失控与样式归一化

5.1 事件处理器怎么在 JSON 里传:指令 vs 回调名

这是每一个 JSON 渲染器都要回答的问题:模型渲染了一个按钮,按钮点击之后该干什么?JSON 里不能直接放函数,所以 json-render 采用的是一种"事件指令"协议。在 props 里,模型不直接写onClick: () => fn(),而是写onClick: { $event: 'handleAddItem', payload: { id: 'abc' } }。渲染层看到$event前缀,就把它从普通 props 中剥离,然后绑定到组件的事件 props 上,真正的事件处理函数则来自RenderOptions里传入的eventHandler。

这个设计的妙处是把"模型能触发什么"和"前端怎么响应"彻底解耦。模型只需要知道"按钮可以触发 handleAddItem 这个动作",至于这个动作是打开弹窗、调用 API 还是跳转路由,完全由前端在eventHandler里决定。

我见过有些团队图省事,让模型直接传onClick: "alert(1)",然后在渲染端 eval。这基本等于把浏览器安全当玩笑。如果将来你的渲染器要支持事件,我强烈建议走指令协议,别走字符串执行。

5.2 schema 太严,模型输出被频繁拒绝;太松,渲染四处破洞

这个坑非常经典。json-render 的 propsSchema 如果字段类型写得过于严格,比如number就必须是integer且范围在 0-100,模型稍微输出一个浮点数,整段渲染就失败。但如果完全放开,又会出现组件拿到undefinedprops 导致崩溃。

我的经验是 schema 的粒度要控制在"不会崩"和"容忍偏差"之间:必填但无关安全的字段可以放宽;会影响渲染结构与安全的字段(比如type枚举、children数组、href协议)必须严格。

5.3 嵌套过深导致的死循环与卡顿:深度上限和节点总数都要管

递归渲染最怕的不是深度大,而是深度大且每个组件都很重。json-render 的 maxDepth 默认值我记得是不超过 20,这个值对绝大多数对话式 UI 足够。更聪明的方案是同时跟踪节点总数:深度只有 10,但每个节点都带 100 个 children,总数一样能卡死页面。你可以在renderNode里维护一个计数器,超过阈值就停止展开,这个策略和 React 的"无限列表"懒渲染是同一个出发点。

5.4 样式方案的归一化:模型不知道你的 Tailwind 版本

最后一个坑是样式。模型学到的 CSS 概念和前端实际使用的方案经常对不上。它在 JSON 里写了style: { padding: '16px' },你的设计系统用的是 Tailwind 原子类,根本没有内联样式习惯。json-render 在 props 清洗阶段会单独处理 style 对象,你可以注册一个normalizeStyle回调,把模型输出的 camelCase 对象转成你需要的原子类或者 CSS 变量。

这样设计的好处是,模型层的 UI 描述保持"通用语义",而前端的主题系统保持"私有实现"。两者之间由归一化层翻译。这也是 json-render 这类渲染器和传统低代码平台的核心差异:它不要求模型"会说你的语言",而是提供一个翻译层,把模型的通用表达翻译成前端方言。

6. 在业务中落地 json-render:以"接入自己的组件库"为例做扩展

6.1 组件注册表怎么写,才能支持按需加载

如果你的业务需要接入自己的组件库,最直接的做法是拿到 json-render 后写一个 registry 配置。但请记住,不要一次性把所有组件塞进一个 Record 里,这会让首屏包体瞬间膨胀。用 Proxy 实现的注册表天然支持这种写法:get拦截里判断组件名,返回一个lazy(() => import('./components/' + name))包装过的组件。

如果是给 AI 聊天页面用的,我建议注册的组件不要超过 20 个,太多了模型的输出熵会增加,表现会不稳定。组件越少,模型越容易学到正确的调用方式,渲染成功率越高。这不是 json-render 的限制,是所有生成式 UI 的经验法则。

6.2 流式输出怎么配合:一边生成一边渲染的节奏控制

Vercel Labs 的很多项目是围绕 AI SDK 展开的,json-render 也天然支持流式场景。模型通过 stream 输出 JSON 片段,渲染端不会等整个 JSON 全部到齐才渲染,而是每收到一个完整的顶层节点,就局部 patch 一次渲染树。

这里要注意的是,流式场景里频繁重渲染的压力比一次性渲染大得多。建议给渲染器加一个"节流"机制:比如每 50ms 强制 flush 一次,而不是每次 chunk 都触发 setState。这个机制在源码里对应的是一个受控的调度队列,业务方可以通过 options 调整节流窗口。

6.3 服务端渲染的几个额外考虑

如果你想把 json-render 用在 SSR 场景,有几件事和纯客户端不同。第一,eventHandler在服务端不存在,你要把事件指令在序列化阶段就过滤掉,或者把事件回调映射成某种 postMessage 协议传给前端。第二,样式归一化在服务端要做成纯函数,不能依赖window。第三,错误降级策略要更保守:服务端一个节点渲染失败,不应该让整页 500,应该降级成可标记的错误占位符,毕竟 AI 生成的东西天然有不确定性。

7. 写到最后:这个东西并不神秘,但很有启发

json-render 的源码读完后,我最深的感受是:它没有发明任何黑科技,所有的技术点——Proxy 注册表、schema 校验、递归渲染、错误边界——都是前端和 Node 生态里早就有的东西。它真正厉害的地方是把这些组合成了一台面向"模型输出"的稳定渲染机器,并且每一层的职责都非常清楚。

如果你也要做类似的东西,我的建议是不要一上来就追求把所有功能都塞进渲染器。先搞清楚两个问题:一是你能接受的"渲染失败"长什么样(直接崩、降级占位、还是静默忽略);二是模型输出的不可控性会在哪个环节造成最大伤害(节点结构、props 类型、还是样式注入)。把这两个问题想清楚,你就能自己设计出这套渲染管线的骨架,json-render 的源码充其量是一个参考坐标。

我个人实际用下来,还有一个小技巧可以分享:把 onError 里收集到的失败样本直接接回 prompt 构造逻辑。比如模型输出了heading组件但注册表里只有typography,你可以在错误日志里看到这个信号,然后在下一轮 prompt 里补一句"标题组件必须叫 typography,不叫 heading"。这种"渲染器反馈驱动 prompt 进化"的闭环,比任何人工调 prompt 都来得快。生成式 UI 的难点从来不在渲染本身,而在让整套系统知道自己错在哪、怎么改——而 json-render 的源码,恰好把"知道错在哪"这个能力正式化了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询