一、问题背景:流式输出的滚动痛点
刚实现流式输出,产品经理就来找麻烦了:用户正在复制 AI 回复的历史消息,新内容突然涌出,视口被强行踢到底部------操作被打断,体验极差。能不能实现一个「智能自动滚动」,别打扰用户?
二、需求分析:三种行为场景
分析下需求,分为三个行为:
- 默认行为:用户在底部时,新消息自动跟进。
- 干预行为:用户一旦上滑,查看历史,立即停止自动滚动,把控制权交给用户:
- 恢复行为:用户回到底部,恢复自动跟进。
三、核心原理
scrollTop:用户滚走了多少。scrollHeight:内容总共有多长。clientHeight:窗口能看见多长。
利用这三个值,就能精准判断用户是"在看最新消息"(在底部),还是"在翻看历史消息"(不在底部),从而决定是否要自动滚动。
判定公式:
ini
//浏览器里元素的总高度=滚动高度+可视区域高度+距离底部高度。
// 距离底部的剩余像素
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
// 是否在底部(留 5px 容错,防止亚像素渲染导致的抖动)
const isAtBottom = distanceFromBottom <= 5;
四、状态机设计:userScrolled 的妙用
底部高度既然判断出了,就需要一个标记,来判断"用户是否主动干涉过"。
逻辑:
- 监听元素的scroll事件。
- 计算isAtBottom(是否在底部):
- 若
!isAtBottom:无条件锁定 (userScrolled = true)。 (解释:包括用户滚到中间或顶部的所有情况) - 如果
isAtBottom:说明用户回去了 →→ 设置userScrolled = false(解锁自动滚动)。
- 若
核心代码实现:状态机与滚动监听
ini
import { useState, useCallback, useEffect, useRef } from 'react';
export default function ChatContainer({ messages, isStreaming }) {
const scrollContainerRef = useRef(null);
// 1. 状态定义
// userScrolled: true = 用户已干预(锁定自动滚动), false = 允许自动跟随
const [userScrolled, setUserScrolled] = useState(false);
const [isAtBottom, setIsAtBottom] = useState(true);
// 2. 滚动监听器 (状态机的核心)
const handleScroll = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
// 计算距离底部的像素 (兼容不同浏览器的亚像素渲染差异,留 5px 容错)
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
const atBottom = distanceFromBottom <= 5;
// 更新底部状态标记
setIsAtBottom(atBottom);
// --- 关键逻辑修正点 ---
// 只要不在底部 (无论 scrollTop 是 100 还是 0),都视为用户正在阅读历史,必须锁定!
if (!atBottom) {
setUserScrolled(true);
} else {
// 只有当用户主动滚回底部时,才解锁自动跟随
setUserScrolled(false);
}
}, []);
// 3. 自动滚动执行器 (响应新消息)
useEffect(() => {
// 【守卫条件】只有同时满足:正在流式输出 AND 用户未锁定 AND 容器存在
if (!isStreaming || userScrolled || !scrollContainerRef.current) {
return;
}
const container = scrollContainerRef.current;
// 【性能优化】使用 requestAnimationFrame 确保 DOM 已更新
requestAnimationFrame(() => {
// 【策略选择】流式高频更新用 'auto' 防卡顿,非流式用 'smooth' 做过渡
const behavior = isStreaming ? 'auto' : 'smooth';
container.scrollTo({
top: container.scrollHeight,
behavior: behavior
});
});
}, [messages, isStreaming, userScrolled]); // 依赖 messages 以触发更新
return (
<div
ref={scrollContainerRef}
onScroll={handleScroll}
style={{ height: '500px', overflowY: 'auto', border: '1px solid #ccc' }}
>
{messages.map((msg) => (
<div key={msg.id}>{msg.content}</div>
))}
{isStreaming && <div style={{ color: '#999', fontStyle: 'italic' }}>AI 正在思考...</div>}
</div>
);
}
五、总结
实现"智能自动滚动"其实就抓住了两个核心:
- 精准判断 :用
scrollHeight - scrollTop - clientHeight计算距底距离,5px 容错防抖动。 - 状态仲裁 :用
userScrolled做开关,用户在底部时自动跟随,用户上滑时立即锁定。