页面滚动相关场景
· 阅读需 26 分钟
短摘要:本文档汇总常见的页面滚动场景与实现示例(虚拟滚动、滚动进度、滚动定位、滚动加载、滚动动画等),并给出兼容性与性能优化建议,便于工程中复用。
目录:
- 虚拟滚动
- 滚动进度
- 滚动定位
- 滚动加载
- 滚动动画
- 滚动穿透
- 滚动条样式
- 滚动吸附
- 下拉回弹
- 橡皮筋效果
1. 虚拟滚动
虚拟滚动(Virtual Scrolling)是一种优化长列表或大量数据渲染性能的技术。它只渲染可视区域内的元素,随着滚动动态加载和卸载不可见的内容,从而显著减少 DOM 节点数量,提高页面流畅度。
实现方式
- 计算可视区域:根据容器高度和单个元素高度,计算当前可见的元素索引范围。
- 渲染可见元素:只渲染可视区域内的数据项,其余部分不渲染或用占位元素填充。
- 占位填充:通过设置容器的
padding或margin,让滚动条长度与完整列表一致,保证滚动体验。 - 监听滚动事件:在滚动时实时计算并更新可见元素。
示例
以 React 为例,写一个简单的虚拟滚动组件,包括核心的hook和UI组件。首先是hook,用于计算可视区域和渲染元素,它有以下几个参数:
type UseVirtualListOptions = {
/** 容器元素,会监听这个元素的滚动事件,动态调整数据项 */
containerRef?: React.RefObject<HTMLElement>;
/** 一共有多少条数据 */
itemCount: number;
/** 每条数据的高度 */
itemHeight: number;
/** 额外渲染的项数,默认 3,UI 上看起来更流畅 */
overscan?: number;
};
useVirtualList 返回的参数:
type UseVirtualListReturn = {
/** 要渲染的数组项的开始索引 */
start: number;
/** 要渲染的数组项的结束索引 */
end: number;
/** 第一项渲染的元素距离顶部的偏移量 */
offset: number;
/** 渲染出的数组元素总的高度 */
totalHeight: number;
/** 渲染的总条目 */
visibleCount: number;
/** 外部 API:滚动到某个索引 */
scrollToIndex: (index: number, behavior: ScrollBehavior = "auto") => void;
};
基本原理:
- 页面总可滚动高度 = 文档完整高度 - 视口高度;
- 当前滚动百分比 = scrollY / (documentHeight - window.innerHeight);
- 将百分比(0~100)映射为顶部进度条的宽度(或水平缩放比例);
我们需要实现两个函数:
getScrollTop:获取当前滚动位置(scrollY);getClientHeight:获取当前容器的高度,对于全局的滚动,是window.innerHeight,而元素则是element.clientHeight;
代码如下:
function getScrollTop(container: Window | HTMLElement) {
if (!container) return 0;
if (container === window) return window.scrollY || window.pageYOffset || 0;
return (container as HTMLElement).scrollTop;
}
function getClientHeight(container: Window | HTMLElement) {
if (!container) return 0;
if (container === window) return window.innerHeight || 0;
return (container as HTMLElement).clientHeight;
}
接着是核心的更新函数,它根据容器高度、单个元素高度、滚动位置等计算要渲染的元素范围:
function updateRange() {
/** 获取滚动高度 */
const scrollTop = getScrollTop(container);
/** 获取容器高度 */
const clientHeight = getClientHeight(container);
/** 计算可见区域的起始和结束索引 */
const visibleStart = Math.floor(scrollTop / itemHeight);
const visibleEnd = Math.ceil((scrollTop + clientHeight) / itemHeight) - 1;
/** 根据 overscan 重新计算出起始索引和结束索引 */
const start = Math.max(0, visibleStart - overscan);
const end = Math.min(itemCount - 1, visibleEnd + overscan);
/** 计算出高度偏移量 */
const offset = start * itemHeight;
}
完整代码:
type Range = {
start: number;
end: number;
offset: number;
totalHeight: number;
};
export function useVirtualList(options: UseVirtualListOptions) {
const { containerRef, itemCount, itemHeight, overscan = 3 } = options;
const frameRef = useRef<number | null>(null);
const [range, setRange] = useState<Range>(() => ({
start: 0,
offset: 0,
totalHeight: itemCount * itemHeight,
end: Math.max(
0,
Math.min(
itemCount - 1,
Math.floor(((typeof window !== "undefined" ? window.innerHeight : 0) || 0) / itemHeight),
),
),
}));
useEffect(() => {
const container = containerRef?.current ?? window;
function updateRange() {
const scrollTop = getScrollTop(container);
const clientHeight = getClientHeight(container);
const visibleStart = Math.floor(scrollTop / itemHeight);
const visibleEnd = Math.ceil((scrollTop + clientHeight) / itemHeight) - 1;
const start = Math.max(0, visibleStart - overscan);
const end = Math.min(itemCount - 1, visibleEnd + overscan);
const offset = start * itemHeight;
setRange({ start, end, offset, totalHeight: itemCount * itemHeight });
}
function onScroll() {
if (frameRef.current != null) cancelAnimationFrame(frameRef.current);
frameRef.current = requestAnimationFrame(() => {
updateRange();
});
}
// 初始计算
updateRange();
if (container && container !== window) {
(container as HTMLElement).addEventListener("scroll", onScroll, {
passive: true,
});
window.addEventListener("resize", onScroll, { passive: true });
} else if (typeof window !== "undefined") {
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll, { passive: true });
}
return () => {
if (frameRef.current != null) cancelAnimationFrame(frameRef.current);
if (container && container !== window) {
(container as HTMLElement).removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
} else if (typeof window !== "undefined") {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
}
};
// 注意:我们希望在 itemCount 或高度变化时重新计算,因此把它们作为依赖
}, [itemCount, itemHeight, overscan]);
// 外部 API:滚动到某个索引
const scrollToIndex = useCallback(
(index: number, behavior: ScrollBehavior = "auto") => {
const clamped = Math.max(0, Math.min(itemCount - 1, Math.floor(index)));
const top = clamped * itemHeight;
const container = containerRef?.current ?? window;
if (!container) return;
if (container === window) {
window.scrollTo({ top, behavior });
} else {
(container as HTMLElement).scrollTo({ top, behavior });
}
},
[itemHeight, itemCount],
);
return {
scrollToIndex,
end: range.end,
start: range.start,
offset: range.offset,
totalHeight: range.totalHeight,
visibleCount: Math.max(0, range.end - range.start + 1),
};
}
在组件中集成:
import React, { useRef } from "react";
import useVirtualList from "../hooks/use-virtual-list";
const VirtualListExample: React.FC = () => {
const itemHeight = 50;
const containerRef = useRef<HTMLDivElement | null>(null);
const items = Array.from({ length: 1000 }, (_, i) => `Item ${i + 1}`);
const { start, end, offset, totalHeight, visibleCount, scrollToIndex } =
useVirtualList({
containerRef,
itemCount: items.length,
itemHeight,
overscan: 3,
});
const visibleItems = items.slice(start, end + 1);
return (
<div>
<div style={{ display: "flex", gap: 8, marginBottom: 8 }}>
<button onClick={() => scrollToIndex(0, "smooth")}>Top</button>
<button onClick={() => scrollToIndex(500, "smooth")}>Go 500</button>
<div style={{ marginLeft: "auto" }}>Visible: {visibleCount}</div>
</div>
<div
ref={containerRef}
style={{ height: 400, overflow: "auto", border: "1px solid #ddd" }}
>
<div style={{ height: totalHeight, position: "relative" }}>
<div style={{ position: "absolute", top: offset, left: 0, right: 0 }}>
{visibleItems.map((item, idx) => (
<div
key={start + idx}
style={{
height: itemHeight,
boxSizing: "border-box",
borderBottom: "1px solid #eee",
display: "flex",
alignItems: "center",
padding: "0 12px",
}}
>
{item}
</div>
))}
</div>
</div>
</div>
</div>
);
};
常见的虚拟滚动库有:
2. 滚动进度
滚动进度(Scroll Progress)用于展示页面或某个容器当前滚动到的位置,常见于顶部的进度条、阅读进度提示等场景。它可以提升用户体验,让用户清楚地知道自己浏览到页面的哪一部分。
应用场景
- 文章阅读进度条:在长文页面顶部显示一条横向进度条,指示当前阅读进度。
- 图片/内容滑动预览:在图片浏览、幻灯片等场景,显示滑动进度。
- 滚动触发动画/懒加载:根据滚动进度触发动画或加载内容。
实现思路
- 监听滚动事件:监听页面或容器的
scroll事件。 - 计算滚动百分比:
- 获取当前滚动距离(
scrollTop)。 - 获取可滚动的总高度(内容高度 - 容器高度)。
- 百分比 =
scrollTop / (scrollHeight - clientHeight)。
- 获取当前滚动距离(
- 更新进度条宽度或数值:将百分比映射到进度条的宽度或显示数值。
示例代码
以 React 为例,实现一个顶部滚动进度条:
import React, { useEffect, useState } from "react";
const ScrollProgressBar: React.FC = () => {
const [progress, setProgress] = useState(0);
useEffect(() => {
function updateProgress() {
const scrollTop = window.scrollY || window.pageYOffset;
const docHeight = document.documentElement.scrollHeight;
const winHeight = window.innerHeight;
const total = docHeight - winHeight;
const percent = total > 0 ? scrollTop / total : 0;
setProgress(percent);
}
window.addEventListener("scroll", updateProgress, { passive: true });
window.addEventListener("resize", updateProgress, { passive: true });
updateProgress();
return () => {
window.removeEventListener("scroll", updateProgress);
window.removeEventListener("resize", updateProgress);
};
}, []);
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
height: 3,
width: `${progress * 100}%`,
background: "#007aff",
zIndex: 9999,
transition: "width 0.2s",
}}
/>
);
};
export default ScrollProgressBar;
将 <ScrollProgressBar /> 组件放在页面顶层即可实现滚动进度条效果。
3. 滚动定位
应用场景
- 锚点导航:点击目录、侧边栏等导航项,页面自动滚动到对应内容区域。
- 返回顶部/底部:提供按钮快速滚动到页面顶部或底部。
- 高亮当前区域:根据滚动位置自动高亮导航栏对应项,提升阅读体验。
- 表单校验定位:表单校验失败时自动滚动到第一个错误项。
实现思路
- 定位目标元素:通过
id、ref或选择器获取目标 DOM 元素。 - 滚动方法选择:
- 使用原生
scrollIntoView方法,支持平滑滚动。 - 或者通过设置
scrollTop/scrollTo实现自定义滚动。
- 使用原生
- 平滑滚动:
- 现代浏览器支持
behavior: "smooth",实现平滑过渡。 - 兼容性不足时可用第三方库(如 scroll-into-view-if-needed)或手写动画。
- 现代浏览器支持
- 监听滚动同步导航:
- 监听页面滚动事件,计算当前可视区域,动态高亮导航项。
示例代码
以 React 为例,实现点击目录滚动定位:
function scrollToId(id: string) {
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
// 目录项点击事件
<button onClick={() => scrollToId("section-2")}>跳转到第二节</button>;
监听滚动高亮导航:
useEffect(() => {
function onScroll() {
// 遍历所有锚点,判断当前滚动到哪个区域
// 可结合 getBoundingClientRect/top 判断
}
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
常见库推荐:
4. 滚动加载
场景
- 无限加载列表:如社交媒体、商品列表等,用户滚动到底部自动加载更多内容。
- 图片懒加载:图片或资源在即将进入可视区域时才加载,提升首屏速度。
- 评论区/消息流:滚动到顶部或底部自动加载历史消息或更多评论。