做聊天界面绕不开一道题:新消息进来,要不要自动滚到底?
听上去是 scrollTop = scrollHeight 一行的事。真上线你就知道,这是整个对话 UI 里最阴险的一块。我把踩过的坑按顺序排一排,谁做谁知道。
坑一:用户往上翻历史,你给人强行拽回底部
最低级也最致命。用户正翻昨天的记录,后端推来一条流式消息,你二话不说 scrollToBottom,人直接被弹回最底下,火大。
正确做法是先判断「用户此刻在不在底部附近」。在,就跟着滚;不在,说明人在看历史,别动,顶多给个「↓ 有新消息」的小气泡。
function isNearBottom(el, threshold = 80) {
return el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
}
这个 threshold 别设 0。用户手指没刚好停在像素级底部很正常,留个 80px 的容差,体验天差地别。
坑二:流式回复每吐一个字,高度都在变
这是这道题真正的魔鬼。普通聊天是「一条完整消息进来,滚一次」。AI 流式是「一条消息在几秒里高度持续长大」。你得在每次内容更新后都重新粘底,但又得满足坑一的判断——所以判断必须在「内容更新之前」做。
我的顺序是:更新内容前,先记下「之前在不在底部」;DOM 更新后,如果之前在底部,才滚。顺序反了,scrollHeight 已经被新内容撑大,isNearBottom 永远算出 false,粘底直接失效。这个先后我调了俩小时才反应过来。
坑三:消息一多,DOM 堆几千条直接卡
对话攒到几百上千条,全量渲染 DOM,滚动直接掉帧。我上了虚拟列表,只渲染可视区那十几条。但虚拟列表跟「滚到底」是天生冤家——因为列表项高度不固定(代码块、图片、长短不一),总高度是估算的,你想精确 scrollToBottom 经常差那么几十像素,到不了真底。
我的妥协:虚拟列表里不用 scrollTop = scrollHeight,改成 scrollToIndex(最后一条, align: 'end'),让列表库自己算偏移。聊老实话,这块到现在偶尔还会在快速连发时抖一下,我没彻底治好,先这么用着。
坑四:图片/代码渲染完,高度又变了
气泡里有张图,图加载完高度又长一截,你之前滚的「底」又不是底了。我给流式气泡里的图挂了 onload 回调,加载完若仍处于「跟随」状态,补滚一次。细碎,但不做就是差一口气。
顺带说后端
前端这些我磨得很细,但模型和知识库那摊我没碰——用了个零代码就能拖出对话智能体、还能发布成流式接口的平台,SSE 输出现成给我,我只管接 token、做滚动。
模型层走的**讯飞** MaaS,API 直接调,没自建算力。
你们虚拟列表 + 滚到底是怎么对齐的?尤其高度估算那块,有稳的方案求分享 🙏