教程

React 中的 Generative UI:实战指南

学习如何在 React 应用中实现 AI 生成的 UI 组件,包含真实场景中的实用开发模式。

A
Alex12 分钟阅读

大多数 GenUI 原型在这五个模式上失败

Generative UI 的演示看起来很神奇。但生产中的 GenUI 应用会以五种可预见的方式崩溃:工具选择脆弱、流式传输期间出现竞态条件、运行时 prop 不匹配、模型宕机时没有降级、推理成本无边界增长。本指南介绍五个真正能让 GenUI 功能存活过演示阶段的模式——注册表、分离、骨架屏、错误边界和状态管理——以及每个模式背后隐藏的权衡,还有对通常决定是否上线的两类读者(选择技术栈的工程经理,以及在有限预算上部署副业项目的独立开发者)的具体指导。

为什么选 React 来做 Generative UI?

React 的组件模型天然适合 Generative UI。组件可组合、有类型约束,可以在服务端或客户端渲染。当 AI 模型"生成 UI"时,它实际上做的是用特定 props 选取和组合 React 组件。

本指南涵盖在生产中有效的模式,以及我看到团队在初次构建生成式界面时常犯的错误。我假设你已经有了一个可用的 Next.js 环境并了解 Vercel AI SDK 基础——这是在那个基础之上的实践层。

模式一:工具注册表

任何可维护的 Generative UI 系统的基础,是对 AI 可用组件的显式、集中化注册表。不要把工具定义分散在各个 Server Actions 中。

// lib/genui-registry.ts
import { z } from 'zod';
import { MetricCard } from '@/components/metric-card';
import { DataTable } from '@/components/data-table';
import { BarChart } from '@/components/bar-chart';
import { AlertBanner } from '@/components/alert-banner';
import { LineChart } from '@/components/line-chart';

export const tools = {
  metricCard: {
    description: 'Display a single KPI metric with a trend indicator. Use for scalar values like revenue, user count, or conversion rate.',
    parameters: z.object({
      label: z.string().describe('The metric name, e.g. "Monthly Revenue"'),
      value: z.string().describe('The formatted value, e.g. "$12,400"'),
      change: z.number().describe('Percentage change vs. previous period'),
      period: z.string().describe('The comparison period, e.g. "vs last month"'),
    }),
    component: MetricCard,
  },
  dataTable: {
    description: 'Display tabular data with sortable columns. Use when showing lists of items with multiple attributes.',
    parameters: z.object({
      columns: z.array(z.object({
        key: z.string(),
        label: z.string(),
        numeric: z.boolean().optional(),
      })),
      rows: z.array(z.record(z.string())),
      caption: z.string().optional(),
    }),
    component: DataTable,
  },
  barChart: {
    description: 'Display a bar chart for categorical comparisons. Use when comparing values across discrete categories.',
    parameters: z.object({
      title: z.string(),
      data: z.array(z.object({ label: z.string(), value: z.number() })),
      yAxisLabel: z.string().optional(),
    }),
    component: BarChart,
  },
  lineChart: {
    description: 'Display a line chart for time-series data. Use when showing trends over time.',
    parameters: z.object({
      title: z.string(),
      data: z.array(z.object({ date: z.string(), value: z.number() })),
      unit: z.string().optional(),
    }),
    component: LineChart,
  },
  alertBanner: {
    description: 'Display an important notice, warning, or success message. Use sparingly for genuinely important information.',
    parameters: z.object({
      type: z.enum(['info', 'warning', 'error', 'success']),
      title: z.string(),
      message: z.string(),
    }),
    component: AlertBanner,
  },
};

export type ToolName = keyof typeof tools;

关键洞察: description 字段是 AI 读取以决定使用哪个组件的内容。为 AI 而写,而不是为人类而写。明确说明每个组件何时适用,更重要的是,何时适用。

注意 lineChart 写的是"时间序列",barChart 写的是"分类"。没有这个区分,AI 会在两者之间随机选择。描述越精确,组件选择越好。

