跳到主要内容

页面滚动相关场景

· 阅读需 26 分钟

短摘要:本文档汇总常见的页面滚动场景与实现示例(虚拟滚动、滚动进度、滚动定位、滚动加载、滚动动画等),并给出兼容性与性能优化建议,便于工程中复用。

目录:

  • 虚拟滚动
  • 滚动进度
  • 滚动定位
  • 滚动加载
  • 滚动动画
  • 滚动穿透
  • 滚动条样式
  • 滚动吸附
  • 下拉回弹
  • 橡皮筋效果

1. 虚拟滚动

虚拟滚动(Virtual Scrolling)是一种优化长列表或大量数据渲染性能的技术。它只渲染可视区域内的元素,随着滚动动态加载和卸载不可见的内容,从而显著减少 DOM 节点数量,提高页面流畅度。

实现方式

  1. 计算可视区域:根据容器高度和单个元素高度,计算当前可见的元素索引范围。
  2. 渲染可见元素:只渲染可视区域内的数据项,其余部分不渲染或用占位元素填充。
  3. 占位填充:通过设置容器的 paddingmargin,让滚动条长度与完整列表一致,保证滚动体验。
  4. 监听滚动事件:在滚动时实时计算并更新可见元素。

示例

以 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)用于展示页面或某个容器当前滚动到的位置,常见于顶部的进度条、阅读进度提示等场景。它可以提升用户体验,让用户清楚地知道自己浏览到页面的哪一部分。

应用场景

  • 文章阅读进度条:在长文页面顶部显示一条横向进度条,指示当前阅读进度。
  • 图片/内容滑动预览:在图片浏览、幻灯片等场景,显示滑动进度。
  • 滚动触发动画/懒加载:根据滚动进度触发动画或加载内容。

实现思路

  1. 监听滚动事件:监听页面或容器的 scroll 事件。
  2. 计算滚动百分比
    • 获取当前滚动距离(scrollTop)。
    • 获取可滚动的总高度(内容高度 - 容器高度)。
    • 百分比 = scrollTop / (scrollHeight - clientHeight)
  3. 更新进度条宽度或数值:将百分比映射到进度条的宽度或显示数值。

示例代码

以 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. 滚动定位

应用场景

  • 锚点导航:点击目录、侧边栏等导航项,页面自动滚动到对应内容区域。
  • 返回顶部/底部:提供按钮快速滚动到页面顶部或底部。
  • 高亮当前区域:根据滚动位置自动高亮导航栏对应项,提升阅读体验。
  • 表单校验定位:表单校验失败时自动滚动到第一个错误项。

实现思路

  1. 定位目标元素:通过 idref 或选择器获取目标 DOM 元素。
  2. 滚动方法选择
    • 使用原生 scrollIntoView 方法,支持平滑滚动。
    • 或者通过设置 scrollTop/scrollTo 实现自定义滚动。
  3. 平滑滚动
    • 现代浏览器支持 behavior: "smooth",实现平滑过渡。
    • 兼容性不足时可用第三方库(如 scroll-into-view-if-needed)或手写动画。
  4. 监听滚动同步导航
    • 监听页面滚动事件,计算当前可视区域,动态高亮导航项。

示例代码

以 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. 滚动加载

场景

  • 无限加载列表:如社交媒体、商品列表等,用户滚动到底部自动加载更多内容。
  • 图片懒加载:图片或资源在即将进入可视区域时才加载,提升首屏速度。
  • 评论区/消息流:滚动到顶部或底部自动加载历史消息或更多评论。

实现思路

  1. 监听滚动事件:监听页面或容器的 scroll 事件。
  2. 判断触发条件
    • 判断滚动到距离底部一定距离时(如 100px 内),触发加载。
    • 或者使用 Intersection Observer 监听“加载更多”占位元素是否进入可视区。
  3. 加载数据:触发异步请求,加载新数据并追加到列表。
  4. 防抖/节流:避免频繁触发加载,通常加防抖或节流处理。
  5. 加载状态管理:处理加载中、无更多数据、错误等状态。

示例代码

以 React 为例,使用 Intersection Observer 实现滚动加载:

