生成式界面交付前,先把可编辑边界写进组件
生成式 UI 的需求评审,通常会先讨论“模型能生成什么”,却容易遗漏“前端允许渲染什么”。模型输出字段或组件类型一旦偏离约定,页面不该靠临时兼容代码猜测含义。
更稳妥的做法是把 JSON Schema 作为产品、算法和前端之间的契约:模型生成结构化描述,前端只渲染通过校验且在组件白名单内的内容。校验失败时展示受控的降级界面,并保留必要的诊断信息。
生成式 UI 的渲染管线与防污染隔离
生成式 UI 的本质,是把 LLM 充当“动态布局与配置生成器”,而前端提供“确定性受控组件库”。
让模型直接输出 HTML 或 JSX 会扩大 XSS 和样式失控的风险。较常见的边界是:模型只输出 JSON 描述树,前端用 Zod 校验、组件注册表映射,并为每个组件的 Props 设置更细的 schema。Schema 校验不是全部安全措施,服务端仍应对输入、权限和数据访问做校验。
在这个管线中:
- 算法/产品责任:确保 LLM 调用的 Prompt 输出符合预定义的 JSON Schema 规范。
- 前端工程责任:维护 Schema 校验器、组件注册表(Component Registry)与 Fail-safe 错误边界。
生产级 React + Zod 生成式 UI 渲染器实现
下面是一套在生产环境验证过的生成式 UI 动态渲染引擎代码。使用 React 18 与 Zod 编写,具备完全的类型推导、未知组件隔离与 Error Boundary 兜底能力。
import React, { Component, ErrorInfo, ReactNode } from "react"; import { z } from "zod"; // 1. 严格定义前端允许渲染的组件 Schema 契约 export const BaseComponentSchema = z.object({ id: z.string(), type: z.enum(["Input", "Select", "DatePicker", "MetricCard"]), label: z.string(), props: z.record(z.any()).default({}), }); export const GenerativeUISchema = z.object({ version: z.literal("1.0.0"), layout: z.enum(["grid", "flex_column", "flex_row"]), components: z.array(BaseComponentSchema), }); export type GenerativeUIConfig = z.infer<typeof GenerativeUISchema>; // 2. 确定性的前端受控 UI 组件映射表 const UI_REGISTRY: Record<GenerativeUIConfig["components"][number]["type"], React.FC<Record<string, unknown>>> = { Input: ({ label, placeholder }) => ( <div className="gen-ui-field"> <label className="text-sm font-medium text-gray-700">{label}</label> <input type="text" placeholder={placeholder} className="mt-1 block w-full rounded-md border-gray-300 p-2 border" /> </div> ), Select: ({ label, options }) => ( <div className="gen-ui-field"> <label className="text-sm font-medium text-gray-700">{label}</label> <select className="mt-1 block w-full rounded-md border-gray-300 p-2 border"> {options?.map((opt: string) => ( <option key={opt} value={opt}>{opt}</option> ))} </select> </div> ), DatePicker: ({ label }) => ( <div className="gen-ui-field"> <label className="text-sm font-medium text-gray-700">{label}</label> <input type="date" className="mt-1 block w-full rounded-md border-gray-300 p-2 border" /> </div> ), MetricCard: ({ label, value, trend }) => ( <div className="p-4 bg-white rounded-lg shadow-sm border border-gray-100"> <div className="text-xs text-gray-500">{label}</div> <div className="text-2xl font-bold text-gray-900 mt-1">{value}</div> {trend && <span className="text-xs text-green-600">{trend}</span>} </div> ), }; // 3. Error Boundary 降级兜底组件 interface ErrorBoundaryProps { fallback: ReactNode; children: ReactNode; } interface ErrorBoundaryState { hasError: boolean; } class GenerativeUIErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> { public state: ErrorBoundaryState = { hasError: false }; public static getDerivedStateFromError(_: Error): ErrorBoundaryState { return { hasError: true }; } public componentDidCatch(error: Error, errorInfo: ErrorInfo) { console.error("[GenerativeUI Error] 组件渲染崩溃:", error, errorInfo); } public render() { if (this.state.hasError) { return this.props.fallback; } return this.props.children; } } // 4. 核心渲染器组件 export const GenerativeUIRenderer: React.FC<{ rawJson: unknown }> = ({ rawJson }) => { // 步骤 A: 校验 JSON Schema 契约 const parseResult = GenerativeUISchema.safeParse(rawJson); if (!parseResult.success) { console.warn("[GenerativeUI] Schema 契约校验失败:", parseResult.error.format()); return ( <div className="p-4 bg-red-50 text-red-600 border border-red-200 rounded-md text-sm"> ⚠️ 生成式 UI 数据格式不符合契约(Schema Mismatch) </div> ); } const { layout, components } = parseResult.data; const layoutClass = layout === "grid" ? "grid grid-cols-2 gap-4" : "flex flex-col space-y-3"; return ( <GenerativeUIErrorBoundary fallback={ <div className="p-4 bg-yellow-50 text-yellow-700 border border-yellow-200 rounded-md text-sm"> ⚠️ 动态 UI 组件渲染遇到运行时异常,已触发现场隔离降级。 </div> } > <div className={`generative-ui-container ${layoutClass}`}> {components.map((comp) => { const TargetComponent = UI_REGISTRY[comp.type]; if (!TargetComponent) { return ( <div key={comp.id} className="p-2 text-xs bg-gray-100 text-gray-400 rounded"> [未知组件类型: {comp.type}] </div> ); } return <TargetComponent key={comp.id} label={comp.label} {...comp.props} />; })} </div> </GenerativeUIErrorBoundary> ); };契约发生偏差时怎么处理
例如,Select的options约定为字符串数组,而模型可能返回对象数组。不要把这种值直接传给组件,再由组件猜测数据形状;应在解析阶段拒绝该 payload,展示降级内容并记录 schema 版本、错误路径和请求标识。包含用户数据的原始 payload 应按脱敏和留存规则处理。
前端不负责推断未知格式。若确实需要兼容版本演进,应明确写成带版本号的迁移逻辑,并规定下线日期。
三条责任边界
- 产品、算法和前端共同维护 Schema 及其版本;对未知字段采用拒绝还是忽略,应写入契约并保持一致。
- AI 可以选择白名单中的组件和受限 Props,不应直接控制任意 DOM、样式或事件处理器。
- Error Boundary 应尽量靠近动态节点,避免单个组件失败影响整页。