这个模式的失败场景。 集中注册表假设一个团队拥有目录。如果三个产品团队各自想要自己的组件,注册表就会成为协调瓶颈——每个新工具都需要经过平台团队的 PR。替代方案是每个产品面按产品面建立联邦注册表,代价是描述重复和质量参差不齐。单一产品选集中化,为多个产品面服务的平台选联邦化。参见官方 Vercel AI SDK streamUI 文档了解底层 API。

模式二:分离注册表与流式传输

将注册表定义与 streamUI 调用分开。这样你就可以在多个 Server Actions 中复用工具定义,并让注册表可以独立测试。

// lib/stream-with-tools.ts
import { streamUI } from 'ai/rsc';
import { openai } from '@ai-sdk/openai';
import { tools } from './genui-registry';

// 将注册表格式转换为 streamUI 格式
function buildStreamTools(toolNames: ToolName[]) {
  return Object.fromEntries(
    toolNames.map((name: ToolName) => [
      name,
      {
        description: tools[name].description,
        parameters: tools[name].parameters,
        generate: async function* (params: unknown) {
          yield <ToolSkeleton name={name} />;
          const Component = tools[name].component;
          // 空值保护:注册表条目可能配置错误或在请求中途热重载。
          if (!Component) {
            return <GenUIFallback error={new Error(`Missing component for tool: ${name}`)} resetErrorBoundary={() => {}} />;
          }
          return <Component {...(params as Record<string, unknown>)} />;
        },
      },
    ])
  );
}

// 数据仪表板的 Server Action
export async function generateDashboard(query: string) {
  const result = await streamUI({
    model: openai('gpt-4o'),
    system: 'You are a data analyst assistant. Display information using the appropriate visualization tool.',
    prompt: query,
    tools: buildStreamTools(['metricCard', 'dataTable', 'barChart', 'lineChart', 'alertBanner']),
  });
  return result.value;
}

// 摘要视图的 Server Action(工具更少 = 聚焦更好)
export async function generateSummary(query: string) {
  const result = await streamUI({
    model: openai('gpt-4o'),
    system: 'You are a concise assistant. Show a summary with key metrics only.',
    prompt: query,
    tools: buildStreamTools(['metricCard', 'alertBanner']),
  });
  return result.value;
}

向每个 Server Action 传递工具子集很重要。聚焦的工具集能产生更好的 AI 决策。不要在 5 个够用的情况下给 AI 20 个工具。

这个模式的失败场景。 将注册表和流式传输分离增加了一层间接性。对于只有一个工具的单页面原型,这层间接性是开销,不是架构。等第二个 Server Action 出现时再内联工具定义。

模式三:带骨架屏的流式传输

在 AI 生成期间永远不要显示空白屏幕。显示与预期输出形状匹配的骨架加载状态。这种视觉连续性能显著降低感知延迟。

// components/tool-skeleton.tsx
import { ToolName } from '@/lib/genui-registry';

const SKELETON_HEIGHTS: Record<ToolName, string> = {
  metricCard: 'h-28',
  dataTable: 'h-48',
  barChart: 'h-64',
  lineChart: 'h-64',
  alertBanner: 'h-16',
};

export function ToolSkeleton({ name }: { name: ToolName }) {
  return (
    <div
      className={`animate-pulse rounded-lg bg-muted ${SKELETON_HEIGHTS[name] ?? 'h-32'} w-full`}
      aria-label="加载中..."
      aria-busy="true"
    />
  );
}

对于更精确的骨架屏,匹配组件的内部结构:

export function MetricCardSkeleton() {
  return (
    <div className="rounded-lg border bg-card p-6">
      <div className="h-4 w-24 animate-pulse rounded bg-muted" />
      <div className="mt-3 h-8 w-32 animate-pulse rounded bg-muted" />
      <div className="mt-2 h-3 w-16 animate-pulse rounded bg-muted" />
    </div>
  );
}

匹配内部形状意味着从骨架屏到加载完成组件的过渡是平滑的——没有布局偏移,没有闪烁。

