背景
过去两年,AI 生成 UI 的实践基本集中在两种路径上。第一种是直接让模型生成 JSX、HTML 或 CSS。这条路线的优势在于自由度极高,模型几乎不受约束,看起来"什么都能写"。但在真实工程环境中,这种方式几乎不可控:输出结构不稳定,无法保证组件边界,难以做权限与审计控制,生成的代码经常无法编译或违背工程约定,更重要的是,它与实际业务中的组件体系和设计系统严重脱节。
另一条路线是低代码或 schema 驱动 UI,例如基于 JSON Schema 或表单 schema 的方案。这类方案在工程上是可控的,结构稳定、可校验、可复用,但它们本质上是为"人编写配置"设计的,而不是为"模型生成结构"设计的。schema 表达能力有限,扩展成本高,并且与自然语言之间的映射并不自然,Prompt 往往需要大量人工约束。
Vercel 刚刚开源了 json-render,json-render 的出现,本质上是对这两条路线的重新切分与组合。它并没有试图让 AI 写前端代码,也没有把 AI 限制在传统低代码 schema 中,而是引入了一个中间层:JSON UI AST。AI 只能生成这种 AST,而 AST 的能力边界完全由开发者定义。渲染、状态、行为解释全部留在业务侧完成。开发者因此可以安全地让用户通过自然语言生成仪表盘、小部件或数据视图,而不需要把执行权交给模型。

