本文由「无忆研习社」原创,转载请注明出处。深度剖析 Vercel Labs 最新开源项目 json-render 的核心原理、实战操作与最佳实践。
写在前面
最近在技术圈里,有个项目引起了我的强烈关注——Vercel Labs 开源的 json-render。这个项目上线短短几天,GitHub Star 数就突破了 6000,足见开发者社区对它的认可度。
作为一个常年混迹前端开发圈的老兵,我深知 AI 生成前端代码这件事有多让人头疼。相信正在阅读「无忆研习社」这篇文章的你,大概率也遇到过类似的问题:让 AI 写个页面,结果样式乱七八糟;让它生成个组件,结果各种奇怪的安全隐患冒出来。
今天这篇文章,我会把 json-render 从头到尾拆解一遍,手把手教会你怎么用它来驯服那些"不听话"的 AI 生成代码。文章有点长,建议收藏起来慢慢看。
一、为什么需要 json-render?先搞清楚问题在哪
1.1 AI 生成前端代码的三大痛点
在深入了解 json-render 之前,我们先来复盘一下当前 AI 生成前端代码存在的核心问题。只有理解了问题的本质,才能明白这个解决方案的精妙之处。
痛点一:样式不一致,界面混乱
当你使用 Claude Code、Cursor 或者其他 AI 编程工具时,有没有发现一个问题:同一个项目里,AI 生成的两个页面风格可能完全不搭?
举个例子,你让 AI 生成一个用户列表页面,它可能用了 Tailwind CSS 的某种配色;然后你再让它生成一个订单管理页面,它突然换了另一套配色方案。结果呢?整个项目看起来像是七拼八凑的"缝合怪"。
这还只是表面问题。更深层的是,AI 有时候会随意组合不兼容的 UI 组件。比如把 Material UI 的按钮和 Ant Design 的表格混在一起用,导致样式冲突、布局错乱。
痛点二:代码不规范,安全隐患多
这是更严重的问题。AI 在生成代码时,不会主动考虑安全规范。它可能会:
- 直接用
dangerouslySetInnerHTML渲染用户输入的内容,造成 XSS 攻击风险 - 生成的表单没有做任何输入校验
- 随意引入第三方脚本
- 创建一些根本不存在的"幻想组件"
这些问题在开发阶段可能看不出来,但一旦部署到生产环境,轻则页面崩溃,重则造成数据泄露。
痛点三:生成的界面是"死"的
传统 AI 生成的界面往往是静态的。你让它画一个仪表盘,它确实画出来了,但里面的数据是写死的假数据。想要连接真实的后端接口?对不起,你得自己手动改一遍代码。
这就导致了一个尴尬的局面:AI 看起来帮你省了时间,但实际上你还是要花大量时间去"修补"它生成的代码。
1.2 传统解决方案为什么不行
针对上面这些问题,业界其实尝试过很多方案:
- 方案一:用更详细的 Prompt——效果有限,而且 Prompt 越长,AI 越容易"遗忘"前面的要求
- 方案二:使用设计系统文档作为上下文——上下文窗口有限,塞不下多少文档
- 方案三:多轮对话修正——耗时耗力,而且经常修着修着把之前正确的地方改坏了
这些方案的共同问题是:它们都在试图"教育"AI,让 AI 自己变得更聪明、更规范。但 AI 本质上就是一个概率模型,你没办法100%保证它每次都按你的要求输出。
1.3 json-render 的解题思路:降维打击
Vercel 的工程师们换了一个思路:既然控制不了 AI 的输出,那就限制它能输出什么。
这就好比你让一个小孩子画画。如果给他一盒96色的彩笔和一张白纸,他画出来的东西可能五花八门、天马行空。但如果你给他一本填色画册,里面已经画好了轮廓,他只能在指定区域涂色,那最后的作品至少不会太离谱。
json-render 就是这个"填色画册"。它把 AI 的工作从"自由创作"变成了"标准化填空":
- AI 不再需要写完整的 React/Vue 代码
- AI 只需要输出一份符合规范的 JSON 数据
- 这份 JSON 数据描述了"用什么组件、显示什么内容、怎么排列"
- 渲染引擎读取这份 JSON,自动生成对应的 UI
这个流程可以简化为:AI → JSON → UI
看起来多绕了一层对不对?但正是这一层"中间商",彻底解决了前面提到的三大痛点。
二、深入理解 json-render 的核心原理
2.1 核心概念一:Catalog(组件目录)
Catalog 是 json-render 最核心的概念,没有之一。
你可以把 Catalog 理解为一份"白名单"或者"菜单"。它明确告诉 AI:"这里有一份组件清单,你只能使用清单上列出的组件,不能自己瞎编。"
来看一个具体的例子:
import { createCatalog } from '@json-render/core';
import { z } from 'zod';
const catalog = createCatalog({
components: {
// 定义一个 Card 组件
Card: {
props: z.object({
title: z.string() // 只接受 title 这一个属性
}),
hasChildren: true, // 可以包含子组件
},
// 定义一个 Metric 数据展示组件
Metric: {
props: z.object({
label: z.string(),
valuePath: z.string(), // 绑定数据的路径
format: z.enum(['currency', 'percent', 'number']),
}),
},
// 定义一个 Button 按钮组件
Button: {
props: z.object({
label: z.string(),
action: ActionSchema, // 按钮点击后的动作
}),
},
},
// 定义允许的操作
actions: {
export_report: { description: '导出报表为 PDF' },
refresh_data: { description: '刷新所有数据' },
},
});
这段代码做了什么?
- 定义了三个组件:Card、Metric、Button
- 每个组件都有明确的属性规范(用 Zod schema 定义)
- 定义了两个允许的操作:导出报表和刷新数据
当 AI 接收到这份 Catalog 后,它就知道:
- 只能使用 Card、Metric、Button 这三种组件
- Card 组件只能设置 title 属性
- Metric 组件可以设置 label、valuePath、format 三个属性
- 按钮只能触发 export_report 或 refresh_data 这两种操作
如果 AI "脑子一抽",想输出一个不存在的组件,或者给组件添加一个没定义的属性会怎样?json-render 的渲染引擎会直接拦截,根本不会渲染出来。
这就是「无忆研习社」想重点强调的第一个核心机制:通过 Schema 约束,让 AI 的输出 100% 可预测。
2.2 核心概念二:JSON Tree(JSON 树)
理解了 Catalog 之后,我们来看 AI 实际输出的是什么。
AI 不再输出 JSX 代码,而是输出一棵 JSON 树。这棵树描述了整个 UI 的结构、每个组件的配置、以及它们之间的层级关系。
举个例子,假设用户说:"帮我创建一个显示收入数据的仪表盘,要有刷新按钮。"
AI 会输出类似这样的 JSON:
{
"type": "Card",
"props": { "title": "收入概览" },
"children": [
{
"type": "Metric",
"props": {
"label": "总收入",
"valuePath": "/data/revenue",
"format": "currency"
}
},
{
"type": "Metric",
"props": {
"label": "增长率",
"valuePath": "/data/growth",
"format": "percent"
}
},
{
"type": "Button",
"props": {
"label": "刷新数据",
"action": { "name": "refresh_data" }
}
}
]
}
这份 JSON 清晰地描述了:
- 最外层是一个 Card 组件,标题是"收入概览"
- Card 里面有两个 Metric 组件,分别显示总收入和增长率
- 还有一个 Button,点击后触发刷新数据的操作
- Metric 组件通过
valuePath绑定了真实数据的路径
注意到没有?AI 完全没有写任何样式代码、没有写任何交互逻辑。它只是"填了一份表格",说明了"我想要什么"。
2.3 核心概念三:Registry(组件注册表)
Catalog 告诉 AI 有哪些组件可用,但这些组件具体长什么样、怎么渲染,是由 Registry 决定的。
Registry 就是你自己定义的真实 React 组件库。每个组件负责接收 JSON 中的配置,然后渲染成实际的 UI。
const registry = {
Card: ({ element, children }) => (
<div className="card bg-white rounded-lg shadow-md p-4">
<h3 className="text-xl font-bold mb-4">{element.props.title}</h3>
{children}
</div>
),
Metric: ({ element }) => {
// useDataValue 是 json-render 提供的 Hook
// 根据 valuePath 自动从数据源获取对应的值
const value = useDataValue(element.props.valuePath);
// 根据 format 类型格式化显示
const formattedValue = formatValue(value, element.props.format);
return (
<div className="metric p-2 border rounded">
<span className="text-gray-500">{element.props.label}</span>
<span className="text-2xl font-bold">{formattedValue}</span>
</div>
);
},
Button: ({ element, onAction }) => (
<button
className="btn btn-primary"
onClick={() => onAction(element.props.action)}
>
{element.props.label}
</button>
),
};
这里的精妙之处在于:
- 样式由你掌控:AI 只说"我要一个 Card",具体 Card 长什么样、用什么 CSS 框架,完全由你在 Registry 里定义
- 数据绑定自动化:通过
useDataValueHook,组件可以自动从数据源获取真实数据 - 交互逻辑集中管理:按钮点击后触发什么操作,由统一的
onAction处理
2.4 核心概念四:数据流与动作处理
前面说过,json-render 生成的界面不是"死"的。这是怎么做到的?答案在于它的数据绑定机制。
数据绑定(Data Binding)
json-render 使用 JSON Pointer 语法(类似 /data/revenue 这样的路径)来引用数据。当你提供了初始数据:
<DataProvider initialData={{
revenue: 125000,
growth: 0.15
}}>
<Renderer tree={tree} components={registry} />
</DataProvider>
Metric 组件就能通过 valuePath: "/data/revenue" 自动获取到 125000 这个值。
更厉害的是,当数据发生变化时,UI 会自动更新。你不需要手动写任何状态管理代码。
动作处理(Action Handling)
同样的道理,按钮点击后要执行什么操作,也是通过集中管理的方式处理:
<ActionProvider actions={{
export_report: async () => {
const pdf = await generatePDF();
downloadFile(pdf);
},
refresh_data: async () => {
const newData = await fetchLatestData();
updateData(newData);
},
}}>
<Renderer tree={tree} components={registry} />
</ActionProvider>
AI 只需要在 JSON 里声明"点击这个按钮执行 refresh_data",具体 refresh_data 做什么,由你在 ActionProvider 里定义。
这样做的好处是:
- AI 没有权限执行任何它想执行的代码,只能从你预定义的操作里选
- 所有的业务逻辑集中在一个地方,方便维护和审计
- 可以轻松添加权限控制,比如某些操作只有管理员才能执行
2.5 原理总结:三层防护机制
到这里,我们可以总结 json-render 的核心原理了。它通过三层防护,确保 AI 生成的 UI 是安全可控的:
第一层:Catalog 约束
- 限定 AI 可用的组件类型
- 限定每个组件可用的属性
- 限定可触发的操作类型
第二层:Schema 验证
- 使用 Zod 验证 JSON 结构
- 类型不对、属性缺失都会被拦截
- 防止 AI 产生格式错误的输出
第三层:渲染隔离
- JSON 只是数据,不包含任何可执行代码
- 真正的渲染逻辑由你的 Registry 控制
- AI 无法注入恶意脚本
这三层防护层层递进,形成了一个"安全沙箱"。AI 在这个沙箱里只能做被允许的事情,完全避免了"AI 乱写代码"的问题。
三、实战指南:从零开始使用 json-render
理解了原理之后,我们来实际动手操作一下。「无忆研习社」的读者应该知道,我写教程一向喜欢从最简单的例子开始,一步步增加复杂度。
3.1 环境准备
首先,你需要有一个 React 项目。如果你用的是 Next.js,那就更好了,因为 json-render 和 Vercel 的技术栈配合得非常好。
创建一个新的 Next.js 项目(如果你已经有项目可以跳过):
npx create-next-app@latest my-json-render-app
cd my-json-render-app
安装 json-render:
npm install @json-render/core @json-render/react
还需要安装 Zod 用于 Schema 定义:
npm install zod
3.2 第一步:定义你的 Catalog
在 src/lib 目录下创建 catalog.ts 文件:
// src/lib/catalog.ts
import { createCatalog } from '@json-render/core';
import { z } from 'zod';
// 定义操作的 Schema
const ActionSchema = z.object({
name: z.string(),
params: z.record(z.any()).optional(),
});
export const catalog = createCatalog({
components: {
// 容器组件
Container: {
props: z.object({
direction: z.enum(['row', 'column']).default('column'),
gap: z.number().default(16),
}),
hasChildren: true,
},
// 卡片组件
Card: {
props: z.object({
title: z.string(),
subtitle: z.string().optional(),
}),
hasChildren: true,
},
// 文本组件
Text: {
props: z.object({
content: z.string(),
variant: z.enum(['h1', 'h2', 'h3', 'body', 'caption']).default('body'),
}),
},
// 数据展示组件
DataDisplay: {
props: z.object({
label: z.string(),
valuePath: z.string(),
format: z.enum(['text', 'number', 'currency', 'percent', 'date']).default('text'),
}),
},
// 按钮组件
Button: {
props: z.object({
label: z.string(),
variant: z.enum(['primary', 'secondary', 'danger']).default('primary'),
action: ActionSchema,
}),
},
// 输入框组件
TextField: {
props: z.object({
label: z.string(),
valuePath: z.string(),
placeholder: z.string().optional(),
type: z.enum(['text', 'email', 'password', 'number']).default('text'),
}),
},
// 列表组件
List: {
props: z.object({
itemsPath: z.string(), // 数据数组的路径
itemKey: z.string(), // 每项的唯一标识字段
}),
hasChildren: true, // 子元素作为列表项的模板
},
// 条件渲染组件
Conditional: {
props: z.object({
condition: z.object({
path: z.string(),
operator: z.enum(['eq', 'ne', 'gt', 'lt', 'exists']),
value: z.any().optional(),
}),
}),
hasChildren: true,
},
},
actions: {
submit_form: { description: '提交表单数据' },
refresh_data: { description: '刷新页面数据' },
navigate: { description: '页面跳转' },
show_modal: { description: '显示弹窗' },
close_modal: { description: '关闭弹窗' },
delete_item: { description: '删除某一项' },
edit_item: { description: '编辑某一项' },
},
});
// 导出类型,方便后续使用
export type CatalogType = typeof catalog;
这份 Catalog 定义了一个基础但实用的组件集合,包括:
- 布局组件(Container)
- 内容组件(Card、Text)
- 数据组件(DataDisplay、List)
- 交互组件(Button、TextField)
- 逻辑组件(Conditional)
你可以根据自己的业务需求添加更多组件。比如做电商项目可以加 ProductCard、CartItem;做数据可视化可以加 Chart、Table 等。
3.3 第二步:创建组件注册表
接下来,我们要实现这些组件的真实渲染逻辑。在 src/components/registry 目录下创建:
// src/components/registry/index.tsx
'use client';
import React from 'react';
import { useDataValue, useDataArray } from '@json-render/react';
// 容器组件
export const Container = ({ element, children }: any) => {
const { direction = 'column', gap = 16 } = element.props;
return (
<div
style={{
display: 'flex',
flexDirection: direction,
gap: `${gap}px`,
}}
>
{children}
</div>
);
};
// 卡片组件
export const Card = ({ element, children }: any) => {
const { title, subtitle } = element.props;
return (
<div className="bg-white rounded-xl shadow-md overflow-hidden">
<div className="p-6">
<h3 className="text-xl font-bold text-gray-900">{title}</h3>
{subtitle && (
<p className="text-sm text-gray-500 mt-1">{subtitle}</p>
)}
<div className="mt-4">
{children}
</div>
</div>
</div>
);
};
// 文本组件
export const Text = ({ element }: any) => {
const { content, variant = 'body' } = element.props;
const variantStyles: Record<string, string> = {
h1: 'text-4xl font-bold text-gray-900',
h2: 'text-3xl font-semibold text-gray-900',
h3: 'text-2xl font-medium text-gray-900',
body: 'text-base text-gray-700',
caption: 'text-sm text-gray-500',
};
const Tag = variant.startsWith('h') ? variant : 'p';
return (
<Tag className={variantStyles[variant]}>
{content}
</Tag>
);
};
// 数据展示组件
export const DataDisplay = ({ element }: any) => {
const { label, valuePath, format = 'text' } = element.props;
const value = useDataValue(valuePath);
// 格式化函数
const formatValue = (val: any, fmt: string) => {
if (val === undefined || val === null) return '-';
switch (fmt) {
case 'currency':
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY'
}).format(val);
case 'percent':
return `${(val * 100).toFixed(1)}%`;
case 'number':
return new Intl.NumberFormat('zh-CN').format(val);
case 'date':
return new Date(val).toLocaleDateString('zh-CN');
default:
return String(val);
}
};
return (
<div className="flex flex-col">
<span className="text-sm text-gray-500">{label}</span>
<span className="text-2xl font-bold text-gray-900">
{formatValue(value, format)}
</span>
</div>
);
};
// 按钮组件
export const Button = ({ element, onAction }: any) => {
const { label, variant = 'primary', action } = element.props;
const variantStyles: Record<string, string> = {
primary: 'bg-blue-600 hover:bg-blue-700 text-white',
secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-800',
danger: 'bg-red-600 hover:bg-red-700 text-white',
};
return (
<button
className={`px-4 py-2 rounded-lg font-medium transition-colors ${variantStyles[variant]}`}
onClick={() => onAction(action)}
>
{label}
</button>
);
};
// 输入框组件
export const TextField = ({ element }: any) => {
const { label, valuePath, placeholder, type = 'text' } = element.props;
const [value, setValue] = useDataValue(valuePath, { mutable: true });
return (
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">{label}</label>
<input
type={type}
value={value ?? ''}
onChange={(e) => setValue(e.target.value)}
placeholder={placeholder}
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
);
};
// 列表组件
export const List = ({ element, children, renderItem }: any) => {
const { itemsPath, itemKey } = element.props;
const items = useDataArray(itemsPath);
return (
<div className="space-y-2">
{items.map((item: any, index: number) => (
<div key={item[itemKey] ?? index}>
{renderItem ? renderItem(item, index) : children}
</div>
))}
</div>
);
};
// 条件渲染组件
export const Conditional = ({ element, children }: any) => {
const { condition } = element.props;
const value = useDataValue(condition.path);
const evaluateCondition = () => {
switch (condition.operator) {
case 'eq':
return value === condition.value;
case 'ne':
return value !== condition.value;
case 'gt':
return value > condition.value;
case 'lt':
return value < condition.value;
case 'exists':
return value !== undefined && value !== null;
default:
return false;
}
};
return evaluateCondition() ? children : null;
};
// 导出组件注册表
export const registry = {
Container,
Card,
Text,
DataDisplay,
Button,
TextField,
List,
Conditional,
};
这些组件使用了 Tailwind CSS 进行样式设计。你完全可以根据自己的设计系统替换这些样式,这也是 json-render 的灵活之处——AI 只关心组件的结构,样式由你说了算。
3.4 第三步:创建渲染容器
现在我们把所有东西组装起来。创建一个主渲染组件:
// src/components/JsonRenderContainer.tsx
'use client';
import Reac