← 返回AI变现
🌐 其他

Bubble 打字机效果实践:从项目配置到源码实现

来源:掘金 · 发布于 2026-08-20 17:31:24
Bubble 打字机效果实践:
在 AI 对话产品里,打字机效果几乎是一个默认体验。 用户发送一句话后,AI 不是等很久再突然丢出一大段内容,而是像正在思考和表达一样,一点点回复展示。这篇文章从真实业务落地出发,讲解打字机效果。

Bubble 打字机效果实践:从项目配置到源码实现

one_last_FE 2026-08-20 0 阅读12分钟

Bubble 打字机效果实践:从项目配置到源码实现

前言

在 AI 对话产品里,打字机效果几乎是一个默认体验。

用户发送一句话后,AI 不是等很久再突然丢出一大段内容,而是像正在思考和表达一样,一点点把回复展示出来。

这个效果看起来只是“文字慢慢出现”,但在真实项目里,它解决的是一个很实际的问题:

让用户感知系统正在工作,而不是卡住了。

我在做 Zero Code Agent 工作台时,也遇到了这个问题。

后端已经通过 SSE 一段段返回内容了,但如果前端只是把完整内容直接渲染出来,用户看到的还是:

等待几秒
突然出现一大段回复

这个体验并不好。

所以消息列表里需要引入打字机效果。

当前项目使用的是 Ant Design X Vue 的 BubbleList 和 Bubble。这篇文章就记录一下:

  • 为什么要做打字机效果;
  • 我在项目里是怎么配置的;
  • 打字机和 SSE 是什么关系;
  • 最后结合源码看一下 Bubble 是如何实现这个效果的。

这篇会比单纯的组件使用更深入一点,但不会为了讲源码而讲源码,还是从真实业务落地出发。

为什么 AI 对话需要打字机效果

普通接口请求里,用户点击按钮后,页面通常只需要一个 loading。

比如:

提交表单
显示 loading
请求完成
展示结果

但 AI 对话不太一样。

AI 回复往往比较长,可能是一段解释、一份方案,甚至是一整段代码。

如果等模型完整生成后再展示,用户体验会变成:

用户发送消息
页面空等
等待几秒或十几秒
突然出现一大段内容

用户很难判断:

是网络慢?
是模型卡住?
还是系统没有响应?

而打字机效果可以把体验变成:

用户发送消息
AI 气泡出现
内容开始逐步展示
用户持续感知系统正在输出

这种反馈对 AI 产品非常重要。

它不只是一个动画,而是一个状态表达。

打字机和流式输出不是一回事

这里有一个很容易混淆的点:

打字机效果不等于流式接口。

它们属于两层能力。

SSE / Stream   负责数据怎么从后端到前端
Typing         负责前端怎么把文本展示给用户

如果只有 SSE,没有 typing,前端可能会非常快地把 chunk 拼接出来,视觉上还是像整段出现。

如果只有 typing,没有 SSE,也能把完整文本慢慢显示出来,但用户要等后端完整返回后才能看到第一个字。

比较理想的方式是:

后端通过 SSE 持续返回 chunk
前端持续更新 message.content
Bubble 根据 typing 配置做打字机展示

也就是:

数据层:真实流式
展示层:打字机动画

这也是我当前项目里的实现方式。

项目里的消息展示结构

当前消息展示组件是:

src/features/workbench/components/chat/ChatMessageList.vue

它主要使用了 BubbleList:

<BubbleList
  :items="conversation.messages"
  :roles="roles"
  :auto-scroll="true"
>
</BubbleList>

其中:

参数作用
items当前会话的消息列表
roles用户和 AI 的气泡样式配置
auto-scroll内容变化时自动滚动

打字机效果本身不在 ChatMessageList 里写定时器,也没有自己操作 DOM。

它依赖消息对象里的 typing 字段。

我是如何配置 typing 的

当用户发送消息后,前端会先往当前会话里插入两条消息。

第一条是用户消息:

{
  key: `${now}-user`,
  role: 'user',
  content: payload.message,
  skillKeys: payload.skillKeys,
}

第二条是 AI 消息:

{
  key: assistantKey,
  role: 'assistant',
  content: '',
  loading: false,
  typing: { step: 2, interval: 24 },
  skillKeys: payload.skillKeys,
}

关键就是这里:

typing: { step: 2, interval: 24 }