整体架构:json-render 是一个 DSL 解释系统
从架构视角看,json-render 并不是一个 UI 框架,而是一个 DSL 执行系统。系统由三层构成:最底层是 Catalog,用来声明"系统允许 AI 使用哪些 UI 能力";中间层是 JSON UI Tree,这是 AI 的唯一输出形式;最上层是 Renderer,由业务侧实现,用于解释 JSON 并渲染真实 UI。
它们之间的关系可以用下面这张结构图来理解:
┌────────────┐
│ Prompt │
└─────┬──────┘
│
▼
┌──────────────────┐
│ LLM / AI Model │
└─────┬────────────┘
│ JSON UI AST(受 Catalog 严格约束)
▼
┌──────────────────┐
│ Catalog │ ← 能力白名单 / Schema / Grammar
└─────┬────────────┘
│ 校验 + 解析
▼
┌──────────────────┐
│ Renderer │ ← React / Vue / Native
└─────┬────────────┘
│
▼
┌──────────────────┐
│ Real UI View │
└──────────────────┘
在这个模型中,AI 只参与"结构生成",不参与"执行"。这也是 json-render 在工程上成立的根本原因。
从 Catalog 到 UI
1. Catalog:系统的能力边界定义
下面这段代码是整个系统中最重要的入口,它定义了 AI 能使用的全部 UI 能力。
ts
import { createCatalog } from '@json-render/core'
import { z } from 'zod'
export const catalog = createCatalog({
components: {
Card: {
props: z.object({
title: z.string()
}),
hasChildren: true
},
Metric: {
props: z.object({
label: z.string(),
valuePath: z.string(),
format: z.enum(['number', 'currency', 'percent']).optional()
})
}
},
actions: {
refresh: {
params: z.object({})
}
}
})
这里没有任何 UI 代码,只有能力声明。props 使用 Zod 定义,这意味着它不仅是类型提示,还包含运行时校验规则。如果你对 Zod 没有了解,可以看看这篇博文,Zod:TypeScript 类型守卫与数据验证。action 并不是函数实现,而是一个"意图声明",它只描述"可以发生什么",不描述"怎么发生"。
Catalog 在系统中的地位,相当于一门语言的语法定义文件。AI 后续生成的所有 JSON,本质上都必须符合这套 grammar。
2. AI 输出的 JSON UI AST
当用户输入类似"生成一个收入仪表盘"的提示时,模型生成的结果不是 JSX,而是下面这样的 JSON:
json
{
"type": "Card",
"props": { "title": "Revenue Overview" },
"children": [
{
"type": "Metric",
"props": {
"label": "Total Revenue",
"valuePath": "/metrics/revenue",
"format": "currency"
}
}
]
}
这个 JSON 有几个非常关键的特征。它不包含任何函数、不包含条件表达式、不包含样式或状态逻辑。它只是结构化地描述"使用哪个组件,用什么参数,组件之间如何嵌套"。所有能力完全来源于 Catalog,因此这个 JSON 是可校验、可存储、可 diff、可审计、可回放的。
3. Renderer:JSON 的解释执行
在 React 侧,Renderer 扮演的是解释器的角色。
tsx
import { Renderer } from '@json-render/react'
import { catalog } from './catalog'
function App() {
return (
<Renderer
catalog={catalog}
components={{
Card: ({ title, children }) => (
<div className="card">
<h2>{title}</h2>
{children}
</div>
),
Metric: ({ label, value }) => (
<div>
{label}: {value}
</div>
)
}}
data={{
metrics: { revenue: 120000 }
}}
/>
)
}
Renderer 并不关心 UI 长什么样,它只做三件事:根据 type 找到对应组件定义,根据 Catalog 校验 props 和 children,根据 valuePath 等规则完成数据注入。
为什么 json-render 是"可控的"
下面的借助 AI 能力分析基于 vercel-labs/json-render 主仓库。如果你对此不感兴趣,跳过这部分内容。
1. createCatalog:能力被冻结的起点
文件路径位于 packages/core/src/create-catalog.ts。这个函数的核心作用不是"注册组件",而是"冻结能力边界"。
简化后的核心逻辑可以理解为:
ts
export function createCatalog(definition) {
return {
components: definition.components,
actions: definition.actions,
validateNode(node) {
// 校验 type 是否存在
// 校验 props 是否符合 Zod schema
// 校验 children 是否被允许
}
}
}
每一行代码都在服务一个目标:让 Catalog 成为一个不可突破的白名单。Renderer 和 AI 都无法绕过它。这也是为什么 json-render 把 Catalog 放在 core 包中,而不是 React 包中。
2. Schema 校验:AI 输出必须"先编译再执行"
在 JSON Tree 进入 Renderer 之前,系统会逐节点校验。type 是否在 Catalog 中声明,props 是否通过 Zod 校验,children 是否符合 hasChildren 约束,action 是否存在于白名单。这一过程本质上就是一次 AST 校验。
这意味着 AI 的输出不是"运行时报错",而是"不通过即拒绝执行"。在 AI UI 系统中,这是一个极其关键但经常被忽视的工程点。
3. Renderer:真正的解释器模型
React Renderer 的内部逻辑并不是简单的 switch-case,而是一个递归解释过程。它根据节点的 type 查 Catalog,构造 props,解析 valuePath 注入数据,绑定 action handler,然后递归渲染 children。
从架构角度看,它更接近一个 JSON AST Interpreter,而不是模板引擎。这也是 json-render 可以跨 React、Vue、Native 复用核心思想的原因。
4. valuePath:刻意避免 AI 参与状态逻辑
valuePath 使用字符串路径描述数据依赖,例如:
json
"valuePath": "/metrics/revenue"
这样设计的直接结果是,AI 不需要理解状态结构,也不需要写任何状态逻辑。Renderer 统一负责解析路径、读取数据、触发更新。这在架构上刻意切断了"AI 直接操作状态"的可能性。
下面是仅包含新增内容的补充章节,重点放在可落到源码层面的机制,避免概念化描述。示例代码与解释均基于 vercel-labs/json-render 当前仓库结构与实现思路。
Prompt 与 Catalog 的自动对齐
Prompt 与 Catalog 的自动对齐:不是"调 Prompt",而是"导出 Grammar"。json-render 中,Prompt 与 Catalog 的对齐并不是通过人肉 Prompt Engineering 完成的,而是通过从 Catalog 派生一份机器可理解的能力描述,并将其注入到模型上下文中。这一点在 packages/core 中的设计非常关键。
在 core 层,Catalog 本身并不是一个简单的对象,它包含了完整的组件定义、props schema 以及 action 描述。这些信息会被转换为一种"描述性结构",用于告诉模型当前系统支持的 UI grammar。
类似这样的逻辑:
ts
export function catalogToPrompt(catalog) {
return `
You can generate a JSON UI tree.
Available components:
${Object.entries(catalog.components).map(([name, def]) => `
- ${name}
props: ${describeSchema(def.props)}
hasChildren: ${def.hasChildren}
`).join('\n')}
Available actions:
${Object.keys(catalog.actions).join(', ')}
Rules:
- Output must be valid JSON
- Only use listed components
- Follow prop schemas strictly
`
}
这里的关键点不在于字符串本身,而在于信息来源完全来自 Catalog。换句话说,Catalog 是 single source of truth,Prompt 只是它的一种序列化视图。当开发者新增或修改组件定义时,Prompt 中允许模型使用的能力会自动发生变化,不存在"代码和 Prompt 不一致"的问题。这也是 json-render 能够避免大量"Prompt 腐化"的根本原因。
从模型视角看,它面对的不是一段模糊的自然语言说明,而是一套接近 BNF 的 UI grammar 描述。模型生成 JSON UI Tree 的过程,本质上类似于在给定语法约束下生成 AST。这也是为什么 json-render 要使用 Zod 而不是仅靠 TypeScript 类型。Zod schema 可以被同时用于运行时校验和 Prompt 语义描述,形成闭环。
Streaming UI 的实现细节
流式构建 AST,而不是流式拼字符串。json-render 的 Streaming UI 能力,核心并不在"模型支持流式输出",而在于 UI 的中间表示是可增量合并的 JSON AST。这一点在 React 包中的实现非常清晰。
在 packages/react 中,可以看到类似 useUIStream 的 hook,其核心职责是:
维护一棵当前 UI Tree,并在模型流式输出时不断向这棵树中合并新节点。
简化后的内部结构大致如下:
ts
// packages/react/src/use-ui-stream.ts(概念结构)
export function useUIStream() {
const [tree, setTree] = useState<UITree | null>(null)
function onChunk(chunk: string) {
const partialNode = parseChunkToNode(chunk)
if (!partialNode) return
setTree(prevTree => {
return mergeTree(prevTree, partialNode)
})
}
return { tree, onChunk }
}
这里有两个非常关键但容易被忽略的点。
parseChunkToNode 并不是简单的 JSON.parse。模型在 streaming 模式下输出的通常是不完整 JSON,因此 json-render 采用的是逐段解析、延迟成型的策略。只有当一个节点在结构上是完整且通过 Catalog 校验时,才会被提升为"可合并节点"。mergeTree 是一个纯函数。它不依赖外部状态,只根据已有 UI Tree 和新节点生成下一棵 Tree。这使得每一次更新都是确定性的,也天然适合 React 的状态模型。
在 Renderer 层,这棵 Tree 会被直接用于递归渲染:
tsx
function RenderNode({ node }) {
const Component = components[node.type]
const resolvedProps = resolveProps(node.props)
const children = node.children?.map(child =>
<RenderNode key={child.id} node={child} />
)
return <Component {...resolvedProps}>{children}</Component>
}
由于 Tree 始终是"已校验的合法结构",Renderer 不需要关心节点是否完整,只需要关心"当前有哪些节点已经存在"。这也是 Streaming UI 能在生成未完成时就安全渲染的根本原因。
Streaming 与 Catalog 校验如何协同工作
Streaming UI 并不是绕过校验机制的捷径,恰恰相反,它依赖校验机制才能成立。在实际流程中,每一个候选节点在被合并进 UI Tree 之前,都会经过 Catalog 的校验逻辑:
ts
// packages/core/src/validate-node.ts(概念结构)
export function validateNode(node, catalog) {
const def = catalog.components[node.type]
if (!def) throw new Error('Unknown component')
def.props.parse(node.props)
if (node.children && !def.hasChildren) {
throw new Error('Children not allowed')
}
}
Streaming 模式下,这个校验发生得更频繁,但粒度更小。系统宁可"暂时不渲染",也不会把一个非法节点交给 Renderer。这保证了 UI 在任何时刻都是一个合法子集,而不是半成品垃圾状态。
Prompt 与 Catalog 的自动对齐,确保模型"不会幻想不存在的能力";Streaming UI 的 AST 级增量构建,确保 UI"可以在不完整时仍然正确运行"。两者结合,使 json-render 的执行模型更接近编译器与解释器,而不是模板生成器。从工程视角看,这意味着一个重要转变:**UI 生成不再是一次性结果,而是一个可观察、可中断、可回滚的过程。**这也是 json-render 能够真正进入生产系统,而不仅停留在 Demo 层面的根本原因。
json-render 真正解决了什么
json-render 本身并不是一种全新的技术范式。**"用受限结构描述 UI,再由运行时解释执行"这一思想,在前端工程中早已反复出现过。**早期的 JSON Schema Form、react-jsonschema-form、Formily、本质上都是用结构化数据描述界面,再由渲染器生成真实 UI。低代码平台、搭建系统、配置化后台,几乎全部建立在同一逻辑之上。即便在 AI 出现之前,这种模式也已经非常成熟:工程师通过 schema 描述组件、属性和布局,运行时负责校验与渲染,业务侧只操作结构而不直接触碰代码。json-render 并没有发明这种模式,它继承的正是这一整条技术脉络。
json-render 的不同之处在于,它首次把"模型生成"作为一等公民纳入设计前提。传统 schema UI 假设配置由人编写,因此更强调完整性、可读性和编辑体验;而 json-render 假设结构由模型生成,因此更强调语法边界清晰、失败可恢复、部分结果可执行,以及与 Prompt 的自动对齐能力。从这个角度看,json-render 更像是"为 AI 重新设计的一代 schema UI 执行模型"。它真正解决的问题并不是"怎么用 JSON 渲染 UI",而是当结构来源变成不可靠的模型时,工程边界应该在哪里。它给出的答案非常明确:AI 只负责生成结构化意图,工程师负责能力定义、执行与渲染,JSON 作为唯一中介和约束层。这使得 AI UI 不再是一次性 Demo,而是可以进入生产系统的工程能力。在当前阶段,这是少数真正站在工程立场思考 AI UI 的方案之一。