《前端细节控:如何完美实现聊天窗口的“智能自动滚动”?》

0 阅读3分钟

一、问题背景:流式输出的滚动痛点

刚实现流式输出,产品经理就来找麻烦了:用户正在复制 AI 回复的历史消息,新内容突然涌出,视口被强行踢到底部——操作被打断,体验极差。能不能实现一个「智能自动滚动」,别打扰用户?

二、需求分析:三种行为场景

分析下需求,分为三个行为:

  1. 默认行为:用户在底部时,新消息自动跟进。
  2. 干预行为:用户一旦上滑,查看历史,立即停止自动滚动,把控制权交给用户:
  3. 恢复行为:用户回到底部,恢复自动跟进。

三、核心原理

  • scrollTop:用户滚走了多少。
  • scrollHeight:内容总共有多长。
  • clientHeight:窗口能看见多长。

利用这三个值,就能精准判断用户是“在看最新消息”(在底部),还是“在翻看历史消息”(不在底部),从而决定是否要自动滚动。

判定公式

//浏览器里元素的总高度=滚动高度+可视区域高度+距离底部高度。
// 距离底部的剩余像素 
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;

// 是否在底部(留 5px 容错,防止亚像素渲染导致的抖动)
const isAtBottom = distanceFromBottom <= 5;

四、状态机设计:userScrolled 的妙用

底部高度既然判断出了,就需要一个标记,来判断“用户是否主动干涉过”。

逻辑:

  1. 监听元素的scroll事件。
  2. 计算isAtBottom(是否在底部):
    • 若 !isAtBottom无条件锁定 (userScrolled = true)。 (解释:包括用户滚到中间或顶部的所有情况)
    • 如果 isAtBottom:说明用户回去了 →→ 设置 userScrolled = false(解锁自动滚动)。

核心代码实现:状态机与滚动监听

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>
  );
}

五、总结

实现“智能自动滚动”其实就抓住了两个核心:

  1. 精准判断:用 scrollHeight - scrollTop - clientHeight 计算距底距离,5px 容错防抖动。
  2. 状态仲裁:用 userScrolled 做开关,用户在底部时自动跟随,用户上滑时立即锁定。