它的含义是:

字段作用
step每次增加几个字符
interval每次增加之间的间隔时间

当前配置是:

每 24ms 展示 2 个字符

这个值没有绝对标准。

如果 interval 太大,AI 看起来会非常慢;如果太小,又会接近“整段闪出”。

我这里选择 step: 2 和 interval: 24,是一个比较折中的体验:

有打字机感
不会拖慢太多
长文本也不会让用户等太久

为什么只给新建会话的 AI 回复配置 typing

这里还有一个很重要的项目细节:

打字机效果只应该出现在“当前正在生成的这条 AI 回复”上,不应该出现在所有 AI 消息上。

在当前工作台里,消息大概有两类来源。

第一类是用户刚刚发送消息后,前端临时创建的实时回复:

{
  key: assistantKey,
  role: 'assistant',
  content: '',
  loading: false,
  typing: { step: 2, interval: 24 },
}

这类消息正在接收 SSE chunk,它应该有打字机效果。

第二类是历史对话接口加载出来的消息。

比如用户点击左侧某个历史会话时,前端会请求后端历史消息,然后把 chat_history 映射成消息列表:

{
  key: history.id,
  role: history.messageType === 'ai' ? 'assistant' : 'user',
  content: history.message,
}

这里不会配置:

typing: { step: 2, interval: 24 }

原因很简单:

历史消息已经生成完成了
用户打开历史会话时应该直接看到完整内容
不应该把过去的每条 AI 回复重新打一遍

如果把 typing 放到 roles.assistant 里,所有 AI 消息都会默认启用打字机。

这会导致:

欢迎语会打字
历史消息会打字
错误消息可能也会打字
已经完成的回复重新进入页面时还会打字

这些都不是一个真实产品里想要的效果。

所以我更倾向于把职责分开:

roles 负责角色的稳定样式
message item 负责单条消息的实时状态

适合放在 roles.assistant 的是:

{
  placement: 'start',
  variant: 'borderless',
  avatar: {...},
  classNames: { content: 'agent-message-ai' },
}

这些属于“AI 消息长什么样”。

适合放在单条消息里的才是:

{
  loading: false,
  typing: { step: 2, interval: 24 },
}

这些属于“这条消息当前是什么状态”。

这也是我在实际项目里更推荐的写法:

新创建的流式 assistant 消息配置 typing,历史消息不配置 typing。

SSE chunk 如何进入消息

AI 消息一开始是空的:

content: ''

当后端通过 SSE 返回 chunk 时,前端会不断追加到这条消息的 content。

核心逻辑是:

onChunk: (chunk) => {
  replaceMessage(target, assistantKey, item => ({
    ...item,
    loading: false,
    content: `${item.content}${chunk}`,
  }));
}

假设后端依次返回:

ä½ 
好
,
我
可
以
帮
ä½ 

那么消息状态会不断变化:

content = 'ä½ '
content = '你好'
content = '你好,'
content = '你好,我'
content = '你好,我可以'

但是用户看到的并不一定是完整的 content。

因为 Bubble 内部会根据 typing 做一层展示处理。

也就是说:

message.content 是完整数据
Bubble 实际显示的是打字机处理后的内容

这个分层很重要。

业务层只需要维护真实文本,展示节奏交给组件。

为什么 done 时不能立刻关闭 typing

这个地方我踩过一个坑。

一开始我在收到 SSE 的 done 事件后,直接把 typing 设置成了 false。

看起来很合理:

后端结束了
那前端也结束 typing

但实际效果不对。

因为后端 chunk 可能返回得非常快,done 很快就到了。

如果这时立刻关闭 typing,组件还没来得及把文字一个个打出来,最终用户看到的就是:

突然出现一整段

所以现在的处理方式是:

onDone: () => {
  finishRunning(target, '刚刚完成回复');
}

注意这里没有关闭 typing。

done 只表示:

后端流式数据传输结束

但视觉上的打字机展示,应该继续交给 Bubble 自己完成。

只有在报错或用户取消时,才需要主动关闭:

typing: false

这个细节很小,但对体验影响很明显。

不要用 loading 抢占内容区

另一个容易踩的点是 loading。

Bubble 支持 loading 状态,如果设置:

loading: true

它会优先展示 loading 内容。

在普通请求里这很合理。