import React, { useEffect, useRef, useState } from "react";

const InfiniteList: React.FC = () => {
const [items, setItems] = useState<string[]>(() =>
Array.from({ length: 20 }, (_, i) => `Item ${i + 1}`),
);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const loaderRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
if (!hasMore) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !loading) {
setLoading(true);
setTimeout(() => {
setItems((prev) => [
...prev,
...Array.from(
{ length: 20 },
(_, i) => `Item ${prev.length + i + 1}`,
),
]);
setLoading(false);
if (items.length >= 100) setHasMore(false);
}, 1000);
}
},
{ rootMargin: "100px" },
);
if (loaderRef.current) observer.observe(loaderRef.current);
return () => observer.disconnect();
}, [loading, hasMore, items.length]);

return (
<div style={{ height: 400, overflow: "auto", border: "1px solid #ddd" }}>
{items.map((item) => (
<div key={item} style={{ padding: 12, borderBottom: "1px solid #eee" }}>
{item}
</div>
))}
{hasMore && (
<div ref={loaderRef} style={{ padding: 16, textAlign: "center" }}>
{loading ? "加载中..." : "加载更多"}
</div>
)}
{!hasMore && (
<div style={{ padding: 16, textAlign: "center" }}>没有更多了</div>
)}
</div>
);
};

自定义 Hook:滚动到底部触发回调

下面实现一个 useScrollToBottom hook,当页面或指定容器滚动到底部时会触发传入的回调函数:

import { useEffect } from "react";

type UseScrollToBottomOptions = {
/** 监听的容器,默认 window */
containerRef?: React.RefObject<HTMLElement | null>;
/** 距离底部多少像素内触发,默认 0 */
threshold?: number;
};

/**
* 当滚动到底部时触发回调
*/
function useScrollToBottom(
onBottom: () => void,
options: UseScrollToBottomOptions = {},
) {
const { containerRef, threshold = 0 } = options;

useEffect(() => {
const container = containerRef?.current ?? window;

function handleScroll() {
let scrollTop: number, scrollHeight: number, clientHeight: number;
if (container === window) {
scrollTop = window.scrollY || window.pageYOffset;
scrollHeight = document.documentElement.scrollHeight;
clientHeight = window.innerHeight;
} else if (container) {
scrollTop = (container as HTMLElement).scrollTop;
scrollHeight = (container as HTMLElement).scrollHeight;
clientHeight = (container as HTMLElement).clientHeight;
} else {
return;
}
if (scrollTop + clientHeight >= scrollHeight - threshold) {
onBottom();
}
}

container.addEventListener("scroll", handleScroll, { passive: true });
return () => {
container.removeEventListener("scroll", handleScroll);
};
}, [onBottom, threshold]);
}

export default useScrollToBottom;

使用示例

import React, { useRef, useState } from "react";
import useScrollToBottom from "../hooks/use-scroll-to-bottom";

const ScrollToBottomDemo: React.FC = () => {
const [count, setCount] = useState(0);
const containerRef = useRef<HTMLDivElement | null>(null);

useScrollToBottom(() => setCount((c) => c + 1), {
containerRef,
threshold: 20,
});

return (
<div>
<div>到底部触发次数:{count}</div>
<div
ref={containerRef}
style={{
height: 300,
overflow: "auto",
border: "1px solid #ddd",
marginTop: 8,
}}
>
{Array.from({ length: 40 }, (_, i) => (
<div key={i} style={{ padding: 12, borderBottom: "1px solid #eee" }}>
Item {i + 1}
</div>
))}
</div>
</div>
);
};

自动滚动到底部示例

以 React 为例,实现 AI 聊天场景中的自动滚动逻辑:

import React, { useEffect, useRef, useState } from "react";

