Bubble 打字机效果实践:从项目配置到源码实现
前言
在 AI 对话产品里,打字机效果几乎是一个默认体验。
用户发送一句话后,AI 不是等很久再突然丢出一大段内容,而是像正在思考和表达一样,一点点把回复展示出来。
这个效果看起来只是"文字慢慢出现",但在真实项目里,它解决的是一个很实际的问题:
让用户感知系统正在工作,而不是卡住了。
我在做 Zero Code Agent 工作台时,也遇到了这个问题。
后端已经通过 SSE 一段段返回内容了,但如果前端只是把完整内容直接渲染出来,用户看到的还是:
text
等待几秒
突然出现一大段回复
这个体验并不好。
所以消息列表里需要引入打字机效果。
当前项目使用的是 Ant Design X Vue 的 BubbleList 和 Bubble。这篇文章就记录一下:
- 为什么要做打字机效果;
- 我在项目里是怎么配置的;
- 打字机和 SSE 是什么关系;
- 最后结合源码看一下
Bubble是如何实现这个效果的。
这篇会比单纯的组件使用更深入一点,但不会为了讲源码而讲源码,还是从真实业务落地出发。
为什么 AI 对话需要打字机效果
普通接口请求里,用户点击按钮后,页面通常只需要一个 loading。
比如:
text
提交表单
显示 loading
请求完成
展示结果
但 AI 对话不太一样。
AI 回复往往比较长,可能是一段解释、一份方案,甚至是一整段代码。
如果等模型完整生成后再展示,用户体验会变成:
text
用户发送消息
页面空等
等待几秒或十几秒
突然出现一大段内容
用户很难判断:
text
是网络慢?
是模型卡住?
还是系统没有响应?
而打字机效果可以把体验变成:
text
用户发送消息
AI 气泡出现
内容开始逐步展示
用户持续感知系统正在输出
这种反馈对 AI 产品非常重要。
它不只是一个动画,而是一个状态表达。
打字机和流式输出不是一回事
这里有一个很容易混淆的点:
打字机效果不等于流式接口。
它们属于两层能力。
text
SSE / Stream 负责数据怎么从后端到前端
Typing 负责前端怎么把文本展示给用户
如果只有 SSE,没有 typing,前端可能会非常快地把 chunk 拼接出来,视觉上还是像整段出现。
如果只有 typing,没有 SSE,也能把完整文本慢慢显示出来,但用户要等后端完整返回后才能看到第一个字。
比较理想的方式是:
text
后端通过 SSE 持续返回 chunk
前端持续更新 message.content
Bubble 根据 typing 配置做打字机展示
也就是:
text
数据层:真实流式
展示层:打字机动画
这也是我当前项目里的实现方式。
项目里的消息展示结构
当前消息展示组件是:
text
src/features/workbench/components/chat/ChatMessageList.vue
它主要使用了 BubbleList:
vue
<BubbleList
:items="conversation.messages"
:roles="roles"
:auto-scroll="true"
>
</BubbleList>
其中:
| 参数 | 作用 |
|---|---|
items |
当前会话的消息列表 |
roles |
用户和 AI 的气泡样式配置 |
auto-scroll |
内容变化时自动滚动 |
打字机效果本身不在 ChatMessageList 里写定时器,也没有自己操作 DOM。
它依赖消息对象里的 typing 字段。
我是如何配置 typing 的
当用户发送消息后,前端会先往当前会话里插入两条消息。
第一条是用户消息:
ts
{
key: `${now}-user`,
role: 'user',
content: payload.message,
skillKeys: payload.skillKeys,
}
第二条是 AI 消息:
ts
{
key: assistantKey,
role: 'assistant',
content: '',
loading: false,
typing: { step: 2, interval: 24 },
skillKeys: payload.skillKeys,
}
关键就是这里:
ts
typing: { step: 2, interval: 24 }
它的含义是:
| 字段 | 作用 |
|---|---|
step |
每次增加几个字符 |
interval |
每次增加之间的间隔时间 |
当前配置是:
text
每 24ms 展示 2 个字符
这个值没有绝对标准。
如果 interval 太大,AI 看起来会非常慢;如果太小,又会接近"整段闪出"。
我这里选择 step: 2 和 interval: 24,是一个比较折中的体验:
text
有打字机感
不会拖慢太多
长文本也不会让用户等太久
为什么只给新建会话的 AI 回复配置 typing
这里还有一个很重要的项目细节:
打字机效果只应该出现在"当前正在生成的这条 AI 回复"上,不应该出现在所有 AI 消息上。
在当前工作台里,消息大概有两类来源。
第一类是用户刚刚发送消息后,前端临时创建的实时回复:
ts
{
key: assistantKey,
role: 'assistant',
content: '',
loading: false,
typing: { step: 2, interval: 24 },
}
这类消息正在接收 SSE chunk,它应该有打字机效果。
第二类是历史对话接口加载出来的消息。
比如用户点击左侧某个历史会话时,前端会请求后端历史消息,然后把 chat_history 映射成消息列表:
ts
{
key: history.id,
role: history.messageType === 'ai' ? 'assistant' : 'user',
content: history.message,
}
这里不会配置:
ts
typing: { step: 2, interval: 24 }
原因很简单:
text
历史消息已经生成完成了
用户打开历史会话时应该直接看到完整内容
不应该把过去的每条 AI 回复重新打一遍
如果把 typing 放到 roles.assistant 里,所有 AI 消息都会默认启用打字机。
这会导致:
text
欢迎语会打字
历史消息会打字
错误消息可能也会打字
已经完成的回复重新进入页面时还会打字
这些都不是一个真实产品里想要的效果。
所以我更倾向于把职责分开:
text
roles 负责角色的稳定样式
message item 负责单条消息的实时状态
适合放在 roles.assistant 的是:
ts
{
placement: 'start',
variant: 'borderless',
avatar: {...},
classNames: { content: 'agent-message-ai' },
}
这些属于"AI 消息长什么样"。
适合放在单条消息里的才是:
ts
{
loading: false,
typing: { step: 2, interval: 24 },
}
这些属于"这条消息当前是什么状态"。
这也是我在实际项目里更推荐的写法:
新创建的流式 assistant 消息配置 typing,历史消息不配置 typing。
SSE chunk 如何进入消息
AI 消息一开始是空的:
ts
content: ''
当后端通过 SSE 返回 chunk 时,前端会不断追加到这条消息的 content。
核心逻辑是:
ts
onChunk: (chunk) => {
replaceMessage(target, assistantKey, item => ({
...item,
loading: false,
content: `${item.content}${chunk}`,
}));
}
假设后端依次返回:
text
你
好
,
我
可
以
帮
你
那么消息状态会不断变化:
text
content = '你'
content = '你好'
content = '你好,'
content = '你好,我'
content = '你好,我可以'
但是用户看到的并不一定是完整的 content。
因为 Bubble 内部会根据 typing 做一层展示处理。
也就是说:
text
message.content 是完整数据
Bubble 实际显示的是打字机处理后的内容
这个分层很重要。
业务层只需要维护真实文本,展示节奏交给组件。
为什么 done 时不能立刻关闭 typing
这个地方我踩过一个坑。
一开始我在收到 SSE 的 done 事件后,直接把 typing 设置成了 false。
看起来很合理:
text
后端结束了
那前端也结束 typing
但实际效果不对。
因为后端 chunk 可能返回得非常快,done 很快就到了。
如果这时立刻关闭 typing,组件还没来得及把文字一个个打出来,最终用户看到的就是:
text
突然出现一整段
所以现在的处理方式是:
ts
onDone: () => {
finishRunning(target, '刚刚完成回复');
}
注意这里没有关闭 typing。
done 只表示:
text
后端流式数据传输结束
但视觉上的打字机展示,应该继续交给 Bubble 自己完成。
只有在报错或用户取消时,才需要主动关闭:
ts
typing: false
这个细节很小,但对体验影响很明显。
不要用 loading 抢占内容区
另一个容易踩的点是 loading。
Bubble 支持 loading 状态,如果设置:
ts
loading: true
它会优先展示 loading 内容。
在普通请求里这很合理。
但在 SSE + typing 的场景里,如果一开始就给 AI 消息设置 loading: true,用户看到的可能是:
text
正在生成中...
等 loading 结束后,再突然显示完整内容。
这就把打字机体验破坏了。
所以当前项目里,AI 消息创建时是:
ts
loading: false
这样 Bubble 可以直接接管 content 和 typing。
换句话说:
text
流式输出场景里,loading 不应该抢占消息内容区
从源码看 typing 是怎么生效的
下面结合 Ant Design X Vue 的源码看一下它是怎么实现的。
这里主要涉及三个部分:
text
Bubble.vue
useTypingConfig
useTypedEffect
源码位置大致在:
text
node_modules/ant-design-x-vue/lib/bubble/
useTypingConfig:解析 typing 配置
typing 可以传布尔值,也可以传对象。
比如:
ts
typing: true
或者:
ts
typing: { step: 2, interval: 24 }
源码里会先把它解析成统一配置。
核心逻辑可以简化成:
ts
const typingEnabled = computed(() => !!typing);
const defaultConfig = {
step: 1,
interval: 50,
suffix: null,
};
const mergedConfig = computed(() => {
return {
...defaultConfig,
...(typeof typing === 'object' ? typing : {}),
};
});
也就是说,如果只写:
ts
typing: true
组件会使用默认配置:
text
step = 1
interval = 50
如果写:
ts
typing: { step: 2, interval: 24 }
就会覆盖默认值。
所以项目里这段配置:
ts
typing: { step: 2, interval: 24 }
最终会被解析成:
text
启用 typing
每次增加 2 个字符
每 24ms 增加一次
useTypedEffect:真正实现打字机
真正做打字机效果的是 useTypedEffect。
它内部维护了一个当前展示长度。
可以简化理解成:
ts
const currentLength = ref(1);
然后根据 currentLength 从完整内容里截取一部分:
ts
displayContent = content.slice(0, currentLength);
当 typing 开启,并且当前展示长度还没达到完整内容长度时,它会启动一个定时器:
ts
setTimeout(() => {
currentLength += step;
}, interval);
于是完整内容是:
text
你好,我可以帮你分析这个需求
实际展示会变成:
text
你
你好
你好,
你好,我
你好,我可
...
这就是打字机效果的本质:
text
完整文本不变
展示长度一点点增加
组件不是不断拼字符串,而是不断截取已有字符串。
内容变化时如何继续打字
AI 回复不是一次性给出完整文本,而是 SSE 持续追加。
所以 content 会不断变化:
text
你好
你好,我
你好,我可以
你好,我可以帮你
useTypedEffect 里会监听 content 的变化。
当新内容是旧内容的延续时,它不会把打字机重置到开头,而是继续往后展示。
也就是说:
text
旧内容:你好,我
新内容:你好,我可以帮你
组件知道这是同一条消息在继续增长,于是从当前展示位置继续打。
这点对 SSE 场景很关键。
否则每来一个 chunk,打字机都从第一个字重新开始,那体验就崩了。
这背后有一个关键逻辑:判断新旧内容之间的关系。
源码里有一个类似这样的函数,用来找两个字符串的最长公共前缀:
ts
function getCommonPrefixLength(prev: string, next: string) {
let index = 0;
const maxLength = Math.min(prev.length, next.length);
while (index < maxLength && prev[index] === next[index]) {
index += 1;
}
return index;
}
它要解决的问题是:
text
前面已经展示过的部分,不要重新打
后面新增进来的部分,继续按 typing 往后打
比如第一次内容是:
text
你好,我
这时页面可能已经展示到:
text
你好
下一次 SSE chunk 到达后,完整内容变成:
text
你好,我可以帮你
新内容并不是一条全新的消息,而是在旧内容后面继续追加。
所以合理的行为是:
text
已经展示过的"你好"不要消失
也不要从"你"重新开始打
继续把后面的",我可以帮你"打出来
useTypedEffect 里会保存上一次的完整内容。
当 content 变化时,它会判断:
text
新内容是不是以旧内容开头
如果是,说明这是正常追加:
text
旧内容:你好,我
新内容:你好,我可以帮你
这种情况下不需要重置打字机,只要继续增加展示长度。
如果不是正常追加,比如:
text
旧内容:你好,我可以帮你
新内容:抱歉,刚才生成失败
说明内容发生了替换。
这时就不能简单从旧展示长度继续往后打,因为前面内容已经不一样了。
所以源码会找最长公共前缀:
text
如果新内容不是旧内容的前缀
说明内容发生了替换
需要找到公共前缀或重置展示位置
可以理解成:
text
旧内容:你好,我可以帮你
新内容:你好,这里需要重新生成
公共前缀:你好,
这时候打字机可以从公共前缀后面继续,而不是完全从第一个字开始。
如果公共前缀长度为 0,就从开头重新打。
这段逻辑看起来很细,但实际非常重要。
因为在真实项目里,消息内容不一定永远是简单追加。
它可能遇到:
text
SSE chunk 合并
内容重试
错误消息覆盖
模型输出修正
前端重新设置 content
最长公共前缀的意义就在于:
尽量保留用户已经看到的稳定部分,只对新变化的部分做打字机展示。
这个处理让它既能支持正常追加,也能支持内容被替换时的展示修正。
Bubble 如何使用 typedContent
在 Bubble 组件里,它不会直接把原始 content 渲染出来。
它会先经过 useTypedEffect:
ts
const [typedContent, isTyping] = useTypedEffect(...);
然后真正渲染的是:
ts
typedContent
可以理解成:
text
props.content 完整内容
typedContent 当前应该展示给用户看的内容
当 typing 开启时:
text
typedContent = content.slice(0, currentLength)
当 typing 关闭时:
text
typedContent = content
所以业务侧只需要持续更新 content,组件内部会负责把它变成逐字展示。
光标效果是怎么来的
打字机效果除了文字逐步出现,还通常会有一个闪烁光标。
Bubble 里也做了这个效果。
当组件处于 typing 状态时,会给气泡加上类似这样的 class:
text
ant-bubble-typing
然后 CSS 里通过伪元素加一个光标:
css
.ant-bubble-typing .ant-bubble-content:last-child::after {
content: "|";
animation: cursorBlink 0.8s infinite;
}
所以用户看到的效果是:
text
你好,我正在分析 |
这个光标不是我们业务代码里写的,而是 Bubble 自己根据 typing 状态加上的。
为什么不要自定义 message 插槽
我一开始还踩过另一个坑:用了 BubbleList 的 message 插槽手动渲染内容。
类似这样:
vue
<template #message="{ item }">
<span>{{ item.content }}</span>
</template>
这会导致一个问题:
text
你自己直接渲染 item.content
Bubble 内部的 typedContent 就被绕开了
也就是说,即使消息上配置了:
ts
typing: { step: 2, interval: 24 }
最后页面还是直接显示完整 item.content。
所以当前项目没有使用 message 插槽。
如果要使用打字机效果,应该让 Bubble 自己渲染内容:
vue
<BubbleList :items="conversation.messages" />
可以自定义:
text
header
footer
avatar
roles
但不要轻易接管 message 渲染。
除非你非常清楚如何把 content 参数接进来,而不是直接读原始 item.content。
当前项目最终链路
现在项目里的完整链路是:
text
用户输入消息
↓
前端插入 user 消息
↓
前端插入 assistant 空消息,并配置 typing
↓
建立 SSE 连接
↓
后端持续返回 chunk
↓
前端持续追加 assistant.content
↓
Bubble 监听 content 变化
↓
useTypedEffect 控制 typedContent 展示长度
↓
页面形成打字机效果
这条链路里,每一层的职责是清楚的:
| 层级 | 责任 |
|---|---|
| SSE | 传输流式数据 |
| useWorkbenchChat | 维护消息状态 |
| BubbleList | 渲染消息列表 |
| Bubble | 处理单条消息展示 |
| useTypedEffect | 生成打字机展示内容 |
这种分层比在业务代码里自己写定时器更稳定。
因为业务层不应该关心:
text
当前展示到第几个字
定时器什么时候清理
光标什么时候显示
内容替换时怎么处理
这些都应该交给 UI 组件内部解决。
业务层只负责:
text
把真实 message.content 更新好
把 typing 配置传进去
小结
这次实现打字机效果,最终其实只需要一个很小的配置:
ts
typing: { step: 2, interval: 24 }
但真正要把它用好,需要理解它和 SSE、loading、message 插槽之间的关系。
我的实际经验是:
text
1. SSE 负责流式数据,不负责视觉节奏
2. typing 负责视觉展示,不替代 SSE
3. 不要在 done 时立刻关闭 typing
4. 不要用 loading 抢占流式消息内容区
5. 不要随便用 message 插槽绕开 Bubble 内部渲染
从项目角度看,最理想的状态是:
text
接口层只处理数据流
状态层只维护完整消息
组件层负责展示体验
这样打字机效果既能跑起来,也不会把业务代码写得很重。
后面如果继续深入,可以再单独拆一篇文章,专门分析 BubbleList 的 autoScroll 是怎么配合 BubbleContext 做自动滚动的。