面向聊天、日志、流式输出等场景的"自动滚动到底部"通用工具。与框架无关,可在原生 JS、React、Vue 等环境使用。
核心能力
- 自动置底: 新内容追加时自动滚动到底部,默认平滑。
- 上滑暂停 / 回底恢复: 用户上滑超过阈值自动暂停,回到底部阈值内自动恢复。
- 显式控制 : 提供
start/stop/destroy/isAtBottom/scrollToBottom/notifyContentChanged/getContainer
。 - 性能优化 :
passive
监听、requestAnimationFrame
合并、短周期滚动去动画化、MutationObserver + debounce
。 - 降级路径 : 可关闭 DOM 观察器,改用
notifyContentChanged()
显式通知;容器解析失败不抛错。
快速开始
ts
import { createAutoScroller } from '@XXX/utils';
// 通过 id 绑定滚动容器
const scroller = createAutoScroller('#chat-viewport', {
behavior: 'smooth', // 或 'auto'(高频更新建议)
stopThreshold: 80, // 上滑暂停阈值(px)
resumeThreshold: 5, // 回底恢复阈值(px)
observeMutations: true, // 监听 DOM 变更自动置底
mutationDebounce: 60, // 变更防抖(ms)
autoStart: true, // 创建即启动
});
// (在未启用 MutationObserver 时)内容追加后显式通知
// scroller.notifyContentChanged();
// 强制置底(忽略暂停)
// scroller.scrollToBottom(true);
// 卸载/路由切换时释放资源
// scroller.destroy();
React 示例
tsx
import { useEffect } from 'react';
import { createAutoScroller } from '@XXX/utils';
export function ChatPanel() {
useEffect(() => {
const scroller = createAutoScroller('#chat-viewport', {
observeMutations: true,
stopThreshold: 100,
resumeThreshold: 8,
});
return () => scroller.destroy();
}, []);
return (
<div id='chat-viewport' style={{ overflow: 'auto', height: 480 }}>
{/* 消息列表 */}
</div>
);
}
进阶用法
- 高频流式输出
ts
const scroller = createAutoScroller('#log', {
behavior: 'auto', // 高频更新下更稳,避免平滑动画队列
mutationDebounce: 80, // 适度增大防抖
});
- 关闭观察器,显式通知
ts
const scroller = createAutoScroller('#chat', { observeMutations: false });
// 每次内容变更后手动调用
scroller.notifyContentChanged();
- 虚拟列表配合(务必传"滚动容器"真实节点)
ts
const viewport = document.querySelector(
'.virtual-list-viewport'
) as HTMLElement;
const scroller = createAutoScroller(viewport, { observeMutations: false });
// 数据窗口变化后手动触发
scroller.notifyContentChanged();
- 用户交互
ts
// 点击按钮回到底部
document.getElementById('to-bottom')!.onclick = () =>
scroller.scrollToBottom(true);
// 临时暂停/恢复
scroller.stop();
scroller.start();
API 说明
- createAutoScroller(target, options): AutoScrollController
- target :
'#id'
|'.class'
| 纯id
| 纯className
|HTMLElement
- options :
- behavior :
'smooth' | 'auto'
,默认'smooth'
- stopThreshold : 上滑暂停阈值(px),默认
80
- resumeThreshold : 回底恢复阈值(px),默认
5
- observeMutations : 是否监听 DOM 变更,默认
true
- mutationDebounce : DOM 变更防抖(ms),默认
60
- autoStart : 创建后自动
start()
,默认true
- behavior :
- target :
- AutoScrollController
start(): void
stop(): void
destroy(): void
isAtBottom(): boolean
scrollToBottom(force?: boolean): void
notifyContentChanged(): void
getContainer(): HTMLElement | null
性能与兼容性建议
- 高频追加/流式输出场景,将
behavior
设为 'auto' 可避免动画堆积。 - 批量插入(如 500+ 节点)依赖防抖 + rAF 合并;必要时改为显式通知。
- Safari 平滑滚动与 Chromium 细节略有差异,不影响功能。
- 旧环境缺少
MutationObserver
时,关闭观察器并调用notifyContentChanged()
。
常见问题(FAQ)
- 新内容未自动置底?
- 检查是否开启
observeMutations
;或在内容追加后手动调用notifyContentChanged()
;确认所选的是"滚动容器"本身。
- 检查是否开启
- 出现卡顿或掉帧?
- 设为
behavior: 'auto'
;适度增大mutationDebounce
;避免同帧多次scrollTo
。
- 设为
- 容器选择不准确?
- 建议直接传入
HTMLElement
,尤其是虚拟列表/复杂嵌套布局场景。
- 建议直接传入
源码(完整引用)
以下为当前仓库中的完整实现与导出入口,便于核对与复制。
1:244:/XXX/auto-scroll/index.ts
/**
* 通用视口自动置底工具(与框架无关,可在任意环境使用)
*
* 功能特性:
* 1. 传入容器(id / className / HTMLElement)即可启用自动滚动到底部
* 2. 用户上滑超过阈值后暂停置底;用户回到底部(阈值内)自动恢复置底
* 3. 支持手动启停、强制置底、外部通知内容变更、销毁监听
* 4. 性能优化:passive 监听、requestAnimationFrame 节流、MutationObserver 可选、短时间内合并多次滚动
*
* 设计说明:
* - "是否应自动置底"受两层状态控制:running(整体启停)与 autoScrollEnabled(由滚动位置驱动)
* - 用户滚动时按距离底部的阈值(stopThreshold/resumeThreshold)切换 autoScrollEnabled
* - DOM 变更时可由 MutationObserver 自动触发置底;也可通过 notifyContentChanged 手动触发
*/
export interface AutoScrollOptions {
/** 滚动行为,默认 'smooth' */
behavior?: ScrollBehavior;
/**
* 距底部超过该阈值(px)则暂停自动置底,默认 80
* 建议根据消息项平均高度/容器视高进行微调
*/
stopThreshold?: number;
/** 回到底部判定阈值(px),默认 5(允许少量误差) */
resumeThreshold?: number;
/** 是否监听 DOM 变更以自动置底,默认 true */
observeMutations?: boolean;
/** DOM 变更合并时间(ms),默认 60 */
mutationDebounce?: number;
/** 创建后是否自动 start,默认 true */
autoStart?: boolean;
}
export interface AutoScrollController {
/** 开始自动置底(绑定监听) */
start(): void;
/** 暂停自动置底(保留绑定,仅逻辑暂停) */
stop(): void;
/** 销毁(解绑所有监听与观察,释放资源) */
destroy(): void;
/** 当前是否在底部(≤ resumeThreshold) */
isAtBottom(): boolean;
/** 置底;force=true 时忽略暂停状态强制置底 */
scrollToBottom(force?: boolean): void;
/** 通知外部"内容有变更"以驱动置底(在未启用 MutationObserver 时调用) */
notifyContentChanged(): void;
/** 获取当前绑定容器 */
getContainer(): HTMLElement | null;
}
type TargetInput = string | Element;
/**
* 创建通用的自动置底控制器
* @param target 支持 '#id' / '.class' / 纯 id / 纯 className / HTMLElement
* @param opts 配置项
*/
export function createAutoScroller(
target: TargetInput,
opts: AutoScrollOptions = {}
): AutoScrollController {
const {
behavior = 'smooth',
stopThreshold = 80,
resumeThreshold = 5,
observeMutations = true,
mutationDebounce = 60,
autoStart = true,
} = opts;
let container: HTMLElement | null = resolveTarget(target);
let destroyed = false;
let running = false; // 逻辑启停
let autoScrollEnabled = true; // 由滚动位置控制的"是否允许自动置底"
let scrollTicking = false; // rAF 节流
let rafId: number | null = null;
let lastScrollAt = 0; // 上次实际置底时间,用于合并频繁调用
let mutationTimer: number | null = null;
let mutationObserver: MutationObserver | null = null;
function resolveTarget(t: TargetInput): HTMLElement | null {
if (t instanceof Element) return t as HTMLElement;
const s = String(t).trim();
if (!s) return null;
// 允许三种输入:
// 1) '#id' 或 '.class' 选择器
// 2) 纯 id
// 3) 纯 className(取第一个匹配元素)
if (s.startsWith('#') || s.startsWith('.')) {
return (document.querySelector(s) as HTMLElement) || null;
}
const byId = document.getElementById(s);
if (byId) return byId as HTMLElement;
const byClass = document.querySelector(`.${s}`);
return (byClass as HTMLElement) || null;
}
function getDistanceFromBottom(el: HTMLElement): number {
// scrollHeight - clientHeight - scrollTop
return el.scrollHeight - el.clientHeight - el.scrollTop;
}
function isAtBottomInternal(): boolean {
if (!container) return true;
return getDistanceFromBottom(container) <= resumeThreshold;
}
function doScrollToBottom(force = false): void {
if (!container) return;
if (!force && (!running || !autoScrollEnabled)) return;
// 合并高频请求:80ms 内再次置底使用 'auto',避免堆积大量平滑滚动指令
const now = Date.now();
const recent = now - lastScrollAt < 80;
const chosenBehavior: ScrollBehavior = recent ? 'auto' : behavior;
if (rafId != null) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
if (!container) return;
container.scrollTo({
top: container.scrollHeight,
behavior: chosenBehavior,
});
lastScrollAt = now;
});
}
function handleScroll(): void {
if (scrollTicking || !container) return;
scrollTicking = true;
requestAnimationFrame(() => {
if (!container) {
scrollTicking = false;
return;
}
const dist = getDistanceFromBottom(container);
// 超过停止阈值 -> 暂停自动置底
if (dist > stopThreshold) {
autoScrollEnabled = false;
} else if (dist <= resumeThreshold) {
// 回到底部 -> 恢复自动置底并立即置底一次以对齐
if (!autoScrollEnabled) {
autoScrollEnabled = true;
doScrollToBottom();
}
}
scrollTicking = false;
});
}
function bind(): void {
if (!container) return;
container.addEventListener('scroll', handleScroll, { passive: true });
if (observeMutations) {
mutationObserver = new MutationObserver(() => {
if (!running || !autoScrollEnabled) return;
if (mutationTimer != null) window.clearTimeout(mutationTimer);
mutationTimer = window.setTimeout(() => {
mutationTimer = null;
doScrollToBottom();
}, mutationDebounce);
});
try {
mutationObserver.observe(container, { childList: true, subtree: true });
} catch {
// 某些情况下容器可能不可观察,忽略
}
}
}
function unbind(): void {
if (!container) return;
container.removeEventListener('scroll', handleScroll);
if (mutationObserver) {
mutationObserver.disconnect();
mutationObserver = null;
}
if (mutationTimer != null) {
window.clearTimeout(mutationTimer);
mutationTimer = null;
}
}
function start(): void {
if (destroyed) return;
if (!container) container = resolveTarget(target);
if (!container) return;
running = true;
bind();
// 初始若在底部或内容较少,直接置底一次
if (isAtBottomInternal()) {
autoScrollEnabled = true;
doScrollToBottom(true);
} else {
autoScrollEnabled = false;
}
}
function stop(): void {
running = false;
}
function destroy(): void {
destroyed = true;
running = false;
unbind();
if (rafId != null) {
cancelAnimationFrame(rafId);
rafId = null;
}
container = null;
}
function notifyContentChanged(): void {
// 外部告知有新内容追加(在未启用 MutationObserver 时可调用)
if (!running || !autoScrollEnabled) return;
doScrollToBottom();
}
const api: AutoScrollController = {
start,
stop,
destroy,
isAtBottom: isAtBottomInternal,
scrollToBottom: (force?: boolean) => doScrollToBottom(!!force),
notifyContentChanged,
getContainer: () => container,
};
if (autoStart) start();
return api;
}
1:8:/XXX/src/utils/index.ts
// utils 统一出口,便于通过 @XXX/utils 引用
export { createAutoScroller } from './auto-scroll';
export type { AutoScrollOptions, AutoScrollController } from './auto-scroll';