const AIChatScroll: React.FC = () => {
const [messages, setMessages] = useState<string[]>(["Hello"]);
const [isAutoScroll, setIsAutoScroll] = useState(true);
const containerRef = useRef<HTMLDivElement | null>(null);

// 模拟 AI 输出消息
useEffect(() => {
const timer = setInterval(() => {
setMessages((prev) => [...prev, `Message ${prev.length + 1}`]);
}, 500);
return () => clearInterval(timer);
}, []);

// 监听滚动事件,判断用户是否手动滚动
useEffect(() => {
const container = containerRef.current;
if (!container) return;

function handleScroll() {
const { scrollTop, scrollHeight, clientHeight } = container;
// 当滚动到底部(距离底部 10px 内)时,启用自动滚动
const isAtBottom = scrollHeight - scrollTop - clientHeight < 10;
setIsAutoScroll(isAtBottom);
}

container.addEventListener("scroll", handleScroll, { passive: true });
return () => container.removeEventListener("scroll", handleScroll);
}, []);

// 当有新消息且启用自动滚动时,滚动到底部
useEffect(() => {
if (isAutoScroll && containerRef.current) {
const container = containerRef.current;
// 使用 requestAnimationFrame 确保 DOM 更新后再滚动
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight;
});
}
}, [messages, isAutoScroll]);

return (
<div>
<div
ref={containerRef}
style={{
height: 300,
overflow: "auto",
border: "1px solid #ddd",
padding: 12,
marginBottom: 8,
}}
>
{messages.map((msg, idx) => (
<div
key={idx}
style={{ marginBottom: 8, padding: 8, background: "#f0f0f0" }}
>
{msg}
</div>
))}
</div>
<div style={{ fontSize: 12, color: "#666" }}>
自动滚动: {isAutoScroll ? "启用" : "禁用"}
</div>
</div>
);
};

export default AIChatScroll;

核心逻辑:

  1. 监听容器的 scroll 事件,通过 scrollHeight - scrollTop - clientHeight 判断是否滚动到底部。
  2. 当用户手动滚动时,动态更新 isAutoScroll 状态。
  3. 依赖 messagesisAutoScroll,当有新消息且启用自动滚动时,使用 requestAnimationFrame 确保 DOM 更新后再滚动。

常见库推荐:

5. 滚动动画

应用场景

  • 视差滚动效果:背景图片以不同的速度滚动,创建深度感。
  • 元素淡入淡出:根据滚动位置,让元素逐步显示或隐藏。
  • 滚动触发动画:当元素进入可视区域时触发动画(如放大、平移、旋转等)。
  • 导航栏隐藏/显示:向下滚动时隐藏导航栏,向上滚动时显示。
  • 平滑滚动到顶部/底部:点击按钮后平滑滚动回页面顶部。

实现思路

  1. 监听滚动事件:获取实时滚动距离 scrollY
  2. 计算动画进度:将滚动位置映射为 0~1 的动画进度值。
  3. 应用变换:使用 CSS 的 transformopacity 等属性或直接更新样式。
  4. 性能优化:使用 requestAnimationFrameIntersection Observer 避免频繁重绘。
  5. 平滑滚动:使用 scrollTo({ behavior: "smooth" }) 实现平滑过渡。

示例代码

视差滚动效果:

import React, { useEffect, useState } from "react";

const ParallaxScroll: React.FC = () => {
const [scrollY, setScrollY] = useState(0);

useEffect(() => {
function handleScroll() {
setScrollY(window.scrollY);
}
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);

return (
<div>
<div
style={{
height: 400,
background: "url(/bg.jpg) no-repeat center",
backgroundSize: "cover",
transform: `translateY(${scrollY * 0.5}px)`,
transition: "transform 0.1s",
}}
/>
<div style={{ height: 800, padding: 20 }}>
<p>滚动内容...</p>
</div>
</div>
);
};

export default ParallaxScroll;

滚动触发元素淡入:

import React, { useEffect, useRef, useState } from "react";

const ScrollFadeIn: React.FC = () => {
const elementRef = useRef<HTMLDivElement | null>(null);
const [isVisible, setIsVisible] = useState(false);

useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
}
},
{ threshold: 0.1 },
);

if (elementRef.current) {
observer.observe(elementRef.current);
}

return () => observer.disconnect();
}, []);

return (
<div
ref={elementRef}
style={{
opacity: isVisible ? 1 : 0,
transform: isVisible ? "translateY(0)" : "translateY(20px)",
transition: "all 0.6s ease-out",
padding: 40,
background: "#f0f0f0",
}}
>
当滚动到此处时淡入显示
</div>
);
};