这个模式的失败场景。 每个组件定制骨架屏会让维护面翻倍:每次组件更新都需要同步更新对应的骨架屏。对于感知延迟不影响业务的低流量内部工具,通用的灰色方块就够了。将精心调整的骨架屏留给每次会话都展示给终端用户的界面。

模式四:生成 UI 的错误边界

生成的组件以手工编码的组件不会出现的方式失败。AI 可能传入一个数字字符串在需要数字的地方,一个负值在只接受正值的地方,或者一个空数组给需要至少一项的组件。

始终用错误边界包裹生成的输出:

// components/safe-genui.tsx
'use client';

import { ErrorBoundary } from 'react-error-boundary';

function GenUIFallback({ error, resetErrorBoundary }: {
  error: Error;
  resetErrorBoundary: () => void;
}) {
  return (
    <div className="rounded-lg border border-destructive/50 bg-destructive/5 p-4">
      <p className="text-sm font-medium text-destructive">
        此组件无法渲染
      </p>
      <p className="mt-1 text-xs text-muted-foreground">{error.message}</p>
      <button
        onClick={resetErrorBoundary}
        className="mt-2 text-xs underline text-muted-foreground"
      >
        重试
      </button>
    </div>
  );
}

export function SafeGenUI({ children }: { children: React.ReactNode }) {
  return (
    <ErrorBoundary FallbackComponent={GenUIFallback}>
      {children}
    </ErrorBoundary>
  );
}

<SafeGenUI> 包裹每一块生成的输出。一个组件的渲染错误不应该破坏整个响应。react-error-boundary处理底层机制。

这个模式的失败场景。 错误边界会吞掉异常。如果你不将 error.message 输送到可观测性工具(Sentry、GlitchTip、Datadog),同样的 bug 可能在生产中悄悄触发数周。没有日志记录的错误边界比没有错误边界更糟,因为它隐藏了症状。

模式五:生成交互的状态管理

AI 生成的组件通常需要是可交互的——可排序的表格、带工具提示的图表、提交数据的表单。这种交互性存在于组件本身,不需要特殊考虑。

需要思考的是当生成的 UI 需要影响其自身之外的应用状态时:

// 使用 React context 让生成的组件与应用交互
export const AppStateContext = createContext<{
  onDataSelected: (data: unknown) => void;
  onActionTriggered: (action: string, params: unknown) => void;
} | null>(null);

// 在你的生成组件中
function DataTable({ columns, rows }: DataTableProps) {
  const appState = useContext(AppStateContext);

  function handleRowClick(row: Record<string, string>) {
    appState?.onDataSelected(row);
  }

  return (
    <table>
      {/* ... */}
      {rows.map((row, i) => (
        <tr key={i} onClick={() => handleRowClick(row)} className="cursor-pointer hover:bg-muted">
          {/* ... */}
        </tr>
      ))}
    </table>
  );
}

为生成的组件设计清晰的外部交互 API。通过 context 传递回调 props,而不是直接导入全局状态——生成的组件应该是可移植的。

这个模式的失败场景。 基于 context 的状态耦合使生成的组件无法独立测试:你现在需要在每个 Storybook 故事中都挂载一个 provider。如果只有一两个组件需要外部状态,老老实实 prop drilling 更诚实。只有当三个或更多组件共享相同的出站接口时,才引入 context。

模式选择矩阵

对于优先规定哪些模式的工程经理,权衡取舍如下:

模式引入成本收益可跳过的情况
注册表1 天随目录增长成倍放大;可测试性所需你只有 1 个工具,永远
注册表/流式传输分离2 小时跨界面复用;独立单元测试单个 Server Action
骨架屏每个组件 1 天(定制),1 小时(通用)流式传输时的感知延迟;慢速模型必需内部工具,无 SLA
错误边界2 小时 + 日志接入生产必需;没有它每个 prop bug 都是白屏永远不要——始终发布这个
外部状态0.5–2 天GenUI 触发应用动作时必需只读展示

错误边界一行是唯一不可协商的。其他四个按团队规模排序:独立开发者最后发布骨架屏;5 人团队第一天就建注册表,因为没有注册表的协调成本高于建注册表的成本。

按团队规模估算 TCO

