让 AI 的回答「逐段说话」:react-streaming 的顺序流式渲染实践

让 AI 的回答「逐段说话」:react-streaming 的顺序流式渲染实践

目标读者 :做过 AI 对话、报告类前端应用的 React 工程师

核心价值 :看一个约 1200 行的小库,如何用「条目 + 效果组件 + onFinish」替代散落在业务里的定时器和动画调度

阅读时间:6--7 分钟
AI 的回答不是一段文本,是一串内容。打字机不该逐字重绘页面,而该逐段放行 React 组件。


背景:AI 回答早就不是「一段文字」了

流式展示的本意很简单:内容分片到达,前端边收边画,用户不必干等整段回答。

麻烦在于,现在模型吐出来的不只是文字。指标卡、表格、图表、带图的分析段,挤在同一串回复里。前端要做的,不再是把 token 印到屏幕上,而是让类型各异的内容按顺序、带节奏地出场。

还有一种更常遇到的情况:接口直接返回结构化 JSON。一份分析报告是 { summary, skills, metrics, suggestions },数据一个请求就齐了。但你仍然希望总结先打字、标签再浮现、指标卡最后淡入。数据什么时候到,和组件什么时候出场,是两件事。 节奏变成了前端自己要负责的一层。

早年聊天窗口对着 Markdown 逐字输出就够了。现在回答里挂着真正的 React 组件------要挂载、要动画、要占布局。它们是 ReactNode,不能像字符串那样 substring。切坏了,就是渲染错误。


当前遇到的问题:时序逻辑散落在各处

自己动手写这类界面,通常会撞上四个坑:

  1. 「什么时候显示下一块」没有统一答案。 A 组件里塞一个 setTimeout 延 300ms,B 组件等上一个动画的 Promise,C 干脆全部渲染再集体淡入。内容一多,所有东西同时冒出来,顺序荡然无存。
  2. 打字机只会打字符串。 手写一个逐字文本组件不难;难的是对一棵含卡片、图片的 JSX 树逐字输出。文本可以截一段算一段,React 节点不能这么切。
  3. 新一轮回答来了,旧动画还在跑。 用户连发两条消息,上一轮遗留的定时器晚到一步,把新一轮的显示进度往前推了一格。这类竞态难复现,也难修。
  4. 内容一长高就要跟滚,用户上翻时又不能抢滚动条。 贴底跟随和手动翻历史,两件事容易打架。

四个坑其实是同一件事:缺一套约定,规定谁先谁后、何时算结束、内容换了一轮怎么办。动画本身反而是小事。


引入 react-streaming:一个「逐条放行」的小库