export default ScrollFadeIn;

导航栏隐藏/显示:

import React, { useEffect, useState } from "react";

const ScrollNavbar: React.FC = () => {
const [isVisible, setIsVisible] = useState(true);
const [lastScrollY, setLastScrollY] = useState(0);

useEffect(() => {
function handleScroll() {
const currentScrollY = window.scrollY;
// 向下滚动隐藏,向上滚动显示
setIsVisible(currentScrollY < lastScrollY || currentScrollY < 50);
setLastScrollY(currentScrollY);
}

window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, [lastScrollY]);

return (
<nav
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
height: 60,
background: "#fff",
transform: isVisible ? "translateY(0)" : "translateY(-100%)",
transition: "transform 0.3s ease-out",
zIndex: 100,
}}
>
导航栏
</nav>
);
};

export default ScrollNavbar;

平滑滚动到顶部/底部:

import React from "react";

const SmoothScroll: React.FC = () => {
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" });
};

const scrollToBottom = () => {
window.scrollTo({
top: document.documentElement.scrollHeight,
behavior: "smooth",
});
};

return (
<div>
<button onClick={scrollToTop}>回到顶部</button>
<button onClick={scrollToBottom}>跳到底部</button>
<div style={{ height: 2000, padding: 20 }}>
<p>长页面内容...</p>
</div>
</div>
);
};

export default SmoothScroll;

数值计数动画(基于滚动进度):

import React, { useEffect, useRef, useState } from "react";

const ScrollCounter: React.FC = () => {
const [count, setCount] = useState(0);
const sectionRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
let intervalId: number | null = null;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
// 进入可视区域时启动计数
let num = 0;
// 使用 window.setInterval 并保存 id,便于外层 cleanup 清理
intervalId = window.setInterval(() => {
if (num < 100) {
num += 2;
setCount(num);
} else {
if (intervalId != null) {
clearInterval(intervalId);
intervalId = null;
}
}
}, 20);
} else {
if (intervalId != null) {
clearInterval(intervalId);
intervalId = null;
}
}
},
{ threshold: 0.5 },
);

if (sectionRef.current) {
observer.observe(sectionRef.current);
}

return () => {
observer.disconnect();
if (intervalId != null) {
clearInterval(intervalId);
}
};
}, []);

return (
<div
ref={sectionRef}
style={{
padding: 40,
textAlign: "center",
fontSize: 48,
fontWeight: "bold",
}}
>
用户数:{count}+
</div>
);
};

export default ScrollCounter;

常见库推荐:

6. 滚动穿透

场景

  • 弹窗/抽屉滚动穿透:弹窗、侧边栏、抽屉等浮层弹出时,底层页面依然可以滚动,导致内容错位或交互混乱。
  • 移动端遮罩穿透:移动端弹出遮罩层时,用户滑动会导致背景页面滚动,影响体验。
  • 多层嵌套滚动:浮层内部滚动到边界后,继续滑动会导致外层页面滚动。

解决思路

  1. 阻止背景滚动

    • 弹窗/浮层打开时,给 <body> 添加 overflow: hidden,禁止页面滚动。
    • 关闭弹窗时移除该样式,恢复滚动。
    • 注意:iOS 下仅设置 overflow: hidden 可能无效,需配合 position: fixed 或锁定 bodytop
  2. 阻止事件冒泡

    • 在浮层内部监听 touchmove(移动端)或 wheel(PC 端)事件,阻止事件冒泡到外层,防止滚动穿透。
    • 只允许浮层自身滚动,滚动到边界时阻止默认行为。
  3. 锁定滚动位置

    • 弹窗弹出时记录当前滚动位置,将 body 设为 position: fixed; top: -scrollY,关闭时还原。
    • 适用于移动端,防止页面“跳动”。

示例代码

React 阻止滚动穿透 Hook:

import { useEffect } from "react";