12 个月的大致总拥有成本,假设使用 GPT-4o 级别的推理和中等流量产品(每天 10,000 次生成)。这些是一阶估算——在承诺之前请根据你自己的遥测数据进行校准。

团队规模构建(工程周)推理($/月)运维 + 值班(工程小时/月)
独立(个人)2–3 周$150–$4004–8
小团队(3–5 人)4–6 周$400–$1,2008–16
中团队(10+ 人)8–12 周$1,200–$5,000+16–40

规模化时推理成本占主导。最便宜的杠杆是减少每个 Server Action 的工具数(模式二)并缓存相同的提示词;其次是将简单查询路由到更小的模型。

团队采用路线图

第 1–2 周:在功能标志后面向 5% 的用户发布模式四(错误边界)和模式一(注册表),包含两三个工具。第 3–4 周:添加模式三(骨架屏)和模式二(分离);扩展到 25%。第 5–8 周:添加模式五(状态);推广到 100%。在每个关口等到 p95 延迟、错误率和每次会话的推理成本都在你公布的 SLO 内,再继续。在第一批工具稳定之前不要向注册表添加更多工具。

部署你的 GenUI 应用(独立开发者路径)

如果你是独立工程师,想这个周末发布一个 GenUI 功能,这是最短的可信路径:

  1. 从带 App Router 的 create-next-app 开始。安装 ai@ai-sdk/openaizodreact-error-boundary
  2. 第一个版本跳过模式二——直接在 Server Action 中内联两个工具。
  3. 使用模式三的通用灰色方块骨架,而不是定制版本。等功能有用户之后再发布定制版本。
  4. 用模式四的 <SafeGenUI> 包裹流式传输输出。这是不可协商的。
  5. 部署到 Vercel 免费或 Pro 套餐。在环境变量中添加 OPENAI_API_KEY。首次部署就是 git push
  6. 在 OpenAI 密钥上设置硬性使用上限(OpenAI 仪表板支持月度限制),这样失控的循环就不会在一夜之间耗尽你的预算。

独立开发者的副业项目成本估算(每月 1,000 次生成):推理约 $5–$15,在 Vercel 爱好者套餐上托管 $0,使用 Vercel 内置日志监控 $0。根据我们的估算,第一张有意义的账单大约在每月约 50,000 次生成时开始出现;那是模式二(拆分 Server Actions)和提示词缓存开始物有所值的时候。

独立开发者规模的简化注册表——一个带有一个工具和骨架屏的 Server Action,可直接粘贴:

// app/actions.tsx
'use server'
import { streamUI } from 'ai/rsc'
import { openai } from '@ai-sdk/openai'
import { z } from 'zod'
import { MetricCard } from '@/components/metric-card'
import { Skeleton } from '@/components/skeleton'

const metricSchema = z.object({
  value: z.number().describe('current numeric value of the metric'),
  label: z.string().describe('human-readable metric name'),
  delta: z.number().describe('percent change vs previous period'),
})

export async function generateUI(prompt: string) {
  const result = await streamUI({
    model: openai('gpt-4o-mini'),
    prompt,
    tools: {
      metricCard: {
        description: 'Show a single KPI value with a delta',
        parameters: metricSchema,
        generate: async function* (p: z.infer<typeof metricSchema>) {
          yield <Skeleton className="h-28 rounded bg-muted" />
          return <MetricCard {...p} period="vs last month" />
        },
      },
    },
  })
  return result.value
}

以及一个单表单客户端调用:

// app/page.tsx
'use client'
import { useState } from 'react'
import { generateUI } from './actions'

export default function Page() {
  const [ui, setUI] = useState<React.ReactNode>(null)
  return (
    <form action={async (formData) => {
      setUI(await generateUI(formData.get('q') as string))
    }}>
      <input name="q" />
      <button>生成</button>
      <div>{ui}</div>
    </form>
  )
}

等功能有付费用户或目录中有三个或以上工具时,再升级到完整模式。

常见错误

