打造聊天框丝滑滚动体验:AI 聊天框的翻转之道

2023-11-29 14:45:57 浏览数 (1)

逐字渲染的挑战

最近在开发AI聊天助手的时候,遇到了一个很有趣的滚动问题。我们需要开发一个类似微信聊天框的交互体验:

每当聊天框中展示新消息时,需要将聊天框滚动到底部,展示最新消息。

如果在 web 什么也不做,聊天体验可能是这样的,需要用户手动滚动到最新消息:

试想一下如何在 web 中实现微信的效果。每当聊天框中接收到新消息时,都需要调用滚动方法滚动到消息底部。

代码语言:javascript复制
element.scrollIntoView({ behavior: "smooth", block: "end");

对于普通的聊天工具来说,这样实现没有什么大问题,因为聊天框接收到每条消息的长度都是确定的。但是 AI 大模型一般都是逐字渲染的,AI 助手聊天框接受的消息体大小不是固定的,而是会随着 AI 大模型的输出不断变大。如果仍使用 scrollIntoView 来滚动到底部,就需要监听消息体的变化,每次消息更新时都要通过 JavaScript 调用一次滚动方法,会造成一些问题:

  1. 频繁的 JavaScript 滚动调用。每输出一个文字要滚动一次,听起来就会性能焦虑。
  2. AI 正在输出内容时,用户无法滚动查看历史消息。用户向上滚动查看历史消息,会被 Javascript 不断执行的 scrollIntoView 打断。需要写特殊逻辑才能避免这个情况。
  3. 通过监听数据变化频繁的执行滚动,基于浏览器单线程的设计,不可避免的会造成滚动行为的滞后,导致聊天体验不够丝滑。

自然列表:灵感来源

聊天框接收到新消息时滚动到最新位置,总感觉这应该是一个很自然的行为,不需要这么多 Javascript 代码去实现滚动行为。

于是联想到了 Excel 表格,当我们在表格中第一行插入一行,这一行后边的内容会被很自然的挤下去。并不需要做什么滚动,这一行就会出现在最顶部的位置。

想到这里惊讶的发现,聊天框实际上不就是一个倒过来的列表吗? 列表最上边新增的行会把后边的行往下挤,而聊天框最下边新增消息需要把上边的消息往上挤。那假如我们将聊天框旋转 180° 呢...?

聊天框的翻转实现

翻转聊天框

利用 CSS transform: rotate(180deg) 将整个聊天框倒转,并且把接收到最新的消息插入到消息列表的头部。发现我们的设想确实是行得通的,新增的消息很自然的把历史消息顶了上去,消息卡片内容增加也能很自然的撑开。并且在消息输出时,也可以随意滚动查看历史记录。

滚动条调整与滚动行为反转

最核心的问题已经解决了,但总觉得哪里看起来怪怪的。滚动条怎么跑到左边,并且滚动行为和鼠标滚轮的方向反了,滚轮向上滚,聊天框却向下滚。(让人想起了 MacOS 连鼠标滚轮的反人类体验)

查阅文档发现 CSS 有个 direction: rtl; 属性可以改变内容的排布的方向。这样我们就可以把滚动条放回右边了。然后在通过监听滚动事件,改变滚动方向就可以恢复鼠标滚轮的滚动行为。

代码语言:javascript复制
element.addEventListener('wheel', event => {
      event.preventDefault(); // 阻止默认滚动行为
      const { deltaY } = event; // 获取滚动方向和速度
      chatContent.current.scrollTop -= deltaY; //  反转方向
    });

消息卡片翻转恢复

可以看到目前就只剩下聊天框中的消息卡片是反的,接下来把聊天框中的消息卡片转正就大功告成了。我们在聊天框中,给每个消息卡片都添加 transform: rotate(180deg);direction: ltr; 样式,把消息重新转正。

这样就把翻转的行为全部隔离在了聊天框组件中。消息卡片组件完全感知不到自己其实已经被旋转了 180° 后又旋转了 180° 了。聊天框的父组件也完全不知道自己的子节点被转了又转。

总结

最后总结一下,我们通过两行 CSS 代码 反转滚动行为,利用浏览器的默认行为完美的实现了 AI 聊天框中的滚动体验。

代码语言:CSS复制
transform: rotate(180deg);
direction: rtl;
代码语言:javascript复制
element.addEventListener('wheel', event => {
      event.preventDefault(); // 阻止默认滚动行为
      const { deltaY } = event; // 获取滚动方向和速度
      chatContent.current.scrollTop -= deltaY; //  反转方向
    });

DEMO 仓库:https://github.com/lrwlf/message-scroll-demo


更新:

想到一个更简洁的办法可以达到相同的效果,只用把聊天框 CSS 设置为:

代码语言:css复制
display: flex;
flex-direction: column-reverse;

让列表倒序渲染,并且像原来的方法一样,在消息列表的头部插入消息,就可以实现一样的效果。不需要对聊天框和消息体再进行旋转操作,也不需要反转滚动条的行为。

以上两种方法都存在一个相同的问题,当一开始聊天消息还很少时,聊天消息也会紧贴着底部,顶部会留出一片空白。

这时只需要在聊天列表的最开始设置一个空白的占位元素,把它的 CSS 设置为:

代码语言:txt复制
flex-grow: 1;
flex-shrink: 1;

就可以实现消息少的时候自动撑开,把消息撑到顶部。消息列表开始滚动时,占位元素又会被挤压消失,不影响列表滚动效果。

(为了演示,把占位元素设置为了黑色)

更新部分代码见: https://github.com/lrwlf/message-scroll-demo

将 App.js 的 chat 组件,替换为 src/components/chat-flex

0 人点赞