function useLockBodyScroll(lock: boolean) {
useEffect(() => {
if (!lock) return;
const scrollY = window.scrollY || window.pageYOffset;
const originalStyle = window.getComputedStyle(document.body).overflow;
document.body.style.overflow = "hidden";
document.body.style.position = "fixed";
document.body.style.top = `-${scrollY}px`;
document.body.style.width = "100%";
return () => {
document.body.style.overflow = originalStyle;
document.body.style.position = "";
document.body.style.top = "";
document.body.style.width = "";
window.scrollTo(0, scrollY);
};
}, [lock]);
}

export default useLockBodyScroll;

弹窗组件中使用:

import useLockBodyScroll from "../hooks/use-lock-body-scroll";

const Modal: React.FC<{ open: boolean; onClose: () => void }> = ({
open,
onClose,
children,
}) => {
useLockBodyScroll(open);

if (!open) return null;
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
zIndex: 1000,
}}
onClick={onClose}
>
<div
style={{
background: "#fff",
margin: "100px auto",
padding: 24,
width: 400,
}}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
);
};

移动端阻止 touchmove 穿透:

useEffect(() => {
function preventTouchMove(e: TouchEvent) {
e.preventDefault();
}
if (open) {
document.body.addEventListener("touchmove", preventTouchMove, {
passive: false,
});
}
return () => {
document.body.removeEventListener("touchmove", preventTouchMove);
};
}, [open]);

常见库推荐:

7. 滚动条样式

场景

  • 自定义滚动条外观:提升页面美观度,适配品牌风格。
  • 隐藏原生滚动条:在特定 UI 组件中隐藏或美化滚动条,避免影响布局。
  • 跨浏览器统一体验:不同浏览器下滚动条样式不一致,需要统一风格。
  • 暗色/亮色主题适配:根据主题切换滚动条颜色。

解决方案

1. CSS 滚动条样式(Webkit/Chromium)

使用 ::-webkit-scrollbar 相关伪元素自定义滚动条(Chrome、Edge、Safari 支持):

/* 整体滚动条 */
::-webkit-scrollbar {
width: 8px;
background: transparent;
}

/* 滚动条轨道 */
::-webkit-scrollbar-track {
background: #f0f0f0;
border-radius: 4px;
}

/* 滚动条滑块 */
::-webkit-scrollbar-thumb {
background: #bdbdbd;
border-radius: 4px;
transition: background 0.2s;
}
::-webkit-scrollbar-thumb:hover {
background: #888;
}

2. Firefox 滚动条样式

Firefox 支持 scrollbar-widthscrollbar-color

/* 细滚动条 */
.scrollable {
scrollbar-width: thin;
scrollbar-color: #bdbdbd #f0f0f0;
}

3. 隐藏滚动条但保留滚动功能

/* Webkit 浏览器 */
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
/* Firefox */
.hide-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none; /* IE/Edge */
}

4. 适配暗色主题

.dark-theme ::-webkit-scrollbar-thumb {
background: #444;
}
.dark-theme ::-webkit-scrollbar-track {
background: #222;
}

5. 跨平台统一方案

6. React 示例

export const CustomScrollbarBox: React.FC = () => (
<div
style={{
height: 200,
overflow: "auto",
border: "1px solid #ddd",
padding: 8,
maxWidth: 300,
}}
className="custom-scrollbar"
>
{Array.from({ length: 20 }, (_, i) => (
<div key={i} style={{ padding: 8 }}>
Item {i + 1}
</div>
))}
<style>
{`
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #bdbdbd;
border-radius: 4px;
}
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: #bdbdbd #f0f0f0;
}
`}
</style>
</div>
);

常见库推荐

8. 滚动吸附

场景

  • 滚动吸附(Sticky):如头部、侧边栏、表头、底部操作栏等在滚动时自动吸附在页面边缘,提升可访问性和操作便捷性。
  • 滚动捕捉(Scroll Snap):通过 CSS scroll-snap 属性,让滚动内容在特定位置自动对齐,常用于轮播图、分页滑动、横向滚动列表等场景,提升滚动体验。

Scroll Snap 场景与实现

应用场景

  • 轮播图/图片画廊:滑动到每一页自动对齐,避免内容卡在中间。
  • 横向滚动菜单/标签栏:滑动时每个菜单项自动吸附到可视区域。
  • 分页内容:如移动端分屏、幻灯片、步骤引导等,滚动到整页位置。
  • 时间轴/日历:滚动时每个时间段或日期自动对齐。

