以 Bubble 为例,看 Ant Design X Vue 组件是怎么设计的
这篇文章以
Bubble为例,简单看一下它在Ant Design X Vue里是怎么设计的,以及如果我们后续要改这个组件,应该从哪些文件入手。
在 Ant Design X Vue 里,Bubble 是一个很核心的组件。
它承担的是 AI 对话场景里最基础、也最常见的能力:消息展示。
看起来它只是一个聊天气泡,但实际设计里包含了很多 AI 产品需要的细节,比如:
text
头像
消息位置
加载态
打字效果
消息头部和底部
自定义消息内容
消息列表
自动滚动
角色默认配置
语义化 class 和 style
主题 token
Bubble 解决什么问题?
在 AI Chat / Agent 产品里,消息展示不是简单渲染一段文本。
一条消息通常会有:
text
发送方
头像
消息内容
消息方向
加载状态
打字动画
Markdown / 自定义内容
操作区域
状态信息
如果是消息列表,还需要考虑:
text
多条消息的渲染
用户消息和 AI 消息的默认样式
新消息出现时自动滚动到底部
流式输出时持续滚动
用户手动滚动后暂停自动滚动
每条消息打字结束后再展示下一条
所以 Bubble 不是一个单纯的 UI 元素,而是 AI 对话界面里的基础消息单元。
相关文件在哪里?
Bubble 相关源码主要在:
text
src/bubble/
目录结构大致如下:

