从 Bubble 到 BubbleList:加载历史对话时才真正看懂组件通信

从 Bubble 到 BubbleList:加载历史对话时才真正看懂组件通信

前言

最近在做 zero-CODE-AGENT 的加载历史对话功能。

一开始我以为这只是一个很普通的数据回显问题:

txt 复制代码
接口返回历史消息
-> 前端保存到 messages
-> 页面渲染出来

但真正开始写的时候才发现,它不是简单把一条消息放到页面上,而是要处理一整个对话列表:

  • 用户消息怎么靠右展示
  • AI 消息怎么靠左展示
  • 历史消息怎么一次性加载
  • 新消息怎么追加到最后
  • AI 回复过程中怎么自动滚动
  • 用户往上看历史时,不能被强行拉到底部
  • 单条 Bubble 内容变化时,怎么通知外层列表

也就是从这个时候开始,我发现重点已经不是单个 Bubble 了,而是:

从单条消息展示,升级到多条消息管理。

这就是 BubbleList 要解决的问题。

这篇文章主要记录我对这个过程的理解:

  • 为什么 Bubble 不够用
  • BubbleListBubble 多做了什么
  • 加载历史对话时数据怎么落到 BubbleList
  • 为什么会出现 BubbleBubbleList 的组件通信
  • 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,
  },
];

如果历史消息很多,每条都写 placementavatarvariant,会非常啰嗦。

所以 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 复制代码
我的内容变了,你看看要不要滚到底部。

这就是 BubbleBubbleList 之间的内部通信。

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 复制代码
内容更新了,请检查一下要不要自动滚动。

然后 BubbleListBubbleContextProvider 包住列表:

tsx 复制代码
<BubbleContextProvider value={context.value}>
  <div>
    <Bubble />
  </div>
</BubbleContextProvider>

这一步的意思是:

txt 复制代码
把 { onUpdate: onBubbleUpdate } 提供给里面所有 Bubble。

注意这个 Provider 自己不生成真实 DOM。

它只是逻辑上包了一层,把上下文传下去。

真实 DOM 里还是原来的列表 div

Provider 本质做了什么

BubbleContextProvidersrc/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 检查是否自动滚动

这就是 BubbleBubbleList 的通信过程。

为什么不用 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 可以通知外层列表。

所以从 BubbleBubbleList,不是组件数量变多了这么简单。

它代表的是:

txt 复制代码
从单条消息展示,升级到完整对话流管理。

这也是后面实现 zero-CODE-AGENT 历史对话功能时,需要先想清楚的一层。

相关推荐
hunterandroid1 小时前
[鸿蒙从零到一] HarmonyOS 安全加固与数据保护实战:从密钥管理到反调试的全链路防护
前端
hunterandroid1 小时前
Android WebView JSBridge 治理实战:从线上白屏崩溃到协议化通信
android·前端
意疏1 小时前
2026年远控软件安全横评:六款主流工具逐项核查——官方文档、一手实测与安全事件,全摊开
大数据·前端·数据库
梦曦i1 小时前
RouterLink H5端控制台错误修复
前端·uni-app
qq_548612451 小时前
.NET 平台报表工具汇总(.NET Framework /.NET6-8,中国式复杂报表、Web 嵌入、填报、导出打印)
前端·.net
懒狗小前端2 小时前
自嗨不如一起嗨
前端
郑州光合科技余经理2 小时前
海外版多语言团购系统架构:主数据互通与核销边界
java·开发语言·前端·后端·系统架构·php·ai编程
达令哥2 小时前
告别 ARouter!基于 Google 官方 Navigation 3 + KSP 打造 Compose 时代的双轨制路由框架
android·前端