基于 json-render 的流式表单渲染方案

需求背景:从纯文本问答到动态表单收集

目前需要将借款等功能接入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. 场景 1 : {"root": "fo (缺少引号闭合、缺少右大括号)
  2. 场景 2 : {"root": "form-1", "elemen (键名被截断)
  3. 场景 3 : {"root": "form-1", "elements": {"form-1": {"type": "Form", "children": ["inpu (数组元素被截断)

如果将这些残缺的字符串直接使用 JSON.parse 解析,毫无疑问会抛出 SyntaxError 导致页面崩溃。

即使 我们通过某些手段把 JSON 的语法修补好了(能成功 parse 出一个对象),如果这个对象在语义上不完整------比如 form-1children 引用了 input-1,但在当前的切片中 input-1 的节点定义还没传输过来------渲染引擎去查找 input-1 时就会遭遇"空指针异常",同样会导致组件树崩溃。

总结来说,我们需要解决两个层面的问题:

  1. 语法层面:如何把截断的 JSON 字符串动态闭合,使其合法。
  2. 语义层面:如何把解析出的 JSON 对象进行"清洗",剔除不可渲染的"半成品"节点,保证数据符合渲染引擎的规范。

二、 实现:定义物料库与渲染注册表 (Catalog & Registry)

在让大模型输出 JSON 之前,我们首先需要告诉它你能输出什么样的组件? ,并且告诉前端渲染引擎如何将这些 JSON 渲染为真实的 React 节点? 。在 @json-render 生态中,这分别由 catalogregistry 负责。

1. 约束大模型输出:Catalog 定义

为了保证大模型生成的 UI 数据结构不仅符合 JSON 语法,更符合我们的业务规范,我们使用 zodcatalog 中定义了支持的组件和属性约束(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 的完整链路图:

graph TD A[后端 SSE 接口流式输出 Token] --> B[前端 EventSource 接收 Chunk] B --> C[拼接至 useRef 缓存 bufferRef] C --> D{1. 字符串修复 repairJSON} D --> |补全引号/括号/剔除尾逗号| E[合法的 JSON 字符串] E --> F[JSON.parse 解析为 JS 对象] F --> G{2. 数据结构清洗 cleanSpec} G --> |过滤残缺节点/修正无效引用| H[合法的 Spec 对象] H --> I[setState 触发 React 更新] I --> J["@json-render/react 渲染视图"] style D fill:#f9f,stroke:#333,stroke-width:2px style G fill:#bbf,stroke:#333,stroke-width:2px style J fill:#dfd,stroke:#333,stroke-width:2px

如上图所示,最核心的逻辑在于 repairJSONcleanSpec 这两个函数。


四、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

  • 扫描完毕后,inStringtruestack 内为 ['}', '}'](对应最外层和 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 配置的低代码/无代码页面。

相关推荐
wobi_baoyan2 小时前
【已解决】使用Maven打包发生或者启动Spring Boot项目发生 错误: 不支持发行版本 17
服务器·前端·javascript
Dylan~~~2 小时前
Go语言Web框架选型指南:从入门到精通
开发语言·前端·golang
学以智用2 小时前
# TypeScript 高级特性(核心+实用)
前端·javascript·typescript
学以智用2 小时前
TypeScript 核心基础:类型/变量 + 函数 + 接口
前端·javascript·typescript
Csvn2 小时前
组件设计模式(下):HOC、Render Props 与 Compound Components
react.js
SuperEugene2 小时前
Vue3 组件解耦实战:Props/Emit/ 事件总线用法 + 避坑指南|Vue 组件与模板规范篇
前端·javascript·vue.js
Cache技术分享2 小时前
360. Java IO API - 访问文件系统
前端·后端
小璐资源网2 小时前
CSS进阶指南:深入解析选择器优先级与继承机制
前端·css
工边页字2 小时前
为什么 RAG系统里,Embedding成本往往远低于 LLM成本,但很多公司仍然疯狂优化 Embedding?
前端·人工智能·后端