AI 对话消息自动滚动实现方法详解(SSE 场景滚动方案)

对话窗口的自动滚动,做对的判断标准只有一条:新消息到达时滚到底,用户向上翻历史时别抢滚动条。实现上靠三件事:用 ref 指向列表底部的哨兵元素,在 DOM 渲染完成后触发滚动,再加一个「用户是否在查看历史」的开关。企业级 AI 网关的对话页面还叠加了 SSE 流式输出——模型一个字一个字往外吐,滚动必须按帧合并,否则每来一个 token 就滚一次,页面会抖得没法看。下面按我们的实现路径展开。

一、三种滚动方案的对比

先看常规做法,各自有明确的适用边界。

方案 实现成本 兼容性 适用场景
手动设置 scrollTop 低 高 兼容旧浏览器、自定义滚动容器
scrollIntoView + 哨兵元素 低 现代浏览器均支持 消息列表自动滚底
CSS scroll-behavior 最低 现代浏览器 简单列表平滑滚动

我们选的是「哨兵元素 + scrollIntoView」:在消息列表末尾放一个空节点,新消息加入后把它滚进视口,语义清晰,也不用手动计算容器高度。

1.1 哨兵元素的基础写法

const listRef = ref(null);
const endRef = ref(null);

function scrollToBottom(behavior = 'smooth') {
  nextTick(() => {
    endRef.value?.scrollIntoView({ behavior });
  });
}

注意必须在 nextTick 里执行。Vue 的 DOM 更新是异步批量的,消息数组刚 push 完就去滚动,此刻新节点还没渲染进列表,滚了个寂寞。

二、SSE 流式输出下的滚动节奏

网关对话走 SSE 流式接口,服务端把回答拆成若干片段推送,前端边收边拼。如果每次收到片段都触发滚动,高频刷新下滚动条会来回抽搐。处理节奏分四步:

  1. 维护一个消息数组,流式片段只追加到当前这条 AI 消息的内容上,不新增数组项;
  2. 用 requestAnimationFrame 合并渲染,把同一帧内的多次追加合成一次滚动;
  3. 滚动前先判断列表是否处于「贴底」状态,只有贴底才滚动;
  4. 收到完成事件后强制滚一次,确保最终内容完整露出。
let ticking = false;

function onChunk(chunk) {
  currentMessage.content += chunk;
  if (!ticking) {
    ticking = true;
    requestAnimationFrame(() => {
      if (isNearBottom()) scrollToBottom('auto');
      ticking = false;
    });
  }
}

2.1 判断是否贴底

贴底判断用「容器滚动高度 – 视口高度 – 当前滚动位置 < 阈值」这套公式,阈值给 80 到 120 像素比较合适。用 smooth 还是 auto 也要分场景:用户发送消息后自己的消息出现,用 smooth 制造顺滑感;流式 token 拼接阶段用 auto,因为高频调用下平滑动画反而会放大抖动。

三、用户上滑查看历史时停止跟随

这是自动滚动最容易做坏的地方。模型还在输出,用户想回看前面某段话,滚动条被强行拽到底,体验直接归零。解决办法是监听容器的 scroll 事件,一旦检测到用户向上滚出贴底区间,就挂起自动滚动。

let userScrollingUp = false;

container.addEventListener('scroll', () => {
  userScrollingUp = !isNearBottom();
});

function onChunk(chunk) {
  currentMessage.content += chunk;
  if (userScrollingUp) return;      // 用户在翻历史,不抢滚动条
  scheduleScroll();
}

用户主动滚回底部时,isNearBottom 重新为真,自动跟随自然恢复。这个状态只在前端维护,不污染消息数据本身。

跟随逻辑要覆盖一种边界:用户滚动触底时恰逢新消息到达,此时贴底判断刚为真,自动滚动恢复,与用户意图不冲突。另一个反向问题是用户停留在列表中间不动,模型持续输出,列表不滚动,容易让人误以为卡住。折中做法是留出呼吸区,距离底部 150 像素以内都算贴底,既保留自动跟随,又减少来回切换的顿挫感。

2.2 初始化定位与历史加载

页面刷新恢复会话时不能直接滚到底。浏览器先渲染首屏,此时容器高度是空的,立即滚动等于没滚。先等消息数组填充完毕,再统一滚动一次。历史消息分页加载时,新一页插入列表顶部,插入完成后要把滚动位置校准回原首条消息所在行,避免用户正在看的上下文被顶走。实现上给列表顶部也放一个哨兵节点,记录插入前后的偏移差并补回 scrollTop。

移动端还要处理软键盘。输入框获得焦点后视口高度变化,滚动位置跟着偏移,监听 resize 事件,在键盘弹起后重新定位到底部。

流式输出时在消息末尾放一个固定宽度的光标占位符,提示生成仍在进行。占位符宽度恒定,不会因每次闪烁触发重排,也就不会引入多余的滚动。

四、长对话的性能处理

对话超过几十轮后,消息列表会积压大量 DOM 节点,每次插入都触发整列重排,滚动卡顿随之而来。两个手段:一是只渲染可见区附近的消息,配合固定高度条目做虚拟列表;二是给图片类内容预留占位高度,避免加载完成后列表高度突变、滚动位置错乱。网关对话模块文本为主,我们在超过 200 条消息时启用虚拟滚动,其余场景直接全量渲染更省事。

4.1 滚动性能的度量

滚动卡顿是主观感受,要用数据说话。在滚动容器上记录帧间隔,统计超过 50ms 的长任务次数与最大阻塞时长,作为降级渲染的触发信号。触发后临时关闭平滑滚动、暂停流式占位符动画,等列表稳定再恢复。实测 200 条消息、近 4000 个 DOM 节点的列表,这套降级能让滚动从明显掉帧恢复到接近满帧率。

常见问题(FAQ)

Q1:滚动到底后页面还是留一截空白怎么办?

容器高度变化晚于滚动触发,改用 resize 观察器监听高度,变化后再滚一次。

Q2:自动滚动和手动滚动怎么区分?

用户滚出贴底区间即视为手动操作,自动滚动仅在贴底状态下生效。

Q3:smooth 和 auto 什么时候切换?

主动发送消息用 smooth,流式 token 高频拼接用 auto,避免动画叠加抖动。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
其AI的头像其AI普通用户

相关推荐

返回顶部