但在 SSE + typing 的场景里,如果一开始就给 AI 消息设置 loading: true,用户看到的可能是:

正在生成中...

等 loading 结束后,再突然显示完整内容。

这就把打字机体验破坏了。

所以当前项目里,AI 消息创建时是:

loading: false

这样 Bubble 可以直接接管 content 和 typing。

换句话说:

流式输出场景里,loading 不应该抢占消息内容区

从源码看 typing 是怎么生效的

下面结合 Ant Design X Vue 的源码看一下它是怎么实现的。

这里主要涉及三个部分:

Bubble.vue
useTypingConfig
useTypedEffect

源码位置大致在:

node_modules/ant-design-x-vue/lib/bubble/

useTypingConfig:解析 typing 配置

typing 可以传布尔值,也可以传对象。

比如:

typing: true

或者:

typing: { step: 2, interval: 24 }

源码里会先把它解析成统一配置。

核心逻辑可以简化成:

const typingEnabled = computed(() => !!typing);

const defaultConfig = {
  step: 1,
  interval: 50,
  suffix: null,
};

const mergedConfig = computed(() => {
  return {
    ...defaultConfig,
    ...(typeof typing === 'object' ? typing : {}),
  };
});

也就是说,如果只写:

typing: true

组件会使用默认配置:

step = 1
interval = 50

如果写:

typing: { step: 2, interval: 24 }

就会覆盖默认值。

所以项目里这段配置:

typing: { step: 2, interval: 24 }

最终会被解析成:

启用 typing
每次增加 2 个字符
每 24ms 增加一次

useTypedEffect:真正实现打字机

真正做打字机效果的是 useTypedEffect。

它内部维护了一个当前展示长度。

可以简化理解成:

const currentLength = ref(1);

然后根据 currentLength 从完整内容里截取一部分:

displayContent = content.slice(0, currentLength);

当 typing 开启,并且当前展示长度还没达到完整内容长度时,它会启动一个定时器:

setTimeout(() => {
  currentLength += step;
}, interval);

于是完整内容是:

你好,我可以帮你分析这个需求

实际展示会变成:

ä½ 
你好
你好,
你好,我
你好,我可
...

这就是打字机效果的本质:

完整文本不变
展示长度一点点增加

组件不是不断拼字符串,而是不断截取已有字符串。

内容变化时如何继续打字

AI 回复不是一次性给出完整文本,而是 SSE 持续追加。

所以 content 会不断变化:

你好
你好,我
你好,我可以
你好,我可以帮你

useTypedEffect 里会监听 content 的变化。

当新内容是旧内容的延续时,它不会把打字机重置到开头,而是继续往后展示。

也就是说:

旧内容:你好,我
新内容:你好,我可以帮你

组件知道这是同一条消息在继续增长,于是从当前展示位置继续打。

这点对 SSE 场景很关键。

否则每来一个 chunk,打字机都从第一个字重新开始,那体验就崩了。

这背后有一个关键逻辑:判断新旧内容之间的关系。

源码里有一个类似这样的函数,用来找两个字符串的最长公共前缀:

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;
}

它要解决的问题是:

前面已经展示过的部分,不要重新打
后面新增进来的部分,继续按 typing 往后打

比如第一次内容是:

你好,我

这时页面可能已经展示到:

你好

下一次 SSE chunk 到达后,完整内容变成:

你好,我可以帮你

新内容并不是一条全新的消息,而是在旧内容后面继续追加。

所以合理的行为是:

已经展示过的“你好”不要消失
也不要从“你”重新开始打
继续把后面的“,我可以帮你”打出来

useTypedEffect 里会保存上一次的完整内容。

当 content 变化时,它会判断:

新内容是不是以旧内容开头

如果是,说明这是正常追加:

旧内容:你好,我
新内容:你好,我可以帮你

这种情况下不需要重置打字机,只要继续增加展示长度。

如果不是正常追加,比如:

旧内容:你好,我可以帮你
新内容:抱歉,刚才生成失败

说明内容发生了替换。

这时就不能简单从旧展示长度继续往后打,因为前面内容已经不一样了。

所以源码会找最长公共前缀:

如果新内容不是旧内容的前缀
说明内容发生了替换
需要找到公共前缀或重置展示位置

可以理解成:

旧内容:你好,我可以帮你
新内容:你好,这里需要重新生成
公共前缀:你好,

