基于json-render的流式表單渲染方案詳細解析
需求背景:從純文本問答到動態(tài)表單收集
目前需要將借款等功能接入AI平臺里,我們需要通過多輪問答的形式來搜集用戶的意愿及具體信息。目前項目里并不支持表單渲染,用戶只能在聊天框里一行行地打字回復。這種體驗既低效,又容易導致數(shù)據(jù)格式混亂,難以進行結構化存儲和后續(xù)業(yè)務流轉。
當模型判斷需要收集某些特定信息時,它不再是輸出干巴巴的純文本問題,而是直接動態(tài)生成并拋出一個可視化的結構化表單(如包含下拉框、日期選擇器、文本框的交互界面)。用戶只需點擊和填寫,體驗大幅提升。
為了保證對話的極致體驗,我們通常需要利用SSE技術將大模型的輸出進行流式傳輸。然而,當流式傳輸遇到結構化數(shù)據(jù)(JSON)時,一個巨大的工程挑戰(zhàn)便浮出水面:如何將源源不斷但殘缺不全的 JSON 字符流,實時轉換并渲染為可交互的 React 組件樹?
本文將以本項目的流式表單場景為例,詳細解析如何結合 @json-render/react 庫,從底層字符串修復到頂層 React 渲染,優(yōu)雅地實現(xiàn)流式表單的實時渲染方案。
一、 核心痛點與挑戰(zhàn)
在使用 @json-render/react 進行表單渲染時,渲染引擎(Renderer)期望接收到的是一個結構完整、語義合法的 Spec 對象。例如:
{
"root": "form-1",
"elements": {
"form-1": {
"type": "Form",
"props": { "title": "表單標題" },
"children": ["input-1"]
},
"input-1": {
"type": "Input",
"props": { "placeholder": "請輸入" }
}
}
}但在流式傳輸(Token by Token)的過程中,前端接收到的數(shù)據(jù)往往是這樣的:
- 場景 1:
{"root": "fo(缺少引號閉合、缺少右大括號) - 場景 2:
{"root": "form-1", "elemen(鍵名被截斷) - 場景 3:
{"root": "form-1", "elements": {"form-1": {"type": "Form", "children": ["inpu(數(shù)組元素被截斷)
如果將這些殘缺的字符串直接使用 JSON.parse 解析,毫無疑問會拋出 SyntaxError 導致頁面崩潰。
即使我們通過某些手段把 JSON 的語法修補好了(能成功 parse 出一個對象),如果這個對象在語義上不完整——比如 form-1 的 children 引用了 input-1,但在當前的切片中 input-1 的節(jié)點定義還沒傳輸過來——渲染引擎去查找 input-1 時就會遭遇“空指針異常”,同樣會導致組件樹崩潰。
總結來說,我們需要解決兩個層面的問題:
- 語法層面:如何把截斷的 JSON 字符串動態(tài)閉合,使其合法。
- 語義層面:如何把解析出的 JSON 對象進行“清洗”,剔除不可渲染的“半成品”節(jié)點,保證數(shù)據(jù)符合渲染引擎的規(guī)范。
二、 實現(xiàn):定義物料庫與渲染注冊表 (Catalog & Registry)
在讓大模型輸出 JSON 之前,我們首先需要告訴它你能輸出什么樣的組件?,并且告訴前端渲染引擎如何將這些 JSON 渲染為真實的 React 節(jié)點?。在 @json-render 生態(tài)中,這分別由 catalog 和 registry 負責。
1. 約束大模型輸出:Catalog 定義
為了保證大模型生成的 UI 數(shù)據(jù)結構不僅符合 JSON 語法,更符合我們的業(yè)務規(guī)范,我們使用 zod 在 catalog 中定義了支持的組件和屬性約束(Schema):
// 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,確保其輸出符合規(guī)范。
- 對于前端:它為
@json-render提供了嚴格的類型推導與運行時校驗的基礎。
2. 映射 React 視圖:Registry 注冊表
有了契約之后,前端需要將 catalog 中的虛擬組件類型映射為包含樣式和交互的真實 React 組件。
// 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 元素。
三、 整體架構與數(shù)據(jù)流轉
以下是前端處理流式 JSON 的完整鏈路圖:

如上圖所示,最核心的邏輯在于 repairJSON 和 cleanSpec 這兩個函數(shù)。
四、JSON 修復 (repairJSON)
如何將 {"root": "form-1", "elements": {"input-1": {"type": "Te 強行變成一個合法的 JSON? 我們需要一個容錯的解析器。由于流式 JSON 的截斷只發(fā)生在尾部,前面的內容一定是一段合法的 JSON 前綴,這為我們利用棧結構 進行符號匹配提供了基礎。
字符串修復邏輯如下:
function repairJSON(str: string) {
let out = '';
let inString = false;
let escape = false;
const stack: string[] = []; // 用于記錄未閉合的括號結構
// 1. 逐字符掃描,解析當前所處的狀態(tài)
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. 尾部狀態(tài)閉合
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 的約束規(guī)則。對于 @json-render/react 來說,它要求每一個 Element 都必須擁有 type,并且 children 數(shù)組里引用的 ID 必須在 elements 字典里真實存在。
流式傳輸時,LLM 是按照字符先后順序輸出的,極有可能出現(xiàn)父節(jié)點的 children 數(shù)組已經(jīng)聲明了 ["child-1"],但 child-1 的詳細定義還在網(wǎng)絡傳輸路上的情況。
下面是 cleanSpec 實現(xiàn):
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 字典中不存在的子節(jié)點 ID
el.children = el.children.filter((childId: string) => cleanElements[childId]);
}
}
return { root: spec.root, elements: cleanElements };
}
這一步相當于為渲染引擎加上了一層校驗。所有未成形、不合法的數(shù)據(jù)結構都會被擋在外面,直到隨著流式傳輸,該節(jié)點的數(shù)據(jù)完整落地,才會被放入 cleanElements 傳遞給下一層進行渲染,從而實現(xiàn)組件流式渲染效果。
六、 React 狀態(tài)層與渲染引擎接入
數(shù)據(jù)層邏輯處理完了,接下來就是在 React 組件中進行狀態(tài)映射。
在 StreamingForm 邏輯中,我們用一個不可變的 bufferRef 來不斷累加來自后端的 Token,以避免頻繁引發(fā)無意義的重渲染。只有當數(shù)據(jù)經(jīng)過清洗且產生了一個合法的 Spec 時,我們才調用 setSpec(cleaned) 去觸發(fā) @json-render/react 的重新渲染。
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); // 觸發(fā)真正的組件樹渲染
}
} catch {
}
};
}, []);
return (
<div className="container mx-auto p-8 flex flex-col md:flex-row gap-8">
{/* 渲染區(qū) */}
<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 流展示區(qū) */}
<div className="flex-1 max-w-lg">
<h2 className="text-lg font-bold mb-2 text-gray-700">當前流式傳輸?shù)?JSON 數(shù)據(jù)</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>
);
}
最佳實踐與優(yōu)化思考: 在當前的案例中,由于我們在本地或局域網(wǎng)模擬流式輸出,每次收到 Token 我們都在主線程進行了
repair -> parse -> clean -> render的全量計算。在生產環(huán)境下,由于大模型輸出速度可能極快,且表單復雜度可能極高,為了避免主線程卡頓掉幀,可以引入 節(jié)流 機制。例如:通過
requestAnimationFrame限制每 16ms 哪怕收到幾十個 Token 也只執(zhí)行一次完整解析渲染。
七、 最終展示效果
右側是為了輸出原始的JSON數(shù)據(jù)結構,調試展示用的;左側是實際要渲染的流式表單。

結語
通過將 SSE 網(wǎng)絡傳輸、基于棧的詞法修復 (repairJSON) 以及 防御性的語義清洗 (cleanSpec) 三者巧妙結合,我們賦能了普通的渲染引擎,讓其擁有了處理流媒體結構化數(shù)據(jù)的能力。
這套方案不僅適用于 @json-render/react 驅動的表單場景,同樣適用于大模型驅動生成 Dashboard(圖表)、Workflow(節(jié)點圖)等所有強依賴 JSON Schema 配置的低代碼/無代碼頁面。
到此這篇關于json-render流式表單渲染方案詳細解析的文章就介紹到這了,更多相關json-render流式表單渲染方案內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
React 使用recharts實現(xiàn)散點地圖的示例代碼
這篇文章主要介紹了React 使用recharts實現(xiàn)散點地圖的示例代碼,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-12-12
react ant-design Select組件下拉框map不顯示的解決
這篇文章主要介紹了react ant-design Select組件下拉框map不顯示的解決方案,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2024-03-03