CSS 实现

.scroll-snap-container {
scroll-snap-type: x mandatory; /* 横向捕捉,强制对齐 */
overflow-x: auto;
display: flex;
}
.scroll-snap-item {
scroll-snap-align: start; /* 每项对齐到容器起始 */
flex: 0 0 80%;
margin-right: 16px;
}

React 示例

export const ScrollSnapDemo: React.FC = () => (
<div
className="scroll-snap-container"
style={{
display: "flex",
overflowX: "auto",
scrollSnapType: "x mandatory",
gap: 16,
padding: 8,
width: 400,
}}
>
{Array.from({ length: 5 }, (_, i) => (
<div
key={i}
className="scroll-snap-item"
style={{
flex: "0 0 80%",
height: 120,
background: "#e0e7ff",
scrollSnapAlign: "start",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 24,
borderRadius: 8,
}}
>
Slide {i + 1}
</div>
))}
</div>
);

常见库推荐

注意事项:

  • scroll-snap 仅通过 CSS 即可实现,性能优异,兼容性良好(IE 不支持)。
  • 可结合 scroll-behavior: smooth 实现平滑滚动体验。
  • 适用于移动端和桌面端的滚动对齐需求。

Sticky 场景与实现

  • 头部/导航栏吸顶:页面滚动到一定位置时,导航栏自动固定在顶部,提升可访问性。
  • 侧边目录吸附:长文档的目录在滚动时保持可见,方便快速跳转。
  • 表头吸顶:表格滚动时表头固定,便于数据对齐阅读。
  • 底部操作栏吸底:操作按钮栏在页面底部吸附,方便操作。

解决思路

  1. CSS position: sticky

    • 最简单的吸附方案,设置元素 position: stickytop/bottom 距离即可。
    • 兼容性较好,适用于大多数现代浏览器。
    • 适合父容器没有 overflow: hidden/auto 的场景。
  2. 监听滚动事件 + 动态切换 fixed

    • 监听页面或容器的 scroll 事件,判断元素距离顶部/底部的距离。
    • 滚动到设定阈值时,将元素切换为 position: fixed,否则恢复原位。
    • 适合需要更复杂吸附逻辑或兼容性要求高的场景。
  3. Intersection Observer

    • 使用 Intersection Observer 监听锚点元素是否离开可视区,动态切换吸附状态。
    • 性能优于频繁监听滚动事件。
  4. 第三方库

常见注意点:

  • 吸附元素切换为 fixed 时需占位,避免页面跳动。
  • 多层嵌套滚动、移动端兼容需额外处理。

简单 CSS 示例:

.sticky-header {
position: sticky;
top: 0;
background: #fff;
z-index: 100;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
}

React 动态吸附示例:

import React, { useEffect, useRef, useState } from "react";

