让 AI 的回答「逐段说话」:react-streaming 的顺序流式渲染实践
目标读者 :做过 AI 对话、报告类前端应用的 React 工程师
核心价值 :看一个约 1200 行的小库,如何用「条目 + 效果组件 + onFinish」替代散落在业务里的定时器和动画调度
阅读时间:6--7 分钟
AI 的回答不是一段文本,是一串内容。打字机不该逐字重绘页面,而该逐段放行 React 组件。

背景:AI 回答早就不是「一段文字」了
流式展示的本意很简单:内容分片到达,前端边收边画,用户不必干等整段回答。
麻烦在于,现在模型吐出来的不只是文字。指标卡、表格、图表、带图的分析段,挤在同一串回复里。前端要做的,不再是把 token 印到屏幕上,而是让类型各异的内容按顺序、带节奏地出场。
还有一种更常遇到的情况:接口直接返回结构化 JSON。一份分析报告是 { summary, skills, metrics, suggestions },数据一个请求就齐了。但你仍然希望总结先打字、标签再浮现、指标卡最后淡入。数据什么时候到,和组件什么时候出场,是两件事。 节奏变成了前端自己要负责的一层。
早年聊天窗口对着 Markdown 逐字输出就够了。现在回答里挂着真正的 React 组件------要挂载、要动画、要占布局。它们是 ReactNode,不能像字符串那样 substring。切坏了,就是渲染错误。
当前遇到的问题:时序逻辑散落在各处
自己动手写这类界面,通常会撞上四个坑:
- 「什么时候显示下一块」没有统一答案。 A 组件里塞一个
setTimeout延 300ms,B 组件等上一个动画的 Promise,C 干脆全部渲染再集体淡入。内容一多,所有东西同时冒出来,顺序荡然无存。 - 打字机只会打字符串。 手写一个逐字文本组件不难;难的是对一棵含卡片、图片的 JSX 树逐字输出。文本可以截一段算一段,React 节点不能这么切。
- 新一轮回答来了,旧动画还在跑。 用户连发两条消息,上一轮遗留的定时器晚到一步,把新一轮的显示进度往前推了一格。这类竞态难复现,也难修。
- 内容一长高就要跟滚,用户上翻时又不能抢滚动条。 贴底跟随和手动翻历史,两件事容易打架。
四个坑其实是同一件事:缺一套约定,规定谁先谁后、何时算结束、内容换了一轮怎么办。动画本身反而是小事。
引入 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 | 为每个条目解析生效的 effect 与 effectOptions;用稳定 key 让各条目动画互不干扰 |
不做放行决策,不管时序 |
| 内置效果 ×4 | effects | 负责「这一条怎么出场」,播完调用 onFinish |
不知道下一条是谁,无权推进队列 |
层与层之间的协作可以压成一句话:状态机决定哪条可见,效果决定怎么出场,两者之间只有 onFinish 这一个握手。 enabled=false 时全部条目直接可见------关掉流式就是一次画完,不会另走一套逻辑。
状态机里有两个防御性设计值得细看:
- 序列签名。
useStreaming把resetKey和所有条目的key拼成一个签名字符串。签名变了(用户发了新问题、开始新一轮回答),进度归零,从头回放。每条onFinish都携带签名,与当前对不上就丢弃。上一轮迟到的回调没法误伤新一轮------上面第 3 个坑从源头解决。 - 稳定回调。 每条绑定一个按下标生成的固定
onFinish,函数引用只在序列变化时才重建。否则父组件每次重渲染都会生成新函数,动画组件误以为内容变了,重新开播。
effect 为什么能随便换
可插拔不靠注册表,也没有工厂或基类。effect 的类型就是一个普通的 React 组件:
ts
type StreamingEffect = React.ComponentType<StreamingEffectProps>;
任何组件,只要接住那四个 props、播完调用 onFinish,就能当 effect 用。剩下的工作只有一条选择规则,加一个兜底组件:
- 选择按优先级。 条目自己的
effect覆盖全局effect;条目显式传effect={null}表示本条不做动画;animation关掉时,所有条目统一换成CompleteEffect------原样渲染content,挂载即onFinish,保证没有动画时流程照样一条条走完,不会卡死。effectOptions按同样的优先级解析。 - 参数各效果自定。
options是一张开放字典(Record<string, unknown>)。TypewriterEffect从里面读speed,BlockRevealEffect读delay和duration。库本身不定义任何公共参数,所以新效果的参数名永远不会和框架冲突。
于是自定义效果的规则只剩三条:播完必须调用一次 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 storybook 看 Components/Streaming dashboard,里面是文字、指标卡、图片、图表、表格混排的完整长序列。
内置的 effects
四个内置效果,覆盖常见节奏(参数单位均为毫秒):
| 效果 | 出场方式 | 关键参数(默认值) |
|---|---|---|
TypewriterEffect |
逐字打出 | speed(50ms/字)、time(初始快进毫秒数) |
WordRevealEffect |
逐词打出 | speed(80ms/词)、time(初始快进毫秒数) |
BlockRevealEffect |
整块延迟淡入 | delay(350ms)、duration(180ms) |
ListRevealEffect |
直接子元素依次淡入 | stagger(相邻两项间隔 80ms)、duration(180ms)、delay(0ms) |
打字机能处理组件树,靠的是 useTypewriter。TypewriterEffect 和 WordRevealEffect 共用它:对任意 ReactNode 逐字或逐词播放,而不只是字符串。思路是先解析、再重放:
- 把传入的 JSX 解析成一棵段落树(
SegmentNode)。文本节点按字或词计长度,可以截断;<img />这类 HTML 空元素,以及不含文本的自定义组件,按「原子」处理,长度记 1,要么整体出现、要么不出现;普通元素递归子节点,长度是子节点之和。 - 定时器每响一次消费 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 外零运行时依赖的库。
跳出这个库,三条做法可以复用到别处:
- 先定状态,再定动画。 「播到第几条」用一个整数维护,动画只是这个整数变化后的表现层。两层各自都简单,合起来才谈得上扩展。
- 用回调约定代替中央调度。 效果组件不知道自己排在第几、下一条是什么,只负责播完交棒。加一个新效果,不需要动状态机。
- 换代靠标识,不靠清理。 给每轮内容算一个签名,旧回调带签名对不上就丢,比在每处定时器里记得清理更可靠。
让界面跟上模型说话的节奏,不需要多复杂的框架。一套清晰的小契约就够了。
源码
- 项目源码与 README:github.com/localSummer/react-streaming
- Storybook 示例:
Components/Streaming dashboardstory