需求背景:从纯文本问答到动态表单收集
目前需要将借款等功能接入AI平台里,我们需要通过多轮问答的形式来搜集用户的意愿及具体信息。目前项目里并不支持表单渲染,用户只能在聊天框里一行行地打字回复。这种体验既低效,又容易导致数据格式混乱,难以进行结构化存储和后续业务流转。
当模型判断需要收集某些特定信息时,它不再是输出干巴巴的纯文本问题,而是直接动态生成并抛出一个可视化的结构化表单(如包含下拉框、日期选择器、文本框的交互界面)。用户只需点击和填写,体验大幅提升。
为了保证对话的极致体验,我们通常需要利用SSE 技术将大模型的输出进行流式传输 。然而,当流式传输遇到结构化数据(JSON)时,一个巨大的工程挑战便浮出水面:如何将源源不断但残缺不全的 JSON 字符流,实时转换并渲染为可交互的 React 组件树?
本文将以本项目的流式表单场景为例,详细解析如何结合 @json-render/react 库,从底层字符串修复到顶层 React 渲染,优雅地实现流式表单的实时渲染方案。
一、 核心痛点与挑战
在使用 @json-render/react 进行表单渲染时,渲染引擎(Renderer)期望接收到的是一个结构完整、语义合法的 Spec 对象。例如:
json
{
"root": "form-1",
"elements": {
"form-1": {
"type": "Form",
"props": { "title": "表单标题" },
"children": ["input-1"]
},
"input-1": {
"type": "Input",
"props": { "placeholder": "请输入" }
}
}
}
但在流式传输(Token by Token)的过程中,前端接收到的数据往往是这样的:
- 场景 1 :
{"root": "fo(缺少引号闭合、缺少右大括号) - 场景 2 :
{"root": "form-1", "elemen(键名被截断) - 场景 3 :
{"root": "form-1", "elements": {"form-1": {"type": "Form", "children": ["inpu(数组元素被截断)
如果将这些残缺的字符串直接使用 JSON.parse 解析,毫无疑问会抛出 SyntaxError 导致页面崩溃。
即使 我们通过某些手段把 JSON 的语法修补好了(能成功 parse 出一个对象),如果这个对象在语义上不完整------比如 form-1 的 children 引用了 input-1,但在当前的切片中 input-1 的节点定义还没传输过来------渲染引擎去查找 input-1 时就会遭遇"空指针异常",同样会导致组件树崩溃。
总结来说,我们需要解决两个层面的问题:
- 语法层面:如何把截断的 JSON 字符串动态闭合,使其合法。
- 语义层面:如何把解析出的 JSON 对象进行"清洗",剔除不可渲染的"半成品"节点,保证数据符合渲染引擎的规范。
二、 实现:定义物料库与渲染注册表 (Catalog & Registry)
在让大模型输出 JSON 之前,我们首先需要告诉它你能输出什么样的组件? ,并且告诉前端渲染引擎如何将这些 JSON 渲染为真实的 React 节点? 。在 @json-render 生态中,这分别由 catalog 和 registry 负责。
1. 约束大模型输出:Catalog 定义
为了保证大模型生成的 UI 数据结构不仅符合 JSON 语法,更符合我们的业务规范,我们使用 zod 在 catalog 中定义了支持的组件和属性约束(Schema):
typescript
// catalog.ts
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { z } from "zod";
export const catalog = defineCatalog(schema, {
components: {
Form: {
props: z.object({
title: z.string(),
description: z.string().optional(),
}),
description: "表单容器",
},
Input: {
props: z.object({
label: z.string(),
name: z.string(),
type: z.enum(["text", "email", "password", "number"]).default("text"),
placeholder: z.string().optional(),
}),
description: "文本框",
}
Select: {
props: z.object({
label: z.string(),
name: z.string(),
options: z.array(z.object({ label: z.string(), value: z.string() })),
}),
description: "下拉选项",
},
Button: {
props: z.object({
label: z.string(),
action: z.string(),
variant: z.enum(["primary", "secondary"]).default("primary"),
}),
description: "提交按钮",
},
},
actions: {
submit: { description: "提交操作" },
},
});
catalog 的核心作用是建立契约:
- 对于后端/大模型:这套基于 Zod 的定义可以直接被转换为 JSON Schema 并作为 Function Calling 的结构提供给 LLM,确保其输出符合规范。
- 对于前端 :它为
@json-render提供了严格的类型推导与运行时校验的基础。
2. 映射 React 视图:Registry 注册表
有了契约之后,前端需要将 catalog 中的虚拟组件类型映射为包含样式和交互的真实 React 组件。
tsx
// registry.tsx
import { defineRegistry } from "@json-render/react";
import { catalog } from "./catalog";
export const { registry } = defineRegistry(catalog, {
components: {
Form: ({ props, children }) => (
<div className="p-4 border rounded shadow-md max-w-md mx-auto bg-white">
<h2 className="text-xl font-bold mb-2">{props.title}</h2>
{props.description && <p className="text-gray-600 mb-4">{props.description}</p>}
<form className="space-y-4" onSubmit={(e) => e.preventDefault()}>
{children} // 递归渲染子组件
</form>
</div>
),
Input: ({ props }) => (
<div className="flex flex-col">
<label className="mb-1 font-medium">{props.label}</label>
<input
type={props.type}
name={props.name}
placeholder={props.placeholder}
className="border rounded p-2"
/>
</div>
),
Select: ({ props }) => (
<div className="flex flex-col">
<label className="mb-1 font-medium">{props.label}</label>
<select name={props.name} className="border rounded p-2">
{(props.options || []).map((opt: { label: string; value: string }) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
),
Button: ({ props, emit }) => (
<button
onClick={() => {
emit(props.action)
}}
className={`px-4 py-2 rounded text-white ${
props.variant === "secondary" ? "bg-gray-500 hover:bg-gray-600" : "bg-blue-500 hover:bg-blue-600"
}`}
>
{props.label}
</button>
),
},
actions: {
submit: async (ctx) => {
console.log("submit", ctx);
},
},
});
通过这两步配置,只要提供一段包含 { "type": "Input", "props": { "label": "Name" } } 的 JSON,@json-render 就能自动渲染出带有 Tailwind CSS 样式的 React 元素。
三、 整体架构与数据流转
以下是前端处理流式 JSON 的完整链路图:
如上图所示,最核心的逻辑在于 repairJSON 和 cleanSpec 这两个函数。
四、JSON 修复 (repairJSON)
如何将 {"root": "form-1", "elements": {"input-1": {"type": "Te 强行变成一个合法的 JSON? 我们需要一个容错的解析器。由于流式 JSON 的截断只发生在尾部 ,前面的内容一定是一段合法的 JSON 前缀,这为我们利用栈结构 进行符号匹配提供了基础。
字符串修复逻辑如下:
typescript
function repairJSON(str: string) {
let out = '';
let inString = false;
let escape = false;
const stack: string[] = []; // 用于记录未闭合的括号结构
// 1. 逐字符扫描,解析当前所处的状态
for (let i = 0; i < str.length; i++) {
const char = str[i];
if (escape) { out += char; escape = false; continue; }
if (char === '\\') { escape = true; out += char; continue; }
if (char === '"') { inString = !inString; out += char; continue; }
// 如果不在字符串内部,遇到左括号入栈,遇到右括号出栈
if (!inString) {
if (char === '{') stack.push('}');
else if (char === '[') stack.push(']');
else if (char === '}' || char === ']') stack.pop();
}
out += char;
}
// 2. 尾部状态闭合
if (escape) out = out.slice(0, -1); // 截断悬空的转义符
if (inString) out += '"'; // 闭合未完成的字符串引号
out = out.trim();
// 3. 剔除悬空的逗号(JSON 不允许尾逗号)
if (out.endsWith(',')) out = out.slice(0, -1);
// 4. 补齐残缺的键值对(例如 {"key": -> {"key":null)
if (out.endsWith(':')) out += 'null';
// 5. 按照栈的后进先出顺序,依次补齐所有未闭合的括号
while (stack.length > 0) {
out += stack.pop();
}
return out;
}
示例分析: 假设输入:{"a": 1, "b": {"c": "hello
- 扫描完毕后,
inString为true,stack内为['}', '}'](对应最外层和 b 的花括号)。 - 首先补全引号,变成:
{"a": 1, "b": {"c": "hello" - 然后依次出栈补齐括号,最终输出:
{"a": 1, "b": {"c": "hello"}}。
五、语义层面的结构清洗 (cleanSpec)
JSON 语法合法了,但不符合 json-render 的约束规则。对于 @json-render/react 来说,它要求每一个 Element 都必须拥有 type,并且 children 数组里引用的 ID 必须在 elements 字典里真实存在。
流式传输时,LLM 是按照字符先后顺序输出的,极有可能出现父节点的 children 数组已经声明了 ["child-1"],但 child-1 的详细定义还在网络传输路上的情况。
下面是 cleanSpec 实现:
typescript
interface Element {
type: string;
props: Record<string, any>;
children?: string[];
}
interface Spec {
root: string;
elements: Record<string, Element>;
}
function cleanSpec(spec: any): Spec | null {
// 非空且结构符合要求
if (!spec || typeof spec !== 'object') return null;
if (!spec.root || !spec.elements || typeof spec.elements !== 'object') return null;
const cleanElements: Record<string, Element> = {};
// 过滤残缺的 Element
for (const key in spec.elements) {
const el = spec.elements[key];
// 如果一个元素连 type 都没有输出完毕,说明它是一个不可用的半成品,直接抛弃
if (el && typeof el === 'object' && typeof el.type === 'string') {
cleanElements[key] = {
type: el.type,
props: (el.props && typeof el.props === 'object') ? el.props : {},
children: Array.isArray(el.children) ? el.children : []
};
}
}
// 剔除悬空的引用
for (const key in cleanElements) {
const el = cleanElements[key];
if (el.children) {
// 过滤掉那些在 cleanElements 字典中不存在的子节点 ID
el.children = el.children.filter((childId: string) => cleanElements[childId]);
}
}
return { root: spec.root, elements: cleanElements };
}
这一步相当于为渲染引擎加上了一层校验。所有未成形、不合法的数据结构都会被挡在外面,直到随着流式传输,该节点的数据完整落地,才会被放入 cleanElements 传递给下一层进行渲染,从而实现组件流式渲染效果。
六、 React 状态层与渲染引擎接入
数据层逻辑处理完了,接下来就是在 React 组件中进行状态映射。
在 StreamingForm 逻辑中,我们用一个不可变的 bufferRef 来不断累加来自后端的 Token,以避免频繁引发无意义的重渲染。只有当数据经过清洗且产生了一个合法的 Spec 时,我们才调用 setSpec(cleaned) 去触发 @json-render/react 的重新渲染。
typescript
export function StreamingForm() {
const [spec, setSpec] = useState<Spec>(initialSpec);
const [rawText, setRawText] = useState("");
const bufferRef = useRef(""); // 使用 ref 缓存字符流,避免闭包陷阱
useEffect(() => {
const eventSource = new EventSource('/api/stream-form');
eventSource.onmessage = (event) => {
const chunk = JSON.parse(event.data);
bufferRef.current += chunk;
setRawText(bufferRef.current);
try {
const repaired = repairJSON(bufferRef.current);
const parsed = JSON.parse(repaired);
const cleaned = cleanSpec(parsed);
if (cleaned) {
setSpec(cleaned); // 触发真正的组件树渲染
}
} catch {
}
};
}, []);
return (
<div className="container mx-auto p-8 flex flex-col md:flex-row gap-8">
{/* 渲染区 */}
<div className="flex-1">
<h1 className="text-2xl font-bold mb-4">流式表单渲染器</h1>
<div className="mb-4 text-sm text-gray-500">
模拟 LLM 流式传输...
</div>
<StateProvider>
<VisibilityProvider>
<ActionProvider>
<ValidationProvider>
<Renderer spec={spec} registry={registry} />
</ValidationProvider>
</ActionProvider>
</VisibilityProvider>
</StateProvider>
</div>
{/* 原始 JSON 流展示区 */}
<div className="flex-1 max-w-lg">
<h2 className="text-lg font-bold mb-2 text-gray-700">当前流式传输的 JSON 数据</h2>
<div className="bg-gray-900 text-green-400 p-4 rounded-lg overflow-auto h-[600px] font-mono whitespace-pre-wrap">
{rawText}<span className="animate-pulse">_</span>
</div>
</div>
</div>
);
}
最佳实践与优化思考 : 在当前的案例中,由于我们在本地或局域网模拟流式输出,每次收到 Token 我们都在主线程进行了
repair -> parse -> clean -> render的全量计算。在生产环境下,由于大模型输出速度可能极快,且表单复杂度可能极高,为了避免主线程卡顿掉帧,可以引入 节流 机制。例如:通过
requestAnimationFrame限制每 16ms 哪怕收到几十个 Token 也只执行一次完整解析渲染。
七、 最终展示效果
右侧是为了输出原始的JSON数据结构,调试展示用的;左侧是实际要渲染的流式表单。

结语
通过将 SSE 网络传输 、基于栈的词法修复 (repairJSON) 以及 防御性的语义清洗 (cleanSpec) 三者巧妙结合,我们赋能了普通的渲染引擎,让其拥有了处理流媒体结构化数据的能力。
这套方案不仅适用于 @json-render/react 驱动的表单场景,同样适用于大模型驱动生成 Dashboard(图表)、Workflow(节点图)等所有强依赖 JSON Schema 配置的低代码/无代码页面。