这时候打字机可以从公共前缀后面继续,而不是完全从第一个字开始。

如果公共前缀长度为 0,就从开头重新打。

这段逻辑看起来很细,但实际非常重要。

因为在真实项目里,消息内容不一定永远是简单追加。

它可能遇到:

SSE chunk 合并
内容重试
错误消息覆盖
模型输出修正
前端重新设置 content

最长公共前缀的意义就在于:

尽量保留用户已经看到的稳定部分,只对新变化的部分做打字机展示。

这个处理让它既能支持正常追加,也能支持内容被替换时的展示修正。

Bubble 如何使用 typedContent

在 Bubble 组件里,它不会直接把原始 content 渲染出来。

它会先经过 useTypedEffect:

const [typedContent, isTyping] = useTypedEffect(...);

然后真正渲染的是:

typedContent

可以理解成:

props.content      完整内容
typedContent       当前应该展示给用户看的内容

当 typing 开启时:

typedContent = content.slice(0, currentLength)

当 typing 关闭时:

typedContent = content

所以业务侧只需要持续更新 content,组件内部会负责把它变成逐字展示。

光标效果是怎么来的

打字机效果除了文字逐步出现,还通常会有一个闪烁光标。

Bubble 里也做了这个效果。

当组件处于 typing 状态时,会给气泡加上类似这样的 class:

ant-bubble-typing

然后 CSS 里通过伪元素加一个光标:

.ant-bubble-typing .ant-bubble-content:last-child::after {
  content: "|";
  animation: cursorBlink 0.8s infinite;
}

所以用户看到的效果是:

你好,我正在分析 |

这个光标不是我们业务代码里写的,而是 Bubble 自己根据 typing 状态加上的。

为什么不要自定义 message 插槽

我一开始还踩过另一个坑:用了 BubbleList 的 message 插槽手动渲染内容。

类似这样:

<template #message="{ item }">
  <span>{{ item.content }}</span>
</template>

这会导致一个问题:

你自己直接渲染 item.content
Bubble 内部的 typedContent 就被绕开了

也就是说,即使消息上配置了:

typing: { step: 2, interval: 24 }

最后页面还是直接显示完整 item.content。

所以当前项目没有使用 message 插槽。

如果要使用打字机效果,应该让 Bubble 自己渲染内容:

<BubbleList :items="conversation.messages" />

可以自定义:

header
footer
avatar
roles

但不要轻易接管 message 渲染。

除非你非常清楚如何把 content 参数接进来,而不是直接读原始 item.content。

当前项目最终链路

现在项目里的完整链路是:

用户输入消息
  ↓
前端插入 user 消息
  ↓
前端插入 assistant 空消息,并配置 typing
  ↓
建立 SSE 连接
  ↓
后端持续返回 chunk
  ↓
前端持续追加 assistant.content
  ↓
Bubble 监听 content 变化
  ↓
useTypedEffect 控制 typedContent 展示长度
  ↓
页面形成打字机效果

这条链路里,每一层的职责是清楚的:

层级责任
SSE传输流式数据
useWorkbenchChat维护消息状态
BubbleList渲染消息列表
Bubble处理单条消息展示
useTypedEffect生成打字机展示内容

这种分层比在业务代码里自己写定时器更稳定。

因为业务层不应该关心:

当前展示到第几个字
定时器什么时候清理
光标什么时候显示
内容替换时怎么处理

这些都应该交给 UI 组件内部解决。

业务层只负责:

把真实 message.content 更新好
把 typing 配置传进去

小结

这次实现打字机效果,最终其实只需要一个很小的配置:

typing: { step: 2, interval: 24 }

但真正要把它用好,需要理解它和 SSE、loading、message 插槽之间的关系。

我的实际经验是:

1. SSE 负责流式数据,不负责视觉节奏
2. typing 负责视觉展示,不替代 SSE
3. 不要在 done 时立刻关闭 typing
4. 不要用 loading 抢占流式消息内容区
5. 不要随便用 message 插槽绕开 Bubble 内部渲染

从项目角度看,最理想的状态是:

接口层只处理数据流
状态层只维护完整消息
组件层负责展示体验

这样打字机效果既能跑起来,也不会把业务代码写得很重。

后面如果继续深入,可以再单独拆一篇文章,专门分析 BubbleList 的 autoScroll 是怎么配合 BubbleContext 做自动滚动的。