这几天群里有人在聊一个前端交互问题,实录我看了好几遍:一个 AI 聊天界面里,用户正往上翻看昨天的一段长对话,看到关键结论的时候,助手的新回复到了,整个视口“刷”一下被拽到最底部,正在读的那几行内容直接被顶出屏幕。来来回回试了几次,每次都能复现。这不是个例。聊天类产品接入流式 AI 输出之后,“我明明在看上面的内容,为什么非要把我拖回去”几乎成了高频吐槽。说穿了,这就是滚动锁定做得到不到位的问题。标题里问的“如何阻止 AI 新消息把它顶下去”,背后不只是一个函数调用,而是一套关于用户注意力、滚动状态和渲染时序的完整交互设计。
1. 新消息为什么能抢走用户的视口:自动滚动与原生锚定
1.1 自动滚动逻辑才是最先要查的“元凶”
大部分聊天组件的初始版本都有一个非常朴素的实现:收到新消息之后,执行一次scrollTop = scrollHeight,或者调用scrollIntoView(),把最新一条消息送进视口。这个逻辑的初衷很善良——让用户第一时间看到新回复。但它完全没考虑一个前提:用户当前到底在不在底部。
如果你正在底部等回复,自动滚动是合理的;但如果你正在往上翻历史,这行代码就变成了一次“视口抢劫”。尤其当 AI 回复是流式输出时,新消息会频繁到达,滚动代码被反复触发,用户几乎没有任何机会停留在历史区域。
更麻烦的是,有些实现用scrollIntoView({ behavior: 'smooth' })。平滑滚动会带来一个运动过程,而不是瞬间跳转。用户明明已经在历史位置停下来了,又被迫看着视口“滑走”,这个体验比瞬间跳转还要糟糕。scrollIntoView还会把目标元素之外的滚动容器也牵扯进来,如果页面里存在多个可滚动的祖先节点,你甚至不一定能预测它最终滚的是哪个容器。
1.2 浏览器原生 scroll anchoring 会偷偷调整你的滚动位置
就算你一句自动滚动代码都没写,浏览器也可能主动帮倒忙。现代浏览器普遍实现了 scroll anchoring(滚动锚定)机制,核心目的是:当视口上方的 DOM 内容发生插入、删除或尺寸变化时,自动调整滚动位置,让用户正在看的元素尽量保持原位。
听起来很贴心,但到了聊天场景里会产生奇怪的行为。比如某条历史消息里的图片延迟加载完成,高度突然从 0 变成几百像素;或者你向上加载了更早的记录,把一批旧消息插到了列表顶部;再或者某个富文本消息里的代码块从折叠变成展开。这些变化发生在用户当前视口的上方,浏览器就会自动重算 scrollTop,试图把“锚点元素”稳住。
这时候你会看到的现象就是:用户什么都没碰,页面却微妙地动了一下。可能不是标题里描述的“顶下去”那么夸张,但同样会破坏阅读节奏。所以排查问题时,不能只盯着业务代码,还要留意浏览器这个“隐形的手”。
1.3 为什么“底部插入新消息”也会让上方内容看起来在动
从几何上讲,普通从上到下的流式布局里,新消息追加在底部并不会推动上方已经渲染的消息。用户当前视口里的文本位置应该是稳定的。那为什么还是有大量“被顶下去”的反馈?
因为大多数不是几何位移,而是程序主动改变了scrollTop。可能来自自动滚动逻辑,也可能来自某个基于scrollHeight裁剪内容的自适应组件。另外,当scrollHeight变大时,浏览器某些渲染路径会触发滚动回调,而回调里如果包含“判断是否在底部并执行滚动”的操作,就会出现连锁反应:新消息让scrollHeight变大,滚动事件被触发,状态机误判用户已经回到底部,然后真的执行了滚动到底部。
所以核心问题并不是“消息插在了哪里”,而是“你有没有在滚动事件里做出错误的自动行为”。
2. 那些看似合理、实战却翻车的防顶方案
2.1 用 scrollTop 判断“是否贴底”:阈值怎么选都有问题
最流行的方案是在滚动事件里计算scrollHeight - scrollTop - clientHeight,当结果小于某个阈值时,认为用户处于底部。然后新消息到达时,只有处于底部才执行滚动。
这个思路方向是对的,但它没有想象中那么可靠。第一,阈值不好选。设成 40px,用户可能为了看倒数第二条消息的结尾,停在了距离底部 30px 的位置,系统会把他当成“在底部”,新消息一来照样被拉走。设成 10px,又可能在用户尚未完全滚到底时提前解锁,或者因为滚动动量导致误判。
第二,流式输出会让这个计算变得不稳定。AI 消息每几百毫秒就可能增长一段,scrollHeight不断变化,即使scrollTop没变,scrollHeight - scrollTop - clientHeight的结果也在来回跳动。特别是当新消息长到一定高度后,这个差值可能从 30 突然变成 200,然后又跌回 30,状态在“贴底”和“不贴底”之间反复横跳。
第三,滚动事件是异步的,且触发频率远超你的想象。移动端惯性滚动、桌面端鼠标滚轮的细粒度滚动,都会产生高频回调。你还要花额外的功夫做节流,否则一个简单的判断也能拖累整个页面的滚动流畅度。
2.2 用 IntersectionObserver 盯底部哨兵:延迟和嵌套容器的坑
有人会在列表底部放一个高度为 1px 的哨兵元素,用 IntersectionObserver 观察它是否进入视口,以此判断用户是否在底部。这个方案比裸scrollTop优雅一些,而且不依赖滚动事件触发,性能理论上有优势。
但实战中同样有坑。IntersectionObserver 是异步回调,触发时机在帧之后。当新消息刚插入时,底部哨兵的可视状态还没被及时上报,你可能会错过一次需要滚动的机会。对于 AI 聊天的流式场景,错过了也就错过了,底部那点内容被遮挡在视口下面,用户感受很直观。
更麻烦的是嵌套滚动容器。如果消息列表是页面主滚动区域里的一个子容器,或者外层有 transform、filter 之类的属性,哨兵的交叉计算会和实际滚动行为产生偏差。跨 iframe 的场景就更不用说了,IntersectionObserver 的穿透限制会直接让哨兵失效。综合下来,这个方案适合结构非常简单的页面,复杂一点就难伺候。
2.3 每次渲染后无脑滚动到底部:覆盖用户意图的典型错误
还有一类实现更简单粗暴:监听消息数组的长度,一旦变化,就在渲染完成后的生命周期回调里执行滚动到底部。数组从 10 变成 11,就滚一次;从 11 变成 12,再滚一次。
这在“用户永远只想看最新消息”的产品里能用,比如监控日志、实时行情。但在 AI 对话产品里就是灾难,因为用户不仅想看最新消息,还想回看之前的推理过程、方案结论、代码示例。你每次新消息都滚动,等于明说“我不接受你看历史”。
轮询场景会更离谱。如果消息来自轮询接口,每 3 秒拉一次,每拉一次列表就多一条,用户手放在滚轮上想往上翻一点,结果每 3 秒被打断一次。这种体验已经不是“瑕疵”,而是“不可用”。
2.4 给这些方案做一个横向对比
| 方案 | 实现成本 | 流式输出下的稳定性 | 用户意图识别 | 复杂布局兼容性 | 综合评价 |
|---|---|---|---|---|---|
scrollTop距离判断 | 低 | 较差,容易抖动 | 一般,阈值难调 | 一般 | 可作辅助,不适合单独使用 |
| 底部哨兵 + IntersectionObserver | 中 | 一般,存在异步延迟 | 较好 | 较差,嵌套容器失效 | 适合固定结构的小型列表 |
| 渲染后无脑滚动到底部 | 极低 | 极差 | 无 | 一般 | 只适合自动刷新的日志页 |
| 状态机 + 程序滚动标志 | 中高 | 好 | 好 | 好 | 推荐,正文马上就讲 |
对比之后你会发现,真正需要的不是“更精确地判断用户是否在底部”,而是“把用户当前正在做什么”当成一个显式状态来维护。
3. 一套可落地的滚动锁定状态机:代码与取舍
3.1 核心思路:把“用户在看哪里”变成显式状态
我把滚动行为拆成两个状态:FOLLOWING和EXPLORING。
FOLLOWING是“跟随底部”状态,用户当前在底部附近,新消息到达后应该保持视口贴底。EXPLORING是“浏览历史”状态,用户已经离开底部,不管历史区域有多近,新消息到达时都不允许抢走视口。
这两个状态的切换由滚动事件驱动。关键点在于区分“用户主动滚动”和“程序化滚动”。每次程序滚动都设置一个临时标志,在滚动回调里看到这个标志就跳过状态判断,避免自己的滚动操作反过来污染状态。
3.2 React 里的实现示例
我用 React 的useRef加useLayoutEffect写了一个最小可运行版本。为什么用useLayoutEffect?因为新消息触发状态更新后,需要在浏览器完成实际绘制之前拿到最新的scrollHeight并执行滚动,避免闪一帧。
import { useRef, useLayoutEffect, useState } from 'react'; function ChatList({ messages }) { const containerRef = useRef(null); // 是否处于“跟随底部”的状态 const isFollowingRef = useRef(true); // 标记当前滚动是否由程序发起 const programmaticScrollRef = useRef(false); const isNearBottom = (el, threshold = 40) => { if (!el) return false; return el.scrollHeight - el.scrollTop - el.clientHeight < threshold; }; const scrollToBottom = () => { const el = containerRef.current; if (!el) return; programmaticScrollRef.current = true; el.scrollTop = el.scrollHeight; isFollowingRef.current = true; // 在下一帧释放标志,避免后续滚动事件覆盖状态 requestAnimationFrame(() => { programmaticScrollRef.current = false; }); }; const handleScroll = () => { const el = containerRef.current; if (!el) return; if (programmaticScrollRef.current) return; // 这里就是一个简单阈值判断,但配合滚动方向会更稳 isFollowingRef.current = isNearBottom(el); }; // 新消息到达且用户处于跟随状态时,滚动到底部 useLayoutEffect(() => { if (messages.length > 0 && isFollowingRef.current) { scrollToBottom(); } }, [messages]); return ( <div ref={containerRef} onScroll={handleScroll} style={{ height: '100%', overflowY: 'auto' }} > {messages.map((msg) => ( <div key={msg.id} className="message"> {msg.content} </div> ))} </div> ); }这个版本解决了最核心的问题:用户不在底部时,新消息不会触发滚动。programmaticScrollRef保证了程序滚动产生的回调不会误把状态从FOLLOWING改成EXPLORING。
3.3 阈值、滚动方向和“距离底部”的三个细节
实际工程里,光靠上面这个版本还不够。第一个细节是阈值。不要用 0,因为用户滚动到真正的scrollHeight末端时,客户端高度和滚动高度的差值不一定是 0,移动端底部工具栏和全屏键盘都可能吃掉一段空间。我一般建议 32px 到 48px,低于 20px 会导致判断过分敏感,高于 80px 会在用户还没真正准备看新消息时就提前激活跟底。
第二个细节是滚动方向。用户可能快速向上滚动后停在距离底部 30px 的位置,此时按照阈值判断他“在底部”,新消息一到照样把他拽下去,体验依然不好。我在实际项目中会多记录一个lastScrollDirection。只有最后一次滚动方向是向下,并且距离底部小于阈值时,才进入FOLLOWING。如果用户是向上滚动后停住的,即使停在底部附近,也暂时不跟随,给他一个缓冲时间。
第三个细节是程序滚动的标志释放时机。上面用了requestAnimationFrame,但严格来说还是不够稳。如果滚动容器在 iframe 或者有动画驱动的滚动,滚动事件可能会在多个帧里持续触发。更稳妥的做法是维护一个时间戳:lastProgrammaticScrollTime,滚动事件回调里检查“距上次程序滚动是否超过 150ms”,超过才算是用户主动滚动。这个时间窗口够长,既能挡住自己引发的事件,又不至于妨碍用户连续滚动。
4. 流式 AI 输出是滚动锁定的加强版考验
4.1 消息高度每秒都在变,位置补偿怎么做
普通聊天场景里,新消息是整条到达的,高度变化一次,问题好处理。AI 流式输出完全不同:一条消息从第一个 token 开始就在不断生长,几百毫秒变一次。如果用户在FOLLOWING状态,容器要保持贴底;如果用户在EXPLORING状态,又要保证他正在看的那段内容不被推跑。
麻烦的地方在于,即使普通布局中底部插入不会推动上方元素,浏览器渲染时依然会因为scrollHeight的增长触发各种副作用。有些浏览器会重新计算滚动锚,选择一个视口内的锚点元素,尝试保持它的位置。如果锚点选择不理想,页面就会产生肉眼可见的微小位移。
这种情况下,我采用的办法是在容器上挂一个ResizeObserver,监听scrollHeight的变化。当回调触发时,如果当前不是FOLLOWING,就把scrollTop显式恢复到变化前的值。由于布局变化和恢复滚动发生在同一帧内,用户几乎感知不到。
const previousScrollTopRef = useRef(0); useLayoutEffect(() => { const el = containerRef.current; if (!el) return; const observer = new ResizeObserver(() => { if (!isFollowingRef.current) { // 如果用户不在底部,强制保持原来的滚动位置 el.scrollTop = previousScrollTopRef.current; } else { // 如果用户在底部,则保持贴底 el.scrollTop = el.scrollHeight; } }); // 每次用户滚动时记录当前位置 const handleScroll = () => { if (!programmaticScrollRef.current) { previousScrollTopRef.current = el.scrollTop; } }; el.addEventListener('scroll', handleScroll, { passive: true }); observer.observe(el); return () => { observer.disconnect(); el.removeEventListener('scroll', handleScroll); }; }, []);这个“锚点恢复”方案的关键在于previousScrollTopRef的更新时机。它必须在浏览器布局变化之前记录,而用户滚动事件天然是实时更新的,所以我直接在滚动回调里同步更新这个值。注意ResizeObserver的回调里不能再去修改previousScrollTopRef,否则会形成事件循环。
4.2 更精准的锚点策略:锁定视口里的第一条消息
恢复scrollTop是最简单的补偿方式,但在极端场景下不够精准。因为你恢复的是“容器滚动距离”,而用户稳定的其实是“视口里那几行文字的位置”。如果变化发生位置的上下文中还有 margin 折叠、padding 变化、图片撑高这类影响,恢复scrollTop就可能导致视觉偏移。
这时候可以把锚点思路再深化一步:记录视口里第一条可见消息元素的offsetTop,以及这条消息距离视口顶部的偏移量。当列表高度变化时,把scrollTop调整到让该元素的视觉位置重新对齐。
这里给一个简化思路:滚动事件里用document.elementsFromPoint或者遍历消息节点,找到第一个offsetTop > el.scrollTop的子节点作为锚点。变化发生后,计算锚点的新位置,然后设置:
el.scrollTop = anchorEl.offsetTop - fixedOffset;fixedOffset是锚点原本离视口顶部的距离。这样做的好处是,即使布局因为图片加载、代码块展开而发生了较大变化,用户看到的那个“起始位置”始终保持稳定。代价是计算成本略高,不建议在每次滚动事件里全量遍历,应该只在ResizeObserver触发的低频回调里做。
4.3 column-reverse 反向列表:到底要不要用
聊到滚动锁定,绕不开display: flex; flex-direction: column-reverse这个反向列表技巧。它的设计思路非常聪明:让容器以底部为滚动原点,列表项的视觉顺序正常排列,但滚动条数值是反的。这样新消息 append 到容器末尾时,如果用户原本就在底部,新消息会“停在原地”,不需要任何滚动操作;如果用户上翻了,新消息在视觉上位于用户下方,也不会推动当前视口。
这个方案对于“纯展示聊天记录”的产品确实好用,代码量少,稳定性高。但换到 AI 对话这种带着输入框、富文本、虚拟滚动的场景,就有几个问题。
第一,滚动方向反转。鼠标滚轮往下滚,内容表现是向上移动还是反向移动,在不同浏览器里的处理不统一,你得多做一层兼容。第二,加载更早历史消息时,原本在列表前部的旧消息会插入到视觉顶部,此时必须调整 scrollTop,否则视口会跳。第三,iOS Safari 某些版本对column-reverse和输入框聚焦时的页面滚动有组合缺陷。我建议在可用性要求高、交互复杂的 AI 聊天界面里,还是用“正常布局 + 状态机 + 锚点补偿”来解决问题,反向列表留给日志流这类简单场景。
5. 回归测试清单与我最终的建议
5.1 发版前至少跑一遍的场景矩阵
滚动锁定这种交互,只靠开发时手动验一遍根本不够。AI 消息是流式到达的,用户手速、网络延迟、图片加载时机都不一样。我给自己准备了一张测试矩阵,每次改动后都要跑一遍。
| 测试场景 | 用户滚动位置 | 触发动作 | 期望行为 |
|---|---|---|---|
| 普通新消息 | 底部 | 插入一条完整的消息 | 视口始终贴着底部 |
| 普通新消息 | 距离底部 50px | 插入一条完整的消息 | 视口位置不跳变 |
| 流式消息增长 | 底部 | 目标消息持续输出 token | 视口保持贴底 |
| 流式消息增长 | 上翻到历史区域 | 目标消息持续输出 token | 用户正在看的内容稳定 |
| 用户在快速向上滚动 | 滚动中 | 新消息到达 | 不打断手势,不强制跳转 |
| 图片异步加载 | 历史区域 | 某条消息的图片加载完成 | 该消息位置不跳动 |
| 代码块展开/折叠 | 历史区域 | 上方消息高度变化 | 当前视口内容稳定 |
| 窗口尺寸变化 | 任意 | 无新消息,只缩放浏览器 | 不误触发跟随底部 |
| 移动端键盘弹出 | 底部 | 键盘顶起容器 | 不把阅读位置推到奇怪位置 |
这套矩阵不是让你全部自动化,至少人工回归的时候要覆盖到。每条都要有明确的“期望行为”,不然测了跟没测一样。
5.2 复现和调试的技巧
滚动问题最大的痛点是复现难。你等半天新消息才来,等到了又未必对上用户的手势。我一般直接在控制台往消息列表里批量注入假数据,模拟不同频率的流式到达。
比如在 React 项目里,可以直接在 DevTools 里执行:
// 模拟流式输出:每 500ms 追加一段内容 const timer = setInterval(() => { const el = document.querySelector('[data-chat-list]'); if (!el) return; const node = document.createElement('div'); node.textContent = '模拟新增内容 ' + Date.now(); el.appendChild(node); }, 500);跑起来之后,手动把列表滚到某个历史位置,观察是否跳动。这个方法的优点是可以稳定复现、随意控制频率和内容长度。
调试定位时,推荐两个手段。第一是 DevTools 里的“Event Listener Breakpoints”,勾选scroll一类的事件,在滚动回调里断点,看每一次触发是从哪里来的。第二是 Elements 面板里右键滚动容器,选择 Break on -> subtree modifications,能看到每次列表变化是哪个模块写进来的,方便区分“是自动滚动抢跑”还是“外部插入导致锚点重算”。
5.3 我踩过的坑和最终建议
我在这类问题上踩过不少坑。最典型的一个是只判断scrollTop === 0,完全忽略clientHeight和scrollHeight的动态变化。在带底部 inset 的移动端浏览器里,这个判断几乎从来不等于 0,底部跟随时好时坏。
第二个坑是同步执行scrollTo造成性能问题。聊天列表里消息很多时,一轮滚动到底部涉及的布局计算量不小,不应该被流式到达的每个 token 都触发一次。要合并到同一帧:一个消息批次到达,只在最后一条写入 DOM 后执行一次滚动。
第三个坑是 AI 消息中的图片和代码块。消息先以纯文本形式渲染,随后图片才加载完成,高度突然撑开。如果用户正停在历史区域,这个延迟高度变化会被很多人忽略,结果视口还是悄悄动了。解决方案就是前面讲的ResizeObserver补偿,这个钱必须花。
最后说一点个人体会。滚动锁定的核心不是算法有多精巧,而是产品要不要“尊重用户注意力”。我后来在两个项目里都用了同一个思路:新消息到达时,如果用户正在浏览历史,我不滚动他的视口,只在底部显示一条“有新消息”的浮条,点击浮条才回到最新位置。结果用户投诉反而比自动滚动更少。因为对人来说,被强行打断注意力比少看一条新消息更难受。你可以在自己的项目里试试这个优先级,大概率会发现自动滚动的必要性并没有想象中那么高。