工具太多。 如果你给 AI 50 个组件可以选择,它会做出糟糕的选择。我见过团队从 20+ 个工具开始,然后发现 AI 持续选错。从 5–8 个定义良好的工具开始,只在数据显示有未匹配查询时才扩展。

描述模糊。 "展示数据"不是有用的工具描述。"当展示具有多个属性的项目列表时,用可排序列的表格展示表格数据"才能告诉 AI 何时使用它。

没有降级。 当 AI 模型宕机或返回错误时,用户看不到任何东西。始终为关键路径提供静态降级 UI。如果你在为数据仪表板使用 Generative UI,要有一个当 AI 不可用时加载的默认静态视图。

跳过 Zod 校验。 AI 偶尔会传入意外的 props——在需要数字的地方传字符串,在需要值的地方传 null。严格的 Zod 校验会在这些到达你的组件之前捕获它们。

过度生成。 并非每次交互都需要 Generative UI。如果静态组件能用,就用静态的。GenUI 增加 200–800ms 的延迟,而且要花钱。把它用在变化真正有价值的交互上。

不记录工具调用。 没有记录 AI 选择了哪些工具以及传入了什么参数,你就没有改进的数据。从第一天起就记录所有内容。一周使用后看到的模式会改变你写工具描述的方式。

生产清单

在将 Generative UI 发布到生产之前:

  • 所有生成的组件都包裹在错误边界中
  • 每个工具都有骨架加载状态
  • AI 不可用或返回错误时的静态降级
  • 所有工具参数都有严格的 Zod 校验
  • 工具调用日志已就位(工具名称、参数、延迟)
  • 延迟监控(如果到第一个组件超过 2 秒则告警)
  • 每次 AI 推理的成本追踪
  • 所有生成组件组合的无障碍审计
  • 生成布局的移动端响应式测试
  • Server Action 上的速率限制

关于测试的说明

测试 Generative UI 需要与传统 UI 测试不同的方法。简短版本:

  • 用标准单元测试独立测试你的组件——它们就是普通的 React 组件
  • 分别测试你的 Zod schema,确保它们接受有效输入并拒绝无效输入
  • 对于针对 AI 的集成测试,测试结构属性(调用了正确的工具,参数有效),而不是精确内容(温度是 22°)
  • 在 CI 中 mock AI,每晚运行真实的 AI 集成测试

这个话题值得单独成文,我们已经写了一篇:测试 Generative UI 应用

考虑过的替代方案

上述模式假设使用带有 React Server Components 的 Vercel AI SDK。有两个值得了解的替代方案,在你做出承诺之前:

  • Tambo / 组件目录运行时。 一个用于 React 上 AI 生成 UI 的开源框架(github.com/tambo-ai/tambo,截至 2026-05 约 11k stars)发布更快(无需编写注册表代码)并集中管理描述质量。当快速出第一个演示比长期单位成本更重要时使用它。
  • 声明式 JSON 协议,如 Thesys C1(封闭 API)或 A2UI v0.9(Google 的开放规范,2025 年 11 月),将模型与 React 完全解耦;任何客户端(web、移动、语音)都可以渲染相同的载荷。当你有非 web 界面时使用这些,代价是需要构建自己的渲染器。
  • 纯 JSON + 手写分发器。 完全不用 SDK。你写一个 switch 语句处理工具名称。小规模时成本最低,超过五个工具后最难维护。

选择轴是护城河与可移植性 vs. 上线速度。对于大多数只有 React 的产品,本文中的 SDK 路径胜出;对于多界面或供应商中立的产品,评估 A2UI。

延伸阅读


正在进行 React Generative UI 实现?获取专家指导,了解架构、性能和生产就绪方面的建议。

分享TwitterLinkedIn邮件
reactgenerative-uipatternsimplementation
A

Alex

Generative UI Engineer & Consultant

专注于 AI 界面与 Generative UI 系统的资深工程师。帮助产品团队用正确的 GenUI 技术栈更快交付。

掌握 Generative UI 前沿动态

每周文章、框架更新与实用实现指南——直达你的邮箱。

我们尊重你的隐私。随时退订。

需要帮助实现你刚读到的内容?

预约免费咨询