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

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

前言

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

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

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

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

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

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

text 复制代码
等待几秒
突然出现一大段回复

这个体验并不好。

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

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

  • 为什么要做打字机效果;
  • 我在项目里是怎么配置的;
  • 打字机和 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: 2interval: 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 可以直接接管 contenttyping

换句话说:

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 插槽

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

类似这样:

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 复制代码
接口层只处理数据流
状态层只维护完整消息
组件层负责展示体验

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

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

相关推荐
mldong5 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排5 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60616 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角7 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!7 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰7 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi9 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen9 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马10 小时前
AI源码分析:拆解模型底层实现逻辑
前端
IT_陈寒10 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端