从 Bubble 到 BubbleList:加载历史对话时才真正看懂组件通信
前言
最近在做 zero-CODE-AGENT 的加载历史对话功能。
一开始我以为这只是一个很普通的数据回显问题:
txt
接口返回历史消息
-> 前端保存到 messages
-> 页面渲染出来
但真正开始写的时候才发现,它不是简单把一条消息放到页面上,而是要处理一整个对话列表:
- 用户消息怎么靠右展示
- AI 消息怎么靠左展示
- 历史消息怎么一次性加载
- 新消息怎么追加到最后
- AI 回复过程中怎么自动滚动
- 用户往上看历史时,不能被强行拉到底部
- 单条
Bubble内容变化时,怎么通知外层列表
也就是从这个时候开始,我发现重点已经不是单个 Bubble 了,而是:
从单条消息展示,升级到多条消息管理。
这就是 BubbleList 要解决的问题。
这篇文章主要记录我对这个过程的理解:
- 为什么
Bubble不够用 BubbleList比Bubble多做了什么- 加载历史对话时数据怎么落到
BubbleList - 为什么会出现
Bubble和BubbleList的组件通信 provide / inject在这里到底解决了什么问题- 自动滚动是怎么和单条消息更新关联起来的
从 Bubble 开始
先看最基础的 Bubble。
Bubble 负责展示一条消息。
比如:
vue
<Bubble
content="你好,我是 AI"
placement="start"
/>
它解决的是单条消息的问题:
txt
这一条消息显示什么内容?
这一条消息在左边还是右边?
这一条消息有没有头像?
这一条消息是不是 loading?
这一条消息要不要 typing?
所以 Bubble 的关注点是:
txt
单条消息展示
如果只是写一个 demo,页面里放一条 AI 回复,用 Bubble 就够了。
但真实对话不是一条消息。
比如历史对话加载出来可能是这样:
txt
用户:帮我生成一个登录页
AI:好的,我先分析一下页面结构
用户:需要暗色风格
AI:明白,我会使用深色背景和高亮按钮
这时候就不是一个 Bubble 能解决的了。
因为我们需要渲染的是:
txt
多个 Bubble
于是就需要 BubbleList。
为什么要升级到 BubbleList
如果不用 BubbleList,我们当然也可以自己写:
vue
<template>
<Bubble
v-for="item in messages"
:key="item.id"
:content="item.content"
:placement="item.role === 'user' ? 'end' : 'start'"
/>
</template>
这样能显示。
但问题是,一旦进入真实 Agent 场景,事情会越来越多:
txt
用户消息统一靠右
AI 消息统一靠左
AI 消息需要 typing
历史消息不应该重新 typing
新消息来了要滚到底部
用户看历史时不要自动滚
AI 正在输出时列表高度会不断变化
头像、footer、loading 都要按角色复用
如果这些逻辑都写在业务页面里,业务页面会越来越乱。
所以 BubbleList 本质上是把这些列表级能力收起来:
txt
Bubble 负责一条消息
BubbleList 负责一组消息
我现在的理解是:
BubbleList不是简单的v-for Bubble,它是对话列表的管理器。
BubbleList 接收什么数据
BubbleList 最核心的是 items。
大概这样使用:
vue
<Bubble.List
:items="items"
:roles="roles"
/>
items 是消息数组:
ts
const items = [
{
key: '1',
role: 'user',
content: '帮我生成一个登录页',
},
{
key: '2',
role: 'assistant',
content: '好的,我先分析一下页面结构',
},
];
每一项最后都会变成一个 Bubble。
所以可以简单理解成:
txt
items 里面有几条数据
BubbleList 就渲染几个 Bubble
这就是从单条消息升级到多条消息的第一步。
roles 解决重复配置
如果每一条 item 都写完整配置,会很重复。
比如:
ts
const items = [
{
key: '1',
role: 'user',
content: '帮我生成一个登录页',
placement: 'end',
},
{
key: '2',
role: 'assistant',
content: '好的,我先分析一下页面结构',
placement: 'start',
typing: true,
},
];
如果历史消息很多,每条都写 placement、avatar、variant,会非常啰嗦。
所以 BubbleList 提供了 roles:
ts
const roles = {
user: {
placement: 'end',
},
assistant: {
placement: 'start',
typing: true,
},
};
这样 items 只需要关心数据本身:
ts
const items = [
{
key: '1',
role: 'user',
content: '帮我生成一个登录页',
},
{
key: '2',
role: 'assistant',
content: '好的,我先分析一下页面结构',
},
];
BubbleList 内部会把:
txt
item 自己的配置
roles 里的角色配置
合并起来,最后传给 Bubble。
所以第二个升级点是:
txt
BubbleList 不只渲染消息,还会根据 role 给消息套默认配置。
这对加载历史对话特别有用。
因为历史数据通常更像这样:
ts
[
{ id: '1', role: 'user', content: '...' },
{ id: '2', role: 'assistant', content: '...' },
]
它不应该关心 UI 细节。
UI 细节应该交给 roles。
历史对话加载时的思路
在 zero-CODE-AGENT 里,加载历史对话大概会有一个会话列表。
用户点击某个历史会话时:
txt
切换 conversationId
-> 请求历史消息
-> 转成前端 messages
-> 交给 BubbleList 渲染
伪代码可以这样理解:
ts
const messages = ref([]);
async function loadHistory(conversationId: string) {
const history = await fetchHistory(conversationId);
messages.value = history.map((item) => ({
key: item.id,
role: item.role,
content: item.content,
}));
}
然后页面:
vue
<Bubble.List
:items="messages"
:roles="roles"
/>
这里有一个关键点:
txt
历史消息一般是已经完成的消息。
所以历史消息通常不应该重新 typing。
否则用户一打开历史会话,几十条消息又重新打一遍,会很奇怪。
真实业务里可以这样控制:
ts
const items = messages.value.map((item, index) => ({
key: item.id,
role: item.role,
content: item.content,
typing: item.status === 'loading' && index === messages.value.length - 1,
}));
意思是:
txt
只有正在生成中的最后一条 AI 消息才 typing。
历史消息直接显示完整内容。
所以从历史对话功能反过来看,BubbleList 要做的不只是渲染列表,还要配合业务判断:
txt
哪些消息是历史消息
哪些消息是正在生成的消息
哪些消息需要动画
哪些消息应该直接显示
为什么会涉及组件通信
一开始我没意识到这里会有组件通信。
后来看到自动滚动和 typing 才明白:
txt
Bubble 内容变化了
BubbleList 需要知道
比如 AI 正在输出:
txt
你好
你好,
你好,世
你好,世界
这几个变化发生在单条 Bubble 里面。
因为 Bubble 的 typing 逻辑在自己内部:
txt
typingIndex 增加
-> typedContent 变化
-> Bubble 内容变长
但是滚动容器在外层 BubbleList。
也就是说:
txt
内容变化发生在 Bubble
滚动操作发生在 BubbleList
这就产生了组件通信。
子组件 Bubble 要告诉父级列表:
txt
我的内容变了,你看看要不要滚到底部。
这就是 Bubble 和 BubbleList 之间的内部通信。
BubbleList 怎么把方法传给 Bubble
BubbleList.vue 里有这段:
ts
const onBubbleUpdate = useEventCallback<void>(() => {
if (autoScroll) {
setUpdateCount(unref(updateCount) + 1);
}
});
const context = computed(() => ({
onUpdate: onBubbleUpdate,
}));
这段可以翻译成:
txt
BubbleList 准备了一个方法 onBubbleUpdate。
如果 Bubble 调用它,
并且 autoScroll 开启,
就让 updateCount + 1。
updateCount 本身不是业务数据。
它更像一个信号:
txt
内容更新了,请检查一下要不要自动滚动。
然后 BubbleList 用 BubbleContextProvider 包住列表:
tsx
<BubbleContextProvider value={context.value}>
<div>
<Bubble />
</div>
</BubbleContextProvider>
这一步的意思是:
txt
把 { onUpdate: onBubbleUpdate } 提供给里面所有 Bubble。
注意这个 Provider 自己不生成真实 DOM。
它只是逻辑上包了一层,把上下文传下去。
真实 DOM 里还是原来的列表 div。
Provider 本质做了什么
BubbleContextProvider 在 src/bubble/context.ts 里:
ts
export const BubbleContextProvider = defineComponent({
props: {
value: objectType<BubbleContextProps>(),
},
setup(props, { slots }) {
useBubbleContextProvider(computed(() => props.value));
return () => {
return slots.default?.();
};
},
});
这段可以分成两部分。
第一部分:
ts
useBubbleContextProvider(computed(() => props.value));
它会调用:
ts
provide(BubbleContextKey, value);
意思是:
txt
把 props.value 放到 BubbleContextKey 这个上下文里。
第二部分:
ts
return () => {
return slots.default?.();
};
意思是:
txt
Provider 不渲染额外标签,只渲染自己包住的内容。
所以它的作用不是布局,而是传值。
可以简单理解成:
txt
BubbleContextProvider 是一个不显示 UI 的传值组件。
Bubble 怎么拿到这个方法
Bubble.vue 里通过:
ts
const { onUpdate } = unref(useBubbleContextInject());
拿到上下文里的 onUpdate。
这个 onUpdate 实际上就是 BubbleList 传下来的:
ts
onBubbleUpdate
然后 Bubble 监听自己的内容:
ts
watch(typedContent, () => {
onUpdate?.();
});
意思是:
txt
只要 Bubble 当前显示内容 typedContent 变了,
就调用 onUpdate。
于是链路变成:
txt
Bubble typing 输出一个字
-> typedContent 变化
-> Bubble 调用 onUpdate
-> 实际执行 BubbleList 的 onBubbleUpdate
-> updateCount + 1
-> BubbleList 检查是否自动滚动
这就是 Bubble 和 BubbleList 的通信过程。
为什么不用 props 直接传
这里很容易想到:
txt
为什么不直接给 Bubble 传 props?
比如:
tsx
<Bubble onUpdate={onBubbleUpdate} />
理论上当然可以。
但是这里的 onUpdate 不是用户应该关心的 API。
它只是 BubbleList 和内部 Bubble 为了自动滚动做的内部协作。
如果直接做成 Bubble 的 prop,就会变成公开能力:
txt
用户好像也可以给 Bubble 传 onUpdate
这会污染 Bubble 的公开 API。
所以这里用 provide / inject 更合适:
txt
BubbleList 在内部 provide
Bubble 在内部 inject
外部用户不需要知道这个 onUpdate
我现在的理解是:
txt
props 更适合公开参数。
provide / inject 更适合内部上下文。
比如:
txt
content、typing、loading 是 Bubble 的公开能力,所以用 props。
onUpdate 是 BubbleList 自动滚动用的内部通信,所以用 provide / inject。
自动滚动为什么需要 updateCount
BubbleList 里有一个状态:
ts
const [updateCount, setUpdateCount] = useState(0);
它的作用不是展示数据,而是触发自动滚动逻辑。
自动滚动的核心代码是:
ts
watch([updateCount, listRef, scrollReachEnd], () => {
if (autoScroll && unref(listRef) && unref(scrollReachEnd)) {
nextTick(() => {
unref(listRef).scrollTo({
top: unref(listRef).scrollHeight,
});
})
}
});
这段意思是:
txt
只要 updateCount 变化,
就检查要不要滚到底部。
如果 autoScroll 开启,
并且当前就在底部,
就滚到最新的 scrollHeight。
为什么要有 scrollReachEnd?
因为用户可能正在往上看历史。
如果用户已经离开底部,AI 每输出一个字都强行滚到底部,体验会很差。
所以逻辑是:
txt
用户在底部:
新内容来了,自动滚到底部
用户不在底部:
新内容来了,不打扰用户
scrollReachEnd 就是记录:
txt
当前是否滚到底部
滚动事件里会更新它:
ts
setScrollReachEnd(
target.scrollHeight - Math.abs(target.scrollTop) - target.clientHeight <= TOLERANCE,
);
所以完整关系是:
txt
Bubble 内容变化
-> onUpdate
-> updateCount + 1
-> autoScroll watch 触发
-> 如果 scrollReachEnd 是 true
-> scrollTo 到底部
为什么要 nextTick
自动滚动里用了:
ts
nextTick(() => {
unref(listRef).scrollTo({
top: unref(listRef).scrollHeight,
});
})
这里必须等 nextTick,是因为:
txt
数据变化了,不代表 DOM 已经更新完了。
比如 AI 新输出了一个字,或者新增了一条消息。
这时候 Vue 会先更新响应式数据,然后再批量更新真实 DOM。
如果立刻读:
ts
listRef.scrollHeight
可能读到的还是旧高度。
用了 nextTick 后,流程是:
txt
数据变化
-> 等 Vue 更新完 DOM
-> 再读取最新 scrollHeight
-> 滚到底部
这对聊天列表很重要。
因为消息变长、换行、新增消息,都会改变列表高度。
如果不等 DOM 更新完,滚动位置可能会差一截。
回到加载历史对话
现在再回头看 zero-CODE-AGENT 的加载历史对话,就比较清楚了。
它不是只要把接口数据塞进页面。
而是要把对话能力拆成几层:
txt
历史数据层:
接口返回 conversation messages
消息状态层:
本地 messages 保存 role / content / status
列表展示层:
BubbleList 根据 items 渲染多个 Bubble
角色配置层:
roles 决定 user / assistant 的默认展示
生成态交互层:
loading / typing 控制 AI 回复过程
内部通信层:
Bubble 内容变化通知 BubbleList 自动滚动
所以从 Bubble 升级到 BubbleList,本质上是从:
txt
展示一条消息
升级到:
txt
管理一个对话流
这里的"对话流"不仅包括多条消息,还包括:
txt
消息顺序
角色样式
历史加载
新消息追加
打字机更新
自动滚动
用户滚动状态
组件内部通信
我现在的理解
刚开始看 Bubble 的时候,我关注的是:
txt
一条消息怎么显示出来
但开始做历史对话后,问题变成了:
txt
一组消息怎么稳定地组成一个聊天窗口
这时 BubbleList 的价值就出来了。
它不仅是多个 Bubble 的循环渲染,还承担了列表级能力:
- 把
items转成多个Bubble - 用
roles复用角色配置 - 用
useDisplayData控制 typing 顺序展示 - 用
autoScroll维护滚动体验 - 用
provide / inject完成内部组件通信 - 用
onTypingComplete串起单条消息和列表状态
所以如果说:
txt
Bubble 是消息气泡
那:
txt
BubbleList 就是对话容器
做 Agent 历史对话时,真正要依赖的是 BubbleList。
因为历史对话不是一条消息,而是一段完整上下文。
总结
这次从加载历史对话功能回头看 BubbleList,我最大的感受是:
只有进入真实业务场景,才会知道为什么组件要这样设计。
单看 Bubble,会觉得它已经能显示消息了。
但一旦要做历史对话,就会发现我们需要的是:
txt
多条消息
角色配置
状态控制
自动滚动
内部通信
这时 BubbleList 就不是简单的列表组件,而是对话体验的基础容器。
它通过 items 管理消息,通过 roles 管理角色,通过 autoScroll 管理滚动,通过 provide / inject 让内部 Bubble 可以通知外层列表。
所以从 Bubble 到 BubbleList,不是组件数量变多了这么简单。
它代表的是:
txt
从单条消息展示,升级到完整对话流管理。
这也是后面实现 zero-CODE-AGENT 历史对话功能时,需要先想清楚的一层。