[@roaming-ai/react-streaming](https://github.com/localSummer/react-streaming) 针对这些问题,源码大约 1200 行 TypeScript;React 以 peer dependency 声明,产物里不重复打包。

它的模型只有三个概念:

  • 内容拆成有序的 items (条目),每条 = 一个稳定 key + 一个任意 ReactNode
  • 每条的出场方式交给一个 effect(效果组件)播放;
  • 效果播完调用 **onFinish** 报告结束,状态机 useStreaming 才放行下一条。

库对外只约定一个接口。任何 effect 必须接住这四个 props:

ts 复制代码
interface StreamingEffectProps {
  content: React.ReactNode;   // 这条要展示的内容
  onFinish: () => void;       // 播完时调用,交棒给下一条
  animation: boolean;         // false 时跳过动画,直接渲染终态
  options?: Record<string, unknown>; // 该效果自己的参数
}

效果组件不需要知道队列里还有谁,只需要负责自己这一段什么时候说完。


项目架构:每层只管一件事

src 下四个目录对应四层:

复制代码
src/
├── core/          # 类型契约 + 条目渲染(renderItems)
├── hooks/         # useStreaming 状态机、useStreamingAutoScroll
├── components/    # Streaming(对外主组件)、StreamingRenderer(纯展示)
└── effects/       # 内置效果:Typewriter / WordReveal / BlockReveal / ListReveal

整体数据流与扩展关系如下:
#mermaid-svg-EhcNk65IoIEQqyGh{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-EhcNk65IoIEQqyGh .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-EhcNk65IoIEQqyGh .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-EhcNk65IoIEQqyGh .error-icon{fill:#552222;}#mermaid-svg-EhcNk65IoIEQqyGh .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-EhcNk65IoIEQqyGh .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-EhcNk65IoIEQqyGh .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-EhcNk65IoIEQqyGh .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-EhcNk65IoIEQqyGh .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-EhcNk65IoIEQqyGh .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-EhcNk65IoIEQqyGh .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-EhcNk65IoIEQqyGh .marker{fill:#333333;stroke:#333333;}#mermaid-svg-EhcNk65IoIEQqyGh .marker.cross{stroke:#333333;}#mermaid-svg-EhcNk65IoIEQqyGh svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-EhcNk65IoIEQqyGh p{margin:0;}#mermaid-svg-EhcNk65IoIEQqyGh .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-EhcNk65IoIEQqyGh .cluster-label text{fill:#333;}#mermaid-svg-EhcNk65IoIEQqyGh .cluster-label span{color:#333;}#mermaid-svg-EhcNk65IoIEQqyGh .cluster-label span p{background-color:transparent;}#mermaid-svg-EhcNk65IoIEQqyGh .label text,#mermaid-svg-EhcNk65IoIEQqyGh span{fill:#333;color:#333;}#mermaid-svg-EhcNk65IoIEQqyGh .node rect,#mermaid-svg-EhcNk65IoIEQqyGh .node circle,#mermaid-svg-EhcNk65IoIEQqyGh .node ellipse,#mermaid-svg-EhcNk65IoIEQqyGh .node polygon,#mermaid-svg-EhcNk65IoIEQqyGh .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-EhcNk65IoIEQqyGh .rough-node .label text,#mermaid-svg-EhcNk65IoIEQqyGh .node .label text,#mermaid-svg-EhcNk65IoIEQqyGh .image-shape .label,#mermaid-svg-EhcNk65IoIEQqyGh .icon-shape .label{text-anchor:middle;}#mermaid-svg-EhcNk65IoIEQqyGh .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-EhcNk65IoIEQqyGh .rough-node .label,#mermaid-svg-EhcNk65IoIEQqyGh .node .label,#mermaid-svg-EhcNk65IoIEQqyGh .image-shape .label,#mermaid-svg-EhcNk65IoIEQqyGh .icon-shape .label{text-align:center;}#mermaid-svg-EhcNk65IoIEQqyGh .node.clickable{cursor:pointer;}#mermaid-svg-EhcNk65IoIEQqyGh .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-EhcNk65IoIEQqyGh .arrowheadPath{fill:#333333;}#mermaid-svg-EhcNk65IoIEQqyGh .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-EhcNk65IoIEQqyGh .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-EhcNk65IoIEQqyGh .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EhcNk65IoIEQqyGh .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-EhcNk65IoIEQqyGh .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EhcNk65IoIEQqyGh .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-EhcNk65IoIEQqyGh .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-EhcNk65IoIEQqyGh .cluster text{fill:#333;}#mermaid-svg-EhcNk65IoIEQqyGh .cluster span{color:#333;}#mermaid-svg-EhcNk65IoIEQqyGh div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-EhcNk65IoIEQqyGh .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-EhcNk65IoIEQqyGh rect.text{fill:none;stroke-width:0;}#mermaid-svg-EhcNk65IoIEQqyGh .icon-shape,#mermaid-svg-EhcNk65IoIEQqyGh .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EhcNk65IoIEQqyGh .icon-shape p,#mermaid-svg-EhcNk65IoIEQqyGh .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-EhcNk65IoIEQqyGh .icon-shape .label rect,#mermaid-svg-EhcNk65IoIEQqyGh .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EhcNk65IoIEQqyGh .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-EhcNk65IoIEQqyGh .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-EhcNk65IoIEQqyGh :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} effects/ 插件层
core/ 契约层
hooks/ 时序层
components/ 接口层
visibleItems + 每条专属 onFinish
renderStreamingItems
播完调用 onFinish,放行下一条
播完调用 onFinish,放行下一条
播完调用 onFinish,放行下一条
播完调用 onFinish,放行下一条
播完调用 onFinish,放行下一条
业务数据(如大模型返回的结构化 JSON)
映射为 items:key + ReactNode
Streaming 主组件
StreamingRenderer(纯展示,可单独使用)
useStreaming 状态机

streamingIndex · 序列签名 · 迟到回调校验
useStreamingAutoScroll

(独立可选)
效果选择优先级

条目级 > 全局 > CompleteEffect

animation=false → CompleteEffect
TypewriterEffect 逐字
WordRevealEffect 逐词
BlockRevealEffect 整块淡入
ListRevealEffect 子元素依次淡入
自定义 effect

各成员的职责和边界:

成员 所在层 职责(只负责什么) 明确不负责
Streaming components 对外主入口:装配状态机与渲染层;把 enabled && animation 收成统一动画开关;流式未播完时显示 fallback 不渲染条目,不碰动画
StreamingRenderer components 纯展示:渲染已放行条目和 fallback 不持有流式状态,可脱离状态机单独使用
useStreaming hooks 唯一状态机:维护 streamingIndex,推导 visibleItems(已完成条目 + 正在播放的当前条);序列签名负责换代重置;回调校验丢弃迟到的 onFinish;为每条生成稳定回调 不执行任何动画
useStreamingAutoScroll hooks 贴底跟随:容器长高时滚到底部,检测到用户主动滚动立即让位 与流式进度无关,独立可选
renderStreamingItems core 为每个条目解析生效的 effecteffectOptions;用稳定 key 让各条目动画互不干扰 不做放行决策,不管时序
内置效果 ×4 effects 负责「这一条怎么出场」,播完调用 onFinish 不知道下一条是谁,无权推进队列

层与层之间的协作可以压成一句话:状态机决定哪条可见,效果决定怎么出场,两者之间只有 onFinish 这一个握手。 enabled=false 时全部条目直接可见------关掉流式就是一次画完,不会另走一套逻辑。

状态机里有两个防御性设计值得细看:

  1. 序列签名。 useStreamingresetKey 和所有条目的 key 拼成一个签名字符串。签名变了(用户发了新问题、开始新一轮回答),进度归零,从头回放。每条 onFinish 都携带签名,与当前对不上就丢弃。上一轮迟到的回调没法误伤新一轮------上面第 3 个坑从源头解决。
  2. 稳定回调。 每条绑定一个按下标生成的固定 onFinish,函数引用只在序列变化时才重建。否则父组件每次重渲染都会生成新函数,动画组件误以为内容变了,重新开播。

effect 为什么能随便换

可插拔不靠注册表,也没有工厂或基类。effect 的类型就是一个普通的 React 组件:

ts 复制代码
type StreamingEffect = React.ComponentType<StreamingEffectProps>;

任何组件,只要接住那四个 props、播完调用 onFinish,就能当 effect 用。剩下的工作只有一条选择规则,加一个兜底组件:

  1. 选择按优先级。 条目自己的 effect 覆盖全局 effect;条目显式传 effect={null} 表示本条不做动画;animation 关掉时,所有条目统一换成 CompleteEffect------原样渲染 content,挂载即 onFinish,保证没有动画时流程照样一条条走完,不会卡死。effectOptions 按同样的优先级解析。
  2. 参数各效果自定。 options 是一张开放字典(Record<string, unknown>)。TypewriterEffect 从里面读 speedBlockRevealEffectdelayduration。库本身不定义任何公共参数,所以新效果的参数名永远不会和框架冲突。

于是自定义效果的规则只剩三条:播完必须调用一次 onFinish(漏了,下一条就永远等不到放行;内置实现用幂等的 complete() 收敛重复触发);animation=false 时直接渲染终态并立即 onFinish;组件卸载时清掉自己的定时器。

好的扩展点是契约,不是配置项。


项目如何使用

bash 复制代码
npm install @roaming-ai/react-streaming

最小示例:一份 AI 报告分「总结、分析」两段,依次出场,分析段里就放着组件。

tsx 复制代码
import { Streaming, TypewriterEffect } from '@roaming-ai/react-streaming';
import type { StreamingItem } from '@roaming-ai/react-streaming';

const items: StreamingItem[] = [
  { key: 'summary', content: <section><h2>总结</h2><p>先逐字打出总结。</p></section> },
  { key: 'analysis', content: <section><h2>分析</h2><MetricCard /></section> },
];

export const Report = ({ streaming }: { streaming: boolean }) => (
  <Streaming
    items={items}
    enabled={streaming}
    effect={TypewriterEffect}
    effectOptions={{ speed: 30 }}
    fallback={<div>Loading next section...</div>}
  />
);

三个常用的进阶配置:

  • 单条覆盖: 某个 item 单独传 effect(比如指标卡换成 BlockRevealEffect 淡入),或传 effect={null} 让纯标题立刻出现;
  • 序列换代: 把会话的轮次 id 传给 resetKey,新一轮从头回放,上一轮迟到的回调被签名校验丢弃;
  • 自动滚动: 需要贴底跟随时,用 useStreaming + StreamingRenderer 自己装配------<Streaming> 不往外抛进度,而滚动依赖需要知道「放行了几条」。
tsx 复制代码
const { visibleItems, isStreaming } = useStreaming({ items, enabled: streaming, resetKey });
const { containerRef, bottomRef } = useStreamingAutoScroll({
  enabled: streaming,
  deps: [visibleItems.length],
  behavior: 'smooth',
});

useStreamingAutoScroll 本身是滚动跟随类需求的一份完整参考:用 ResizeObserver 盯住容器里每个子元素的增高,用 MutationObserver 处理新节点插入;平滑滚动用 requestAnimationFrame 做了约 160ms 的缓动,内容继续长高也没关系,每一帧都重新计算目标位置。一旦用户碰了滚轮、触摸或指针按下,立即取消跟随,把滚动条交还给用户。

接入时再记三点:React >=16.8 <20(Hooks 起始版本);产物同时提供 ESM 与 CJS,并带类型声明,sideEffects: false 可被 tree-shaking;本地可用 npm run storybookComponents/Streaming dashboard,里面是文字、指标卡、图片、图表、表格混排的完整长序列。


内置的 effects

四个内置效果,覆盖常见节奏(参数单位均为毫秒):

效果 出场方式 关键参数(默认值)
TypewriterEffect 逐字打出 speed(50ms/字)、time(初始快进毫秒数)
WordRevealEffect 逐词打出 speed(80ms/词)、time(初始快进毫秒数)
BlockRevealEffect 整块延迟淡入 delay(350ms)、duration(180ms)
ListRevealEffect 直接子元素依次淡入 stagger(相邻两项间隔 80ms)、duration(180ms)、delay(0ms)

打字机能处理组件树,靠的是 useTypewriterTypewriterEffectWordRevealEffect 共用它:对任意 ReactNode 逐字或逐词播放,而不只是字符串。思路是先解析、再重放:

  1. 把传入的 JSX 解析成一棵段落树(SegmentNode)。文本节点按字或词计长度,可以截断;<img /> 这类 HTML 空元素,以及不含文本的自定义组件,按「原子」处理,长度记 1,要么整体出现、要么不出现;普通元素递归子节点,长度是子节点之和。
  2. 定时器每响一次消费 1 个 unit(一个字或一个词),再按当前已消费数重放段落树,得到这一帧的画面。

因为有原子规则,图片不会被「打」出一半,自定义组件也不会先渲染出一个空壳再填内容。

自定义效果照着 StreamingEffectProps 写即可。一个延时 300ms 交棒的淡入效果:

tsx 复制代码
const FadeEffect: React.FC<StreamingEffectProps> = ({ content, onFinish, animation }) => {
  React.useEffect(() => {
    if (!animation) {
      onFinish();
      return;
    }
    const timer = window.setTimeout(onFinish, 300);
    return () => window.clearTimeout(timer);
  }, [animation, onFinish]);
  return <>{content}</>;
};

两个已知边界:ListRevealEffect 只处理内容根节点的直接子元素,嵌套层级不下钻(刻意不做选择器式配置);打字机按「字 / 词」消费单位,想要逐字缩放这类更重的动效,请自己写 effect。


总结

对照开头的四个坑:逐条放行加 onFinish 约定解决了显示顺序;段落树解析让打字机能处理组件树;序列签名和回调校验挡住轮次竞态;useStreamingAutoScroll 处理了跟滚和让位。四个问题,一个约 1200 行、除 React 外零运行时依赖的库。

跳出这个库,三条做法可以复用到别处:

  1. 先定状态,再定动画。 「播到第几条」用一个整数维护,动画只是这个整数变化后的表现层。两层各自都简单,合起来才谈得上扩展。
  2. 用回调约定代替中央调度。 效果组件不知道自己排在第几、下一条是什么,只负责播完交棒。加一个新效果,不需要动状态机。
  3. 换代靠标识,不靠清理。 给每轮内容算一个签名,旧回调带签名对不上就丢,比在每处定时器里记得清理更可靠。

让界面跟上模型说话的节奏,不需要多复杂的框架。一套清晰的小契约就够了。


源码

相关推荐
ar01231 小时前
AR用沉浸式体验改变教育与培训:AR+AI正在重构学习方式
人工智能·ar
BioRunYiXue1 小时前
科研干货 | IC50全面解读:概念解析、实验设计与数据分析要点
java·开发语言·javascript·人工智能·算法·数据挖掘·数据分析
hahaha60161 小时前
HLS高层次综合设计技巧--UART_TX接收模块设计案例
人工智能·算法·计算机视觉
天天被压力1 小时前
【零依赖量化数据实战 #31】沪深A实时盘口全景:逐笔·全盘实时·最新价·历史逐笔
java·人工智能·python
Mr数据杨1 小时前
小样本图像分类实战 Cleaned vs Dirty V2 盘子清洁识别案例解析
人工智能·数据分析·kaggle竞赛
cidy_981 小时前
Main 正式环境合并说明
前端
zzzll11111 小时前
Ollama 本地大模型部署与使用指南
人工智能
万物智能1 小时前
开源鸿蒙内核配置与驱动三条路径—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
前端·后端
hunterandroid1 小时前
Android 多渠道打包与 Gradle 构建优化实战
android·前端·kotlin