逐字渲染的挑战
最近在开发AI聊天助手的时候,遇到了一个很有趣的滚动问题。我们需要开发一个类似微信聊天框的交互体验:
每当聊天框中展示新消息时,需要将聊天框滚动到底部,展示最新消息。
如果在 web 什么也不做,聊天体验可能是这样的,需要用户手动滚动到最新消息:
试想一下如何在 web 中实现微信的效果。每当聊天框中接收到新消息时,都需要调用滚动方法滚动到消息底部。
JS
element.scrollIntoView({ behavior: "smooth", block: "end");
对于普通的聊天工具来说,这样实现没有什么大问题,因为聊天框接收到每条消息的长度都是确定的。但是 AI 大模型一般都是逐字渲染的,AI 助手聊天框接受的消息体大小不是固定的,而是会随着 AI 大模型的输出不断变大。如果仍使用 scrollIntoView
来滚动到底部,就需要监听消息体的变化,每次消息更新时都要通过 JavaScript 调用一次滚动方法,会造成一些问题:
- 频繁的 JavaScript 滚动调用。每输出一个文字要滚动一次,听起来就会性能焦虑。
- AI 正在输出内容时,用户无法滚动查看历史消息。用户向上滚动查看历史消息,会被 Javascript 不断执行的
scrollIntoView
打断。需要写特殊逻辑才能避免这个情况。 - 通过监听数据变化频繁的执行滚动,基于浏览器单线程的设计,不可避免的会造成滚动行为的滞后,导致聊天体验不够丝滑。
自然列表:灵感来源
聊天框接收到新消息时滚动到最新位置,总感觉这应该是一个很自然的行为,不需要这么多 Javascript 代码去实现滚动行为。
于是联想到了 Excel 表格,当我们在表格中第一行插入一行,这一行后边的内容会被很自然的挤下去。并不需要做什么滚动,这一行就会出现在最顶部的位置。
想到这里惊讶的发现,聊天框实际上不就是一个倒过来的列表吗? 列表最上边新增的行会把后边的行往下挤,而聊天框最下边新增消息需要把上边的消息往上挤。那假如我们将聊天框旋转 180° 呢...?
聊天框的翻转实现
翻转聊天框
利用 CSS transform: rotate(180deg)
将整个聊天框倒转,并且把接收到最新的消息插入到消息列表的头部。发现我们的设想确实是行得通的,新增的消息很自然的把历史消息顶了上去,消息卡片内容增加也能很自然的撑开。并且在消息输出时,也可以随意滚动查看历史记录。
滚动条调整与滚动行为反转
最核心的问题已经解决了,但总觉得哪里看起来怪怪的。滚动条怎么跑到左边,并且滚动行为和鼠标滚轮的方向反了,滚轮向上滚,聊天框却向下滚。(让人想起了 MacOS 连鼠标滚轮的反人类体验)
查阅文档发现 CSS 有个 direction: rtl;
属性可以改变内容的排布的方向。这样我们就可以把滚动条放回右边了。然后在通过监听滚动事件,改变滚动方向就可以恢复鼠标滚轮的滚动行为。
js
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;
js
element.addEventListener('wheel', event => {
event.preventDefault(); // 阻止默认滚动行为
const { deltaY } = event; // 获取滚动方向和速度
chatContent.current.scrollTop -= deltaY; // 反转方向
});
DEMO 仓库:github.com/lrwlf/messa...
更新:
想到一个更简洁的办法可以达到相同的效果,只用把聊天框 CSS 设置为:
css
display: flex;
flex-direction: column-reverse;
让列表倒序渲染,并且像原来的方法一样,在消息列表的头部插入消息,就可以实现一样的效果。不需要对聊天框和消息体再进行旋转操作,也不需要反转滚动条的行为。
以上两种方法都存在一个相同的问题,当一开始聊天消息还很少时,聊天消息也会紧贴着底部,顶部会留出一片空白。
这时只需要在聊天列表的最开始设置一个空白的占位元素,把它的 CSS 设置为:
css
flex-grow: 1;
flex-shrink: 1;
就可以实现消息少的时候自动撑开,把消息撑到顶部。消息列表开始滚动时,占位元素又会被挤压消失,不影响列表滚动效果。
(为了演示,把占位元素设置为了黑色)
更新部分代码见: github.com/lrwlf/messa...
将 App.js 的 chat 组件,替换为 src/components/chat-flex