可以先按职责理解:
| 文件 | 作用 |
|---|---|
Bubble.vue |
单条消息气泡组件 |
BubbleList.vue |
消息列表组件,也就是 Bubble.List |
interface.ts |
类型定义,包含 props、ref、roles 等 |
index.ts |
组件导出和 install 注册 |
loading.vue |
默认 loading 效果 |
context.ts |
列表和单条气泡之间的上下文通信 |
hooks/useTypingConfig.ts |
解析 typing 配置 |
hooks/useTypedEffect.ts |
实现打字效果 |
hooks/useListData.ts |
合并消息数据和角色配置 |
hooks/useDisplayData.ts |
控制列表中消息的逐条展示 |
style/index.ts |
Bubble 主样式 |
style/content.ts |
variant 和 shape 样式 |
style/list.ts |
Bubble.List 样式 |
如果是第一次看,建议顺序是:
text
1. interface.ts
2. index.ts
3. Bubble.vue
4. BubbleList.vue
5. hooks/
6. style/
7. docs/component/bubble.md
8. docs/examples/bubble/
对外导出是怎么设计的?
入口文件是:
text
src/bubble/index.ts
这里做了几件事:
ts
import BubbleComponent from './Bubble.vue';
import BubbleList from './BubbleList.vue';
const Bubble = Object.assign(BubbleComponent, {
List: BubbleList,
});
也就是说,对外使用时,既可以用单个气泡:
vue
<Bubble content="hello" />
也可以用列表:
vue
<Bubble.List :items="messages" />
这种设计比较常见:把子组件挂到主组件上。
在 API 语义上,Bubble.List 表示它是 Bubble 体系下的列表能力,而不是另一个完全独立的组件。
同时 index.ts 里还会注册组件:
ts
Bubble.install = function(app: App) {
app.component(Bubble.name, Bubble);
app.component(BubbleList.name, BubbleList);
return app;
}
这样既支持按需引入,也支持通过插件方式注册。
类型是怎么设计的?
类型主要在:
text
src/bubble/interface.ts
核心类型有几个:
text
BubbleProps
BubbleListProps
BubbleDataType
RolesType
TypingOption
BubbleRef
BubbleListRef
BubbleProps
BubbleProps 是单条气泡的 props。
它里面比较重要的字段有:
ts
avatar?: Partial<_AvatarProps> | VNode | (() => VNode);
placement?: 'start' | 'end';
loading?: boolean;
typing?: TypingOption | boolean;
content?: ContentType;
messageRender?: (content: ContentType) => VNode | string;
loadingRender?: () => VNode;
variant?: 'filled' | 'borderless' | 'outlined' | 'shadow';
shape?: 'round' | 'corner';
header?: VNode | string | ((content, info) => VNode | string);
footer?: VNode | string | ((content, info) => VNode | string);
这几个字段基本覆盖了单条消息展示的核心能力:
text
avatar 头像
placement 消息位置
loading 加载中
typing 打字效果
content 消息内容
messageRender 自定义消息渲染
loadingRender 自定义 loading
variant 气泡样式变体
shape 气泡形状
header/footer 头部和底部区域
BubbleListProps
BubbleListProps 是消息列表的 props。
主要字段有:
ts
items?: BubbleDataType[];
autoScroll?: boolean;
roles?: RolesType;
onScroll?: (e: Event) => void;
这里有两个重点。
第一个是 items,它表示消息列表数据。
第二个是 roles,它可以给不同角色配置默认气泡属性。
比如:
ts
const roles = {
user: {
placement: 'end',
},
assistant: {
placement: 'start',
typing: true,
},
};
这样 items 里只要写:
ts
{
role: 'assistant',
content: '你好,我是 AI 助手'
}
组件就可以根据 role 自动套用默认样式。
这个设计很适合 AI 对话,因为消息通常天然分角色:
text
user
assistant
system
tool
Bubble.vue:单条气泡怎么设计?
核心文件是:
text
src/bubble/Bubble.vue
这个组件使用的是:
vue
<script setup lang="tsx">
也就是说,它不是传统 template 写法,而是 Vue + TSX 写法。
这么写的好处是:
text
更方便处理动态插槽
更方便组合 VNode
更接近 Ant Design React 版本的组件结构
复杂渲染逻辑更集中
Bubble.vue 里大致可以拆成几块:
text
props 和 slots
content 本地状态
XProvider 全局配置
组件级配置
打字效果
样式 class 合并
头像渲染
内容渲染
header/footer 渲染
最终 render
ref 暴露
props 和 slots
Bubble.vue 通过 defineProps 拿到所有配置:
ts
const {
prefixCls: customizePrefixCls,
rootClassName,
classNames = {},
styles = {},
avatar,
placement = 'start',
loading = false,
loadingRender,
typing,
content: contentProp = '',
messageRender,
variant = 'filled',
shape,
onTypingComplete,
header,
footer,
_key,
...otherHtmlProps
} = defineProps<BubbleProps<T>>();
这里能看到一些默认值:
text
placement 默认 start
loading 默认 false
content 默认空字符串
variant 默认 filled
classNames 默认空对象
styles 默认空对象
组件还定义了多个插槽:
text
avatar
header
footer
loading
message
这些插槽的设计目的,是给业务方留扩展空间。
比如:
vue
<Bubble content="hello">
<template #avatar>
<UserAvatar />
</template>
<template #message="{ content }">
<MarkdownRenderer :content="content" />
</template>
</Bubble>
所以 Bubble 的设计不是把所有展示都写死,而是提供默认 UI,同时允许业务覆盖关键区域。
prefixCls:样式类名前缀
组件里有一段:
ts
const { direction, getPrefixCls } = useXProviderContext();
const prefixCls = getPrefixCls('bubble', customizePrefixCls);
这里是为了统一 class 命名。
默认情况下,气泡组件可能会生成类似:
text
ant-bubble
ant-bubble-start
ant-bubble-content
实际前缀取决于 XProvider 的配置。
这样做的好处是:
text
统一组件库 class 前缀
支持自定义 prefixCls
支持 RTL 方向
方便主题和样式隔离
打字效果是怎么实现的?
Bubble 的打字效果主要由两个 hook 完成:
text
hooks/useTypingConfig.ts
hooks/useTypedEffect.ts
在 Bubble.vue 里是这样使用的:
ts
const [typingEnabled, typingStep, typingInterval, typingSuffix] = useTypingConfig(() => typing);
const [typedContent, isTyping] = useTypedEffect(
content,
typingEnabled,
typingStep,
typingInterval,
);
useTypingConfig
useTypingConfig 负责把 typing 参数转换成统一配置。
typing 可以是:
ts
true
也可以是:
ts
{
step: 2,
interval: 30,
suffix: '▋'
}
默认配置是:
ts
{
step: 1,
interval: 50,
suffix: null,
}
也就是说:
text
step 每次输出几个字符
interval 每次输出间隔多少毫秒
suffix 打字后缀
如果后续想改默认打字速度,就改这里。
useTypedEffect
useTypedEffect 负责真正的打字逻辑。
它会维护:
text
prevContent 上一次内容
typingIndex 当前展示到第几个字符
typedContent 当前实际展示内容
isTyping 是否正在打字
核心思路是:
text
如果 typing 没开启,直接展示完整 content
如果 typing 开启,并且 content 是字符串,就按 typingIndex 截断展示
通过 setTimeout 定时增加 typingIndex
直到 typingIndex 到达 content.length
还有一个细节:它会比较新旧内容的公共前缀。
这是为了适配流式输出场景。
比如 AI 返回内容是逐步追加的:
text
你
你好
你好,我
你好,我是
这种情况下,新内容通常是旧内容的延长,组件可以从差异位置继续打字,而不是每次都从头开始。
这对 AI streaming 输出很重要。
loading 是怎么处理的?
Bubble.vue 里有一个 contentNode:
ts
const contentNode = computed<VNode>(() => {
if (loading) {
if (slots.loading) {
return slots.loading();
}
return loadingRender ? loadingRender() : <Loading prefixCls={prefixCls} />;
} else {
return (
<>
{mergedContent.value}
{isTyping.value && toValue(typingSuffix)}
</>
);
}
});
这里的逻辑很清楚:
text
如果 loading 为 true:
优先使用 loading 插槽
其次使用 loadingRender
最后使用默认 Loading 组件
如果 loading 为 false:
展示消息内容
如果正在打字,展示 typing suffix
默认 loading 组件在:
text
src/bubble/loading.vue
如果要改默认 loading 样式,可以看:
text
src/bubble/loading.vue
src/bubble/style/index.ts
内容渲染优先级
消息内容的渲染逻辑在 mergedContent 里:
ts
const mergedContent = computed(() => {
if (slots.message) {
return slots.message({ content: typedContent.value as any });
}
return messageRender ? messageRender(typedContent.value as any) : typedContent.value
});
优先级是:
text
message 插槽
messageRender
默认 typedContent
也就是说,如果业务方传了 message 插槽,就完全接管消息内容渲染。
如果没有插槽,但传了 messageRender,就走函数渲染。
都没有,则直接渲染 content。
这个设计适合支持 Markdown、代码块、图表等复杂消息内容。
比如要支持 Markdown,可以通过:
vue
<Bubble :content="markdownText">
<template #message="{ content }">
<MarkdownRenderer :content="content" />
</template>
</Bubble>
或者使用 messageRender。
header 和 footer
Bubble 支持头部和底部区域:
text
header
footer
它们既可以是静态内容,也可以是函数:
ts
header?: VNode | string | ((content, info) => VNode | string);
footer?: VNode | string | ((content, info) => VNode | string);
也可以通过插槽传入:
vue
<Bubble content="hello">
<template #header="{ content, info }">
AI 助手
</template>
<template #footer="{ content, info }">
<span>刚刚</span>
</template>
</Bubble>
适合放这些内容:
text
消息发送人
时间
模型名称
引用来源
复制 / 重试 / 点赞按钮
状态提示
如果要给每条消息底部加操作按钮,优先考虑 footer 或 Actions 组件组合。
classNames 和 styles:语义化自定义
Bubble 支持:
ts
classNames?: Partial<Record<SemanticType, string>>;
styles?: Partial<Record<SemanticType, CSSProperties>>;
其中 SemanticType 是:
ts
type SemanticType = 'avatar' | 'content' | 'header' | 'footer';
这意味着业务方可以针对某个语义区域定制样式,而不是硬写深层 CSS 选择器。
比如:
vue
<Bubble
content="hello"
:classNames="{ content: 'my-bubble-content' }"
:styles="{ content: { background: '#f6ffed' } }"
/>
这种设计对于组件库很重要。
因为组件库需要同时满足:
text
默认样式可用
局部样式可覆盖
不要暴露太多内部 DOM 细节
Bubble.List:消息列表怎么设计?
Bubble.List 的源码在:
text
src/bubble/BubbleList.vue
它主要做几件事:
text
接收 items
合并 roles 默认配置
控制实际展示的 displayData
渲染多个 Bubble
处理自动滚动
把消息更新事件传给列表
暴露 scrollTo 方法
可以简单理解为:
text
Bubble 负责一条消息
Bubble.List 负责一组消息
roles 是怎么合并的?
Bubble.List 会调用:
text
hooks/useListData.ts
这个 hook 负责把 items 和 roles 合并。
核心逻辑类似:
ts
return {
...getRoleBubbleProps(bubbleData, i),
...bubbleData,
key: mergedKey,
};
注意这里的顺序:
text
先展开 role 默认配置
再展开当前 bubbleData
所以单条消息上的配置优先级更高。
比如:
ts
const roles = {
assistant: {
placement: 'start',
typing: true,
},
};
const items = [
{
role: 'assistant',
content: 'hello',
typing: false,
},
];
最终 typing 会是 false,因为 items 里的配置覆盖了 roles。
这符合直觉:角色给默认值,单条消息可以单独覆盖。
displayData:为什么不是直接渲染所有 items?
Bubble.List 没有直接渲染 items,而是经过:
text
hooks/useDisplayData.ts
这个 hook 会返回:
ts
const [displayData, onTypingComplete] = useDisplayData(mergedData);
它的作用是控制消息逐条展示。
简单说:
text
先展示当前应该展示的消息
如果最后一条消息打字完成
再继续展示下一条
这样做的原因是,AI 对话里可能会出现多条消息连续进入列表。
如果都同时打字,会显得很乱。
通过 displayData,可以让消息按顺序出现,体验更自然。
自动滚动是怎么做的?
Bubble.List 里有几个状态:
text
listRef 列表 DOM
bubbleRefs 每条 Bubble 的实例引用
scrollReachEnd 当前是否滚动到底部
updateCount 内容更新次数
自动滚动的大致逻辑是:
text
如果 autoScroll 开启
并且用户当前在底部
当消息内容变化时
列表滚动到底部
其中,单条 Bubble 在内容更新时会触发:
ts
onUpdate?.();
这个事件通过 context.ts 传给 Bubble.List。
列表收到后会更新 updateCount,再触发滚动逻辑。
这里的设计重点是:流式输出时,内容不是新增消息,而是同一条消息持续变长。
如果只监听 items.length,流式输出时不会自动滚动。
所以它需要让 Bubble 在 typedContent 变化时通知列表:
text
我这条消息内容更新了,你可以尝试滚动一下
这就是 context.ts 的作用。
scrollTo 方法
Bubble.List 通过 defineExpose 暴露了:
ts
scrollTo({
key,
offset,
behavior,
block,
})
支持两种滚动方式:
text
按 offset 滚动
按消息 key 滚动
比如可以滚动到某条消息:
ts
bubbleListRef.value?.scrollTo({
key: 'message_1',
behavior: 'smooth',
});
如果后续要做"跳转到引用消息""定位错误消息""定位工具调用结果",这个能力就很有用。
样式是怎么设计的?
Bubble 的样式在:
text
src/bubble/style/
主要文件有:
text
style/index.ts
style/content.ts
style/list.ts
style/index.ts
style/index.ts 负责基础样式,比如:
text
整体 flex 布局
start / end 方向
RTL
头像布局
header / footer
content wrapper
loading 动画
打字光标动画
这里定义了两个关键动画:
text
loadingMove
cursorBlink
loadingMove 用于 loading 点点动画。
cursorBlink 用于打字时的光标闪烁。
如果要改默认光标样式,可以看这里:
ts
[`&${componentCls}-typing ${componentCls}-content:last-child::after`]
style/content.ts
content.ts 负责内容样式变体。
主要有两类:
text
variant
shape
variant 包括:
text
filled
borderless
outlined
shadow
shape 包括:
text
round
corner
所以如果要新增一个气泡样式,比如:
text
variant = 'soft'
就需要改:
text
src/bubble/interface.ts
src/bubble/style/content.ts
docs/component/bubble.md
docs/examples/bubble/
style/list.ts
list.ts 负责列表样式,比如:
text
flex column
gap
overflowY
滚动条样式
如果要改消息间距、滚动条样式、列表布局,主要看这里。
如果我们要改 Bubble,应该怎么改?
下面按常见需求来讲。
场景一:新增一个 prop
比如要给 Bubble 新增一个 status,表示消息状态:
text
sending
success
error
建议修改顺序:
text
1. src/bubble/interface.ts
2. src/bubble/Bubble.vue
3. src/bubble/style/index.ts 或 content.ts
4. docs/component/bubble.md
5. docs/examples/bubble/ 新增示例
6. play/src/App.vue 本地验证
第一步,在 BubbleProps 中新增类型:
ts
status?: 'sending' | 'success' | 'error';
第二步,在 Bubble.vue 的 defineProps 里取出:
ts
status,
第三步,根据 status 加 class:
ts
{
[`${prefixCls}-status-${status}`]: status,
}
第四步,在样式文件里写对应样式。
第五步,更新文档 API 表格和示例。
这样改比较完整,不容易出现"组件能用但文档没有"的问题。
场景二:新增一种 variant
比如要新增:
text
variant = "soft"
需要改类型:
text
src/bubble/interface.ts
把:
ts
variant?: 'filled' | 'borderless' | 'outlined' | 'shadow';
改成:
ts
variant?: 'filled' | 'borderless' | 'outlined' | 'shadow' | 'soft';
然后改样式:
text
src/bubble/style/content.ts
新增:
ts
'&-soft': {
backgroundColor: token.colorFillSecondary,
border: `1px solid ${token.colorBorderSecondary}`,
},
最后更新:
text
docs/component/bubble.md
docs/examples/bubble/variant.vue
如果要本地快速验证,可以先改:
text
play/src/App.vue
写一个:
vue
<Bubble content="soft bubble" variant="soft" />
场景三:修改打字效果
打字效果主要看:
text
src/bubble/hooks/useTypingConfig.ts
src/bubble/hooks/useTypedEffect.ts
src/bubble/style/index.ts
如果只是改默认速度,改:
text
useTypingConfig.ts
比如:
ts
const baseConfig = {
step: 1,
interval: 50,
suffix: null,
};
如果要改"如何逐字输出",改:
text
useTypedEffect.ts
如果要改打字光标样式,改:
text
style/index.ts
比如当前光标是通过 CSS 伪元素实现的:
text
content: "|"
animation: cursorBlink
如果想换成别的样式,可以在这里改。
场景四:修改 loading 效果
默认 loading 组件在:
text
src/bubble/loading.vue
loading 动画样式在:
text
src/bubble/style/index.ts
如果业务方只是临时自定义,不需要改源码,可以直接用:
vue
<Bubble loading>
<template #loading>
<MyLoading />
</template>
</Bubble>
或者:
vue
<Bubble :loadingRender="renderLoading" />
如果要改组件库默认 loading,再改 loading.vue 和样式文件。
场景五:修改自动滚动逻辑
自动滚动主要在:
text
src/bubble/BubbleList.vue
相关逻辑包括:
text
scrollReachEnd
updateCount
onInternalScroll
watch updateCount/listRef/scrollReachEnd
watch displayData.length
onBubbleUpdate
如果要改"什么时候滚到底部",主要看这些 watcher。
如果要改"滚动到底部的行为",看:
ts
unref(listRef).scrollTo({
top: unref(listRef).scrollHeight,
});
比如想改成平滑滚动,可以加:
ts
behavior: 'smooth'
但这里要谨慎。
流式输出时内容会频繁变化,如果每次都 smooth,可能导致滚动表现拖沓。所以这类修改最好在真实流式输出场景里测试。
场景六:修改消息列表的数据合并逻辑
如果要改 roles 和 items 的合并规则,看:
text
src/bubble/hooks/useListData.ts
当前顺序是:
ts
{
...roleDefaultProps,
...bubbleData,
}
也就是说:
text
role 提供默认值
单条消息可以覆盖 role
这个顺序一般是合理的。
如果要改变优先级,需要非常谨慎,因为这会影响用户对 API 的直觉。
场景七:支持新的消息内容类型
当前 BubbleContentType 是:
ts
VNode | string | AnyObject | number
如果要支持更复杂的结构化消息,比如:
ts
type MessageContent = {
type: 'text' | 'image' | 'tool';
value: unknown;
}
可以有两种方式。
第一种,不改组件源码,业务层通过 messageRender 或 message 插槽处理。
这是更推荐的方式。
vue
<Bubble :content="message">
<template #message="{ content }">
<MessageRenderer :content="content" />
</template>
</Bubble>
第二种,改 BubbleContentType 和默认渲染逻辑。
这种改动影响面更大,只有当组件库想原生支持某类消息时才建议这么做。
场景八:新增文档示例
组件改完之后,最好补文档。
Bubble 文档在:
text
docs/component/bubble.md
示例在:
text
docs/examples/bubble/
docs/examples-setup/bubble/
如果新增一个示例,比如:
text
docs/examples/bubble/status.vue
还需要在 docs/component/bubble.md 里加:
md
### 消息状态
:::demo 展示不同消息状态。
bubble/status
:::
这样文档站才能展示。
本地怎么验证?
最简单的方式是改:
text
play/src/App.vue
然后启动:
bash
pnpm play
如果要看文档效果,启动:
bash
pnpm docs:dev
如果要做构建验证,可以跑:
bash
pnpm --dir play run build
或者完整构建:
bash
pnpm build
如果只是改文档,则可以跑:
bash
pnpm docs:build
一个推荐的修改流程
如果以后真的要改 Bubble,建议按这个流程来:
text
1. 先明确需求属于 API、样式、行为、文档还是示例
2. 先看 interface.ts,确认类型怎么扩展
3. 再看 Bubble.vue 或 BubbleList.vue,确认实现位置
4. 如果涉及打字、列表、滚动,再看 hooks
5. 如果涉及视觉,再看 style/
6. 在 play/src/App.vue 写最小 demo
7. 跑 pnpm --dir play run build
8. 更新 docs/component/bubble.md
9. 更新 docs/examples/bubble/
这个流程可以避免只改实现、不改类型,或者只改组件、不改文档的问题。
小结
Bubble 是 Ant Design X Vue 里非常典型的组件。
它的设计思路可以概括成:
text
Bubble.vue 负责单条消息渲染
BubbleList.vue 负责消息列表、自动滚动和顺序展示
interface.ts 负责 API 类型
hooks/ 负责复杂行为拆分
style/ 负责样式和主题 token
docs/examples 负责用户使用示例
这种拆分方式比较适合组件库开发。
因为组件库不仅要实现功能,还要考虑:
text
API 是否清晰
类型是否完整
默认样式是否可用
业务是否能自定义
文档是否跟得上
示例是否能覆盖常见场景