const StickyNav: React.FC = () => {
const navRef = useRef<HTMLDivElement | null>(null);
const [fixed, setFixed] = useState(false);

useEffect(() => {
function onScroll() {
if (!navRef.current) return;
const rect = navRef.current.getBoundingClientRect();
setFixed(rect.top <= 0);
}
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);

return (
<>
<div
ref={navRef}
style={{
height: 60,
background: "#fff",
boxShadow: fixed ? "0 2px 8px rgba(0,0,0,0.06)" : "none",
position: fixed ? "fixed" : "static",
top: 0,
left: 0,
right: 0,
zIndex: 100,
transition: "box-shadow 0.2s",
}}
>
吸附导航栏
</div>
<div style={{ height: 1000, padding: 20 }}>内容区域...</div>
</>
);
};

export default StickyNav;

常见库推荐:

9. 下拉回弹

场景

  • 移动端下拉刷新:在移动端页面或 App 中,用户下拉页面顶部时触发刷新操作,常见于新闻、社交、列表等应用。
  • 下拉回弹动画:用户下拉页面或容器时,内容区域随手势下移,松手后自动回弹到原位,提升交互体验。
  • 自定义下拉提示:下拉过程中显示“下拉刷新”、“松手刷新”等提示,增强用户感知。

实现思路

  1. 监听触摸事件

    • 通过 touchstarttouchmovetouchend 监听用户手势。
    • 记录初始触点位置,计算下拉距离。
  2. 判断下拉条件

    • 仅在页面或容器已滚动到顶部(scrollTop === 0)时允许下拉。
    • 防止横向滑动或非顶部时误触发。
  3. 内容区域跟随手势移动

    • 根据下拉距离动态设置内容区域的 transform: translateY(...),实现跟随手势的下拉动画。
    • 可设置最大下拉距离,避免过度拉伸。
  4. 松手回弹与刷新

    • 松手后判断下拉距离是否超过阈值,若超过则触发刷新逻辑,否则直接回弹。
    • 使用 CSS 过渡或动画平滑回弹到初始位置。
  5. 刷新状态管理

    • 刷新时显示加载动画或提示,刷新完成后重置状态。
  6. 移动端兼容与阻止穿透

    • 阻止默认滚动和事件冒泡,避免下拉时页面整体滚动穿透。

常见库推荐

10. 橡皮筋效果

场景

  • 移动端橡皮筋回弹:在 iOS 等移动端浏览器中,滚动到页面或容器的顶部/底部继续拖动时,内容会出现“拉伸-回弹”的橡皮筋动画效果,提升交互的物理真实感。
  • 自定义滚动体验:在 Web App、H5 页面、Hybrid 应用中,模拟原生橡皮筋效果,增强用户体验。
  • 防止滚动穿透:通过自定义橡皮筋回弹,防止滚动到边界时页面穿透或出现异常滚动。

解决思路

  1. 监听触摸事件
    • 通过 touchstarttouchmovetouchend 监听用户手势,记录初始触点和当前滚动位置。
  2. 判断边界条件
    • 仅在容器已滚动到顶部(scrollTop === 0)或底部(scrollTop + clientHeight >= scrollHeight)时允许橡皮筋拉伸。
  3. 内容区域跟随手势拉伸
    • 根据手指拖动距离,动态设置内容区域的 transform: translateY(...),实现拉伸动画。
    • 可设置最大拉伸距离,避免过度拉伸。
  4. 松手回弹动画
    • touchend 时,使用 CSS 过渡或动画将内容平滑回弹到原位。
  5. 阻止默认滚动和穿透
    • 在拉伸过程中阻止默认滚动行为,避免页面整体滚动穿透。
  6. 兼容性处理
    • iOS Safari 原生支持橡皮筋效果,Android 及部分 WebView 需自定义实现。

常见库推荐

如何禁用橡皮筋效果

有时为了防止页面出现不期望的回弹动画或滚动穿透,需要禁用橡皮筋效果,常见于弹窗、全屏遮罩、Web App 等场景。

禁用方式

  1. CSS 方式(iOS Safari 13+)
    使用 overscroll-behavior 属性:

    html,
    body {
    overscroll-behavior: contain;
    /* 或 overscroll-behavior: none; */
    }
    • overscroll-behavior: contain 可阻止父级滚动和橡皮筋回弹。
    • 也可单独应用于某个容器。
  2. 阻止默认触摸事件
    在需要禁用的容器上监听 touchmove 并阻止默认行为:

    useEffect(() => {
    function preventRubberBand(e) {
    e.preventDefault();
    }
    const el = containerRef.current;
    if (el) {
    el.addEventListener("touchmove", preventRubberBand, { passive: false });
    }
    return () => {
    if (el) {
    el.removeEventListener("touchmove", preventRubberBand);
    }
    };
    }, []);

    注意:此方法会完全禁用容器的滚动,适用于弹窗、遮罩等场景。

  3. Web App/Hybrid App 设置

    • 在 iOS WebView 中,可通过 meta 标签或配置禁用橡皮筋回弹(如 apple-mobile-web-app-capable)。
    • 某些 Hybrid 容器可通过配置参数关闭回弹。

典型应用

  • 弹窗、抽屉、遮罩层弹出时,防止背景页面出现橡皮筋回弹。
  • 全屏 Web App,避免页面边界出现回弹动画影响体验。
  • 嵌套滚动区域,防止内层滚动到边界时外层页面回弹。

参考资料: