设计移动端上拉加载和下拉刷新
问题
如何从零设计一个移动端的上拉加载更多(Infinite Scroll)和下拉刷新(Pull to Refresh)组件?请从交互设计、手势处理、状态管理、性能优化、虚拟列表集成等维度全面阐述方案,并给出可复用的 React Hook 封装。
答案
下拉刷新和上拉加载是移动端信息流产品(新闻资讯、社交 Feed、电商列表等)最基础、最高频的交互模式。看似简单,但其中涉及 Touch 手势处理、阻尼算法、CSS 动画性能、浏览器兼容性、防重复请求 等大量细节。能否从零实现一套可靠的方案,直接体现候选人对移动端开发的理解深度。
下拉刷新和上拉加载的本质是:将用户的物理手势映射为数据操作(刷新/翻页),同时提供即时、流畅的视觉反馈。所有技术选型都服务于这一目标。
一、需求分析与交互设计
交互流程
| 功能 | 交互流程 | 触发条件 |
|---|---|---|
| 下拉刷新 | 手指下拉 → 显示刷新指示器 → 松手触发刷新 → 刷新完成收起 | 列表在顶部时手指向下拖拽 |
| 上拉加载 | 滚动到底部 → 自动加载下一页 → 显示加载状态 → 加载完成追加数据 | 滚动距底部 < 阈值(如 300px) |
状态机设计
下拉刷新涉及多个状态的流转,用状态机建模是最清晰的方式:
上拉加载的状态相对简单:
非功能需求
| 指标 | 目标 |
|---|---|
| 动画帧率 | 下拉拖拽过程中保持 60fps |
| 手势响应 | touchmove → 视觉更新延迟 < 16ms |
| 兼容性 | iOS Safari、Android Chrome/WebView、微信内置浏览器 |
| 可扩展性 | 支持自定义刷新指示器、可与虚拟列表组合 |
二、下拉刷新实现
下拉刷新是本方案的重点和难点,核心在于 Touch 事件处理、阻尼效果、transform 动画。
2.1 整体架构
2.2 核心实现
import React, { useRef, useState, useCallback, useEffect } from 'react';
type PullRefreshState = 'idle' | 'pulling' | 'ready' | 'refreshing' | 'done';
interface PullRefreshProps {
onRefresh: () => Promise<void>;
threshold?: number; // 触发刷新的阈值(px)
maxDistance?: number; // 最大下拉距离(px)
children: React.ReactNode;
refreshingContent?: React.ReactNode;
pullingContent?: (progress: number) => React.ReactNode;
}
// 阻尼函数:下拉距离越大,实际偏移增长越慢
function damping(distance: number, maxDistance: number): number {
// 使用幂函数实现非线性映射
// 当 distance = maxDistance 时,返回 maxDistance * 0.6
return maxDistance * (1 - Math.pow(1 - distance / maxDistance, 0.6));
}
export function PullRefresh({
onRefresh,
threshold = 60,
maxDistance = 200,
children,
refreshingContent,
pullingContent,
}: PullRefreshProps) {
const [state, setState] = useState<PullRefreshState>('idle');
const [translateY, setTranslateY] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const startYRef = useRef(0);
const isAtTopRef = useRef(false);
// 判断列表是否在顶部,只有在顶部才允许下拉刷新
const checkScrollTop = useCallback(() => {
const el = containerRef.current;
if (!el) return false;
return el.scrollTop <= 0;
}, []);
const handleTouchStart = useCallback((e: TouchEvent) => {
if (state === 'refreshing') return;
isAtTopRef.current = checkScrollTop();
startYRef.current = e.touches[0].clientY;
}, [state, checkScrollTop]);
const handleTouchMove = useCallback((e: TouchEvent) => {
if (state === 'refreshing' || !isAtTopRef.current) return;
const currentY = e.touches[0].clientY;
const rawDistance = currentY - startYRef.current;
// 只处理下拉(rawDistance > 0)
if (rawDistance <= 0) {
setTranslateY(0);
setState('idle');
return;
}
// 阻止浏览器默认的下拉刷新行为
e.preventDefault();
// 应用阻尼效果
const distance = damping(rawDistance, maxDistance);
setTranslateY(distance);
setState(distance >= threshold ? 'ready' : 'pulling');
}, [state, maxDistance, threshold]);
const handleTouchEnd = useCallback(async () => {
if (state === 'ready') {
// 超过阈值 → 触发刷新
setState('refreshing');
// 回弹到刷新位置(通常 = threshold 高度)
setTranslateY(threshold);
try {
await onRefresh();
} finally {
setState('done');
// 收起动画
setTranslateY(0);
// 延迟回到 idle,等待 CSS transition 完成
setTimeout(() => setState('idle'), 300);
}
} else {
// 未超过阈值 → 回弹
setTranslateY(0);
setState('idle');
}
}, [state, threshold, onRefresh]);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
// 必须使用 passive: false 才能在 touchmove 中 preventDefault
el.addEventListener('touchstart', handleTouchStart, { passive: true });
el.addEventListener('touchmove', handleTouchMove, { passive: false });
el.addEventListener('touchend', handleTouchEnd, { passive: true });
return () => {
el.removeEventListener('touchstart', handleTouchStart);
el.removeEventListener('touchmove', handleTouchMove);
el.removeEventListener('touchend', handleTouchEnd);
};
}, [handleTouchStart, handleTouchMove, handleTouchEnd]);
const renderIndicator = () => {
switch (state) {
case 'pulling':
return pullingContent?.(translateY / threshold) ?? '继续下拉刷新...';
case 'ready':
return '释放立即刷新';
case 'refreshing':
return refreshingContent ?? '刷新中...';
case 'done':
return '刷新完成';
default:
return null;
}
};
return (
<div
ref={containerRef}
style={{
overflow: 'auto',
// 禁止浏览器默认的 overscroll 行为(iOS 橡皮筋效果)
overscrollBehavior: 'none',
WebkitOverflowScrolling: 'touch',
height: '100%',
}}
>
{/* 下拉指示器 */}
<div
style={{
height: translateY,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
// 使用 transition 实现松手后的平滑收起
transition: state === 'pulling' || state === 'ready'
? 'none'
: 'height 0.3s ease-out',
}}
>
{renderIndicator()}
</div>
{/* 内容区域使用 transform 跟随下拉 */}
{children}
</div>
);
}
top 和 margin 的变化会触发浏览器的重排(Layout),而 transform 只触发合成(Composite),由 GPU 直接处理,性能差异巨大。在 60fps 的要求下,每帧只有约 16ms 的预算,重排很容易导致掉帧。详见 动画性能优化。
2.3 阻尼效果详解
阻尼效果的目的是让用户越往下拉,感觉越"吃力",提供真实的物理反馈:
// 方案一:幂函数(推荐)
function dampingPow(distance: number, max: number): number {
const ratio = distance / max;
// 指数 < 1 时,曲线先快后慢,符合阻尼直觉
return max * Math.pow(ratio, 0.6);
}
// 方案二:对数函数
function dampingLog(distance: number, max: number): number {
// 对数增长,天然就是先快后慢
return max * Math.log10(1 + (distance / max) * 9);
}
// 方案三:线性衰减(最简单)
function dampingLinear(distance: number, max: number): number {
// 0.4 为衰减系数,越小阻尼越强
return distance * 0.4;
}
| 方案 | 手感 | 适用场景 |
|---|---|---|
| 幂函数 | 自然、平滑 | 大多数场景(推荐) |
| 对数函数 | 初始灵敏,后期极度吃力 | 需要强限制的场景 |
| 线性衰减 | 简单粗暴 | 快速原型 |
三、上拉加载实现
3.1 方案对比
| 特性 | scroll 事件监听 | IntersectionObserver |
|---|---|---|
| 性能 | 需要手动节流,高频触发 | 浏览器底层优化,异步回调 |
| 代码复杂度 | 较高(计算滚动位置) | 较低(声明式 API) |
| 兼容性 | 所有浏览器 | iOS 12.2+、Android 5+ |
| 精确度 | 像素级控制 | 基于交叉比例 |
| 推荐度 | 兜底方案 | 首选方案 |
IntersectionObserver 在浏览器主线程之外异步计算元素可见性,不会阻塞主线程。而 scroll 事件的回调在主线程执行,如果回调中包含 getBoundingClientRect() 等操作还会强制触发重排。详见 事件机制。
3.2 IntersectionObserver 方案(推荐)
import React, { useRef, useEffect, useState } from 'react';
interface InfiniteScrollProps {
loadMore: () => Promise<void>;
hasMore: boolean;
threshold?: number; // 提前触发的距离(rootMargin)
children: React.ReactNode;
loadingContent?: React.ReactNode;
noMoreContent?: React.ReactNode;
errorContent?: React.ReactNode;
}
export function InfiniteScroll({
loadMore,
hasMore,
threshold = 300,
children,
loadingContent = <div>加载中...</div>,
noMoreContent = <div>没有更多了</div>,
errorContent,
}: InfiniteScrollProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const sentinelRef = useRef<HTMLDivElement>(null);
// 使用 ref 存储 loading 状态,避免闭包陷阱
const loadingRef = useRef(false);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel || !hasMore) return;
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
// 防重复加载:通过 ref 判断是否正在加载
if (entry.isIntersecting && !loadingRef.current) {
loadingRef.current = true;
setLoading(true);
setError(false);
loadMore()
.catch(() => setError(true))
.finally(() => {
loadingRef.current = false;
setLoading(false);
});
}
},
{
// rootMargin 实现「提前加载」,还没滚到底部就开始请求
rootMargin: `0px 0px ${threshold}px 0px`,
}
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore, loadMore, threshold]);
return (
<>
{children}
{/* 哨兵元素 —— 当它进入视口时触发加载 */}
<div ref={sentinelRef} style={{ height: 1 }} />
{loading && loadingContent}
{error && (
<div onClick={() => {
loadingRef.current = false;
// 点击重试
setError(false);
}}>
{errorContent ?? '加载失败,点击重试'}
</div>
)}
{!hasMore && noMoreContent}
</>
);
}
3.3 scroll 事件方案(兜底)
import { useEffect, useRef, useCallback } from 'react';
interface UseScrollLoadOptions {
target: React.RefObject<HTMLElement | null>;
threshold?: number;
onLoadMore: () => Promise<void>;
hasMore: boolean;
}
export function useScrollLoad({
target,
threshold = 300,
onLoadMore,
hasMore,
}: UseScrollLoadOptions) {
const loadingRef = useRef(false);
const rafIdRef = useRef<number>(0);
const handleScroll = useCallback(() => {
// 使用 requestAnimationFrame 节流,保证一帧只计算一次
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = requestAnimationFrame(() => {
const el = target.current;
if (!el || loadingRef.current || !hasMore) return;
const { scrollTop, scrollHeight, clientHeight } = el;
// 距离底部 < threshold 时触发
if (scrollHeight - scrollTop - clientHeight < threshold) {
loadingRef.current = true;
onLoadMore().finally(() => {
loadingRef.current = false;
});
}
});
}, [target, threshold, onLoadMore, hasMore]);
useEffect(() => {
const el = target.current;
if (!el) return;
el.addEventListener('scroll', handleScroll, { passive: true });
return () => {
el.removeEventListener('scroll', handleScroll);
cancelAnimationFrame(rafIdRef.current);
};
}, [handleScroll, target]);
}
传统的 setTimeout / throttle 节流无法精确对齐浏览器渲染帧,而 requestAnimationFrame 可以确保回调在浏览器下一帧绘制前执行,是动画和滚动场景的最佳节流方案。
四、手势处理细节
4.1 passive 事件监听器
// Chrome 56+ 默认将 touchstart/touchmove 设为 passive: true
// 这意味着无法在回调中调用 preventDefault()
// 错误写法 —— Chrome 控制台会报 warning
el.addEventListener('touchmove', (e) => {
// Unable to preventDefault inside passive event listener invocation
e.preventDefault();
});
// 正确写法 —— 显式声明 passive: false
el.addEventListener('touchmove', (e) => {
e.preventDefault(); // 现在可以正常阻止默认行为
}, { passive: false });
在移动端浏览器中,手指下拉默认会触发浏览器自带的「下拉刷新」或「橡皮筋回弹」效果。如果不调用 preventDefault(),自定义的下拉刷新组件会与浏览器默认行为冲突,出现"双重下拉"的问题。
4.2 方向判断
实际场景中,用户的手指很少纯垂直滑动,需要区分垂直和水平方向:
interface TouchInfo {
startX: number;
startY: number;
}
type Direction = 'vertical' | 'horizontal' | 'none';
function getDirection(
start: TouchInfo,
currentX: number,
currentY: number,
// 最小识别距离,避免微小抖动触发
minDistance: number = 10
): Direction {
const deltaX = Math.abs(currentX - start.startX);
const deltaY = Math.abs(currentY - start.startY);
if (deltaX < minDistance && deltaY < minDistance) {
return 'none'; // 还没滑够,先不判断
}
// 斜率 > 1 认为是垂直滑动
return deltaY > deltaX ? 'vertical' : 'horizontal';
}
当页面中同时存在横向 Swiper(轮播图)和下拉刷新时,方向判断至关重要。首次判断方向后应 锁定方向,整个手势过程中不再改变,避免在对角线滑动时出现跳跃。
4.3 iOS 和 Android 差异处理
| 特性 | iOS Safari | Android Chrome |
|---|---|---|
| 橡皮筋效果 | 默认开启,overscroll-behavior 部分支持 | 无橡皮筋,Chrome 自带下拉刷新 |
| passive 默认值 | touchmove 默认 passive: true | 同左 |
-webkit-overflow-scrolling | 需要 touch 值才有惯性滚动 | 不需要 |
| scrollTop 负值 | 橡皮筋回弹时 scrollTop 可能为负 | 不会出现负值 |
| 滚动穿透 | 弹窗场景容易穿透 | 相对较少 |
.pull-refresh-container {
overflow: auto;
/* 禁止浏览器默认的 overscroll 行为 */
overscroll-behavior-y: none;
/* iOS 惯性滚动 */
-webkit-overflow-scrolling: touch;
}
/* 全局禁止浏览器下拉刷新(谨慎使用) */
html, body {
overscroll-behavior-y: none;
}
iOS Safari 在橡皮筋回弹期间,scrollTop 可能返回负值。在判断"列表是否在顶部"时,应使用 scrollTop <= 0 而非 scrollTop === 0,否则可能出现无法触发下拉刷新的问题。
五、性能优化
5.1 关键优化点
| 优化项 | 做法 | 效果 |
|---|---|---|
| transform 替代 top/margin | 使用 transform: translateY() | 避免重排,GPU 加速 |
| will-change 提示 | will-change: transform | 提前创建合成层 |
| RAF 驱动动画 | 用 requestAnimationFrame 节流 touchmove | 对齐渲染帧 |
| passive 事件 | 不需要 preventDefault 的事件设为 passive | 不阻塞浏览器滚动 |
| 批量 DOM 更新 | 使用 React state 驱动,避免直接 DOM 操作 | 减少重排次数 |
| avoid forced reflow | 不在 touchmove 中读取布局属性 | 避免强制同步布局 |
5.2 will-change 使用
<div
style={{
// 提前告诉浏览器这个元素的 transform 会变化
// 浏览器会为它创建独立的合成层
willChange: state !== 'idle' ? 'transform' : 'auto',
transform: `translateY(${translateY}px)`,
transition: state === 'pulling' ? 'none' : 'transform 0.3s ease-out',
}}
>
{children}
</div>
will-change 会让浏览器提前分配 GPU 内存创建合成层,如果所有元素都加上会导致内存暴增。正确做法是只在动画进行中设置,动画结束后移除(设为 auto)。
5.3 与虚拟列表结合
在大数据量场景下,上拉加载会不断追加数据,列表越来越长。当数据量超过几百条时,需要引入 虚拟列表 来保证滚动性能:
import { FixedSizeList as List } from 'react-window';
import { PullRefresh } from './PullRefresh';
import { useRef, useState, useCallback } from 'react';
interface VirtualPullRefreshListProps {
items: Array<{ id: string; content: string }>;
onRefresh: () => Promise<void>;
onLoadMore: () => Promise<void>;
hasMore: boolean;
itemHeight: number;
}
export function VirtualPullRefreshList({
items,
onRefresh,
onLoadMore,
hasMore,
itemHeight,
}: VirtualPullRefreshListProps) {
const listRef = useRef<List>(null);
const loadingRef = useRef(false);
// 监听虚拟列表的滚动事件,判断是否接近底部
const handleItemsRendered = useCallback(({
visibleStopIndex,
}: {
visibleStopIndex: number;
}) => {
// 当可见区域的最后一项接近列表末尾时触发加载
if (
visibleStopIndex >= items.length - 5 &&
hasMore &&
!loadingRef.current
) {
loadingRef.current = true;
onLoadMore().finally(() => {
loadingRef.current = false;
});
}
}, [items.length, hasMore, onLoadMore]);
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
<div style={style}>
{items[index]?.content}
</div>
);
return (
<PullRefresh onRefresh={onRefresh}>
<List
ref={listRef}
height={window.innerHeight}
itemCount={items.length}
itemSize={itemHeight}
width="100%"
onItemsRendered={handleItemsRendered}
>
{Row}
</List>
{!hasMore && <div style={{ textAlign: 'center', padding: 16 }}>没有更多了</div>}
</PullRefresh>
);
}
真实业务中列表项高度往往不一致(如 Feed 信息流),此时需要使用 react-window 的 VariableSizeList 配合 预估高度 + 动态修正 策略。这比固定高度方案复杂得多,需要在 itemSize 回调中返回已知高度或预估值,并在渲染后通过 ResizeObserver 修正。
六、与虚拟列表结合的深入讨论
6.1 挑战
| 挑战 | 说明 |
|---|---|
| 滚动容器选择 | 虚拟列表自身管理滚动容器,下拉刷新也需要控制滚动容器,两者可能冲突 |
| scrollTop 判断 | 下拉刷新需要判断 scrollTop <= 0,但虚拟列表的滚动容器是内部管理的 |
| 数据追加时的滚动位置 | 上拉加载新数据后,虚拟列表需要正确维护滚动位置 |
| 动态高度 | 不定高列表项需要额外的高度缓存和修正机制 |
6.2 集成策略
关键点:通过 react-window 的 outerRef 获取真实滚动容器的 DOM 引用,将其传递给下拉刷新组件用于判断滚动位置。
七、Hook 封装
7.1 usePullRefresh
import { useRef, useState, useCallback, useEffect } from 'react';
type PullState = 'idle' | 'pulling' | 'ready' | 'refreshing' | 'done';
interface UsePullRefreshOptions {
onRefresh: () => Promise<void>;
containerRef: React.RefObject<HTMLElement | null>;
threshold?: number;
maxDistance?: number;
dampingFactor?: number;
}
interface UsePullRefreshReturn {
state: PullState;
translateY: number;
progress: number; // 0 ~ 1,下拉进度
}
export function usePullRefresh({
onRefresh,
containerRef,
threshold = 60,
maxDistance = 200,
dampingFactor = 0.6,
}: UsePullRefreshOptions): UsePullRefreshReturn {
const [state, setState] = useState<PullState>('idle');
const [translateY, setTranslateY] = useState(0);
const startY = useRef(0);
const isAtTop = useRef(false);
const damping = useCallback(
(d: number) => maxDistance * Math.pow(d / maxDistance, dampingFactor),
[maxDistance, dampingFactor]
);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const onTouchStart = (e: TouchEvent) => {
if (state === 'refreshing') return;
isAtTop.current = el.scrollTop <= 0;
startY.current = e.touches[0].clientY;
};
const onTouchMove = (e: TouchEvent) => {
if (state === 'refreshing' || !isAtTop.current) return;
const raw = e.touches[0].clientY - startY.current;
if (raw <= 0) {
setTranslateY(0);
setState('idle');
return;
}
e.preventDefault();
const dist = damping(Math.min(raw, maxDistance));
setTranslateY(dist);
setState(dist >= threshold ? 'ready' : 'pulling');
};
const onTouchEnd = async () => {
if (state === 'ready') {
setState('refreshing');
setTranslateY(threshold);
try {
await onRefresh();
} finally {
setState('done');
setTranslateY(0);
setTimeout(() => setState('idle'), 300);
}
} else if (state === 'pulling') {
setTranslateY(0);
setState('idle');
}
};
el.addEventListener('touchstart', onTouchStart, { passive: true });
el.addEventListener('touchmove', onTouchMove, { passive: false });
el.addEventListener('touchend', onTouchEnd, { passive: true });
return () => {
el.removeEventListener('touchstart', onTouchStart);
el.removeEventListener('touchmove', onTouchMove);
el.removeEventListener('touchend', onTouchEnd);
};
}, [containerRef, state, threshold, maxDistance, damping, onRefresh]);
return {
state,
translateY,
progress: Math.min(translateY / threshold, 1),
};
}
7.2 useInfiniteScroll
import { useRef, useEffect, useCallback, useState } from 'react';
interface UseInfiniteScrollOptions {
loadMore: () => Promise<void>;
hasMore: boolean;
threshold?: number;
// 哨兵元素的 ref,IntersectionObserver 监听它
sentinelRef: React.RefObject<HTMLElement | null>;
rootRef?: React.RefObject<HTMLElement | null>;
}
interface UseInfiniteScrollReturn {
loading: boolean;
error: boolean;
retry: () => void;
}
export function useInfiniteScroll({
loadMore,
hasMore,
threshold = 300,
sentinelRef,
rootRef,
}: UseInfiniteScrollOptions): UseInfiniteScrollReturn {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const loadingRef = useRef(false);
const doLoad = useCallback(async () => {
if (loadingRef.current || !hasMore) return;
loadingRef.current = true;
setLoading(true);
setError(false);
try {
await loadMore();
} catch {
setError(true);
} finally {
loadingRef.current = false;
setLoading(false);
}
}, [loadMore, hasMore]);
const retry = useCallback(() => {
setError(false);
doLoad();
}, [doLoad]);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel || !hasMore) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
doLoad();
}
},
{
root: rootRef?.current ?? null,
rootMargin: `0px 0px ${threshold}px 0px`,
}
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [sentinelRef, rootRef, hasMore, threshold, doLoad]);
return { loading, error, retry };
}
7.3 组合使用
import { useRef, useState, useCallback } from 'react';
import { usePullRefresh } from '../hooks/usePullRefresh';
import { useInfiniteScroll } from '../hooks/useInfiniteScroll';
interface FeedItem {
id: string;
title: string;
content: string;
}
export function FeedPage() {
const [items, setItems] = useState<FeedItem[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const containerRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
const fetchItems = useCallback(async (pageNum: number) => {
const res = await fetch(`/api/feed?page=${pageNum}&size=20`);
const data = await res.json();
return data as { items: FeedItem[]; hasMore: boolean };
}, []);
// 下拉刷新
const handleRefresh = useCallback(async () => {
const data = await fetchItems(1);
setItems(data.items);
setPage(1);
setHasMore(data.hasMore);
}, [fetchItems]);
// 上拉加载
const handleLoadMore = useCallback(async () => {
const nextPage = page + 1;
const data = await fetchItems(nextPage);
// 追加而非替换
setItems((prev) => [...prev, ...data.items]);
setPage(nextPage);
setHasMore(data.hasMore);
}, [page, fetchItems]);
const { state, translateY } = usePullRefresh({
onRefresh: handleRefresh,
containerRef,
});
const { loading, error, retry } = useInfiniteScroll({
loadMore: handleLoadMore,
hasMore,
sentinelRef,
rootRef: containerRef,
});
return (
<div
ref={containerRef}
style={{
height: '100vh',
overflow: 'auto',
overscrollBehavior: 'none',
}}
>
{/* 下拉指示器 */}
<div style={{
height: translateY,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: state === 'pulling' || state === 'ready'
? 'none'
: 'height 0.3s ease-out',
}}>
{state === 'pulling' && '下拉刷新...'}
{state === 'ready' && '释放刷新'}
{state === 'refreshing' && '刷新中...'}
</div>
{/* 列表内容 */}
{items.map((item) => (
<div key={item.id} style={{ padding: 16, borderBottom: '1px solid #eee' }}>
<h3>{item.title}</h3>
<p>{item.content}</p>
</div>
))}
{/* 哨兵元素 */}
<div ref={sentinelRef} style={{ height: 1 }} />
{loading && <div style={{ textAlign: 'center', padding: 16 }}>加载中...</div>}
{error && <div onClick={retry} style={{ textAlign: 'center', padding: 16, color: 'red' }}>加载失败,点击重试</div>}
{!hasMore && <div style={{ textAlign: 'center', padding: 16, color: '#999' }}>没有更多了</div>}
</div>
);
}
更多关于 Hook 设计原则和闭包陷阱的内容,参见 React Hooks 原理。
八、主流库对比
| 特性 | antd-mobile | vant(Vue) | react-pull-to-refresh | 自研方案 |
|---|---|---|---|---|
| 框架 | React | Vue 3 | React | 任意 |
| 下拉刷新 | PullToRefresh | PullRefresh | ReactPullToRefresh | 自定义 |
| 上拉加载 | InfiniteScroll | List | 无(需自行实现) | 自定义 |
| 虚拟列表 | VirtualList | 需搭配 | 无 | 需自行集成 |
| 阻尼效果 | 内置 | 内置 | 基础 | 可定制 |
| 自定义指示器 | 支持 | 支持 | 有限 | 完全自定义 |
| TypeScript | 原生支持 | 原生支持 | 类型不完善 | 完全可控 |
| 包大小 | ~5KB(按需) | ~4KB(按需) | ~2KB | 0(无额外依赖) |
- 业务项目:优先使用 antd-mobile / vant 等成熟组件库,开箱即用
- 组件库开发:理解原理后自研,实现可完全定制的方案
- 面试场景:能手写核心逻辑(Touch 事件 + 阻尼 + 状态机),是加分项
常见面试问题
Q1: 如何实现一个下拉刷新组件?描述核心实现思路
答案:
下拉刷新的核心实现分为四个部分:
1. Touch 事件监听:监听 touchstart(记录起始 Y 坐标)、touchmove(计算下拉距离)、touchend(判断是否触发刷新)。
2. 状态机管理:维护 idle → pulling → ready → refreshing → done → idle 的状态流转,每个状态对应不同的 UI 展示。
3. 阻尼效果:将手指下拉的原始距离通过非线性函数(如 Math.pow(ratio, 0.6))映射为更小的实际偏移量,模拟物理阻尼感。
4. transform 动画:使用 transform: translateY() 驱动元素偏移,避免使用 top / margin-top 导致的重排。松手后通过 CSS transition 实现平滑回弹。
const handleTouchMove = (e: TouchEvent) => {
const rawDistance = e.touches[0].clientY - startY;
if (rawDistance <= 0 || !isAtTop) return;
e.preventDefault(); // 阻止浏览器默认下拉
const dampedDistance = maxDistance * Math.pow(rawDistance / maxDistance, 0.6);
setTranslateY(dampedDistance);
setState(dampedDistance >= threshold ? 'ready' : 'pulling');
};
const handleTouchEnd = async () => {
if (state === 'ready') {
setState('refreshing');
setTranslateY(threshold); // 回弹到刷新位置
await onRefresh();
setTranslateY(0); // 收起
} else {
setTranslateY(0); // 未达阈值,直接回弹
}
};
Q2: 上拉加载用 scroll 事件 vs IntersectionObserver 哪个好?
答案:
推荐 IntersectionObserver,原因如下:
| 维度 | scroll 事件 | IntersectionObserver |
|---|---|---|
| 性能 | 高频触发,需手动节流 | 浏览器内部异步计算,不阻塞主线程 |
| 强制重排 | getBoundingClientRect() 触发重排 | 不需要读取布局属性 |
| 代码复杂度 | 需要手动计算距离、处理节流 | 声明式 API,几行代码搞定 |
| 精确度 | 像素级 | 基于 rootMargin 可设置提前量 |
| 兼容性 | 所有浏览器 | iOS 12.2+(可 polyfill) |
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !loading) {
loadMore();
}
},
// 提前 300px 触发,用户还没看到底部就已经开始加载
{ rootMargin: '0px 0px 300px 0px' }
);
observer.observe(sentinelElement);
scroll 事件方案仍然有价值,作为不支持 IntersectionObserver 环境的降级方案,或者需要精确像素控制的场景。
Q3: 如何实现下拉刷新的阻尼效果?
答案:
阻尼效果的本质是 非线性映射:手指下拉距离越大,元素实际偏移量增长越慢,给用户"越来越难拉"的物理反馈。
// 核心公式:幂函数映射
function damping(rawDistance: number, maxDistance: number): number {
const ratio = Math.min(rawDistance / maxDistance, 1);
// 指数 0.6 < 1,曲线呈"先快后慢"形态
return maxDistance * Math.pow(ratio, 0.6);
}
数值示例(maxDistance = 200):
| 手指下拉 | 实际偏移(指数 0.6) | 映射比例 |
|---|---|---|
| 50px | ~72px | 144% |
| 100px | ~104px | 104% |
| 150px | ~131px | 87% |
| 200px | ~200px × 0.6^0.6 ≈ 152px | 76% |
可以看到,初始阶段偏移甚至略大于下拉距离(给人"跟手"的感觉),后期增长明显变慢。
其他可选函数:
- 对数函数:
max * Math.log10(1 + ratio * 9),衰减更强 - 双曲正切:
max * Math.tanh(ratio),有自然上限 - 线性衰减:
rawDistance * 0.4,最简单但手感一般
Q4: 下拉刷新如何处理 iOS 和 Android 的兼容性差异?
答案:
主要差异和解决方案:
1. iOS 橡皮筋效果
iOS Safari 在 overscroll 时有弹性回弹效果(橡皮筋),会与自定义下拉刷新冲突。
/* 方案一:CSS 禁用(推荐) */
.container {
overscroll-behavior-y: none; /* iOS 16+ 支持 */
}
/* 方案二:JS 阻止 */
document.addEventListener('touchmove', (e) => {
if (isAtTop && isPullingDown) {
e.preventDefault();
}
}, { passive: false });
2. scrollTop 负值
iOS 橡皮筋回弹时 scrollTop 可能为负数:
// 使用 <= 0 而非 === 0
const isAtTop = container.scrollTop <= 0;
3. -webkit-overflow-scrolling
iOS 旧版本需要此属性启用惯性滚动:
.container {
-webkit-overflow-scrolling: touch; /* iOS 12 及以下需要 */
overflow-y: auto;
}
4. Chrome 默认下拉刷新
Android Chrome 有内置的下拉刷新(地址栏下方的圆形加载器),需要通过 CSS 或 meta 标签禁用:
/* 全局禁用 Chrome 下拉刷新 */
body {
overscroll-behavior-y: none;
}
Q5: 如何防止上拉加载重复请求?
答案:
重复请求的根本原因是:加载请求还没返回时,用户继续滚动又触发了新的加载。解决方案有三层防护:
第一层:loading 状态锁(必选)
const loadingRef = useRef(false);
const handleLoadMore = async () => {
if (loadingRef.current) return; // 正在加载中,直接返回
loadingRef.current = true;
try {
await fetchData();
} finally {
loadingRef.current = false;
}
};
useState 更新是异步的(批量更新),在高频的 scroll/IntersectionObserver 回调中,可能读到旧值。而 useRef.current 是同步修改的,能立即阻止下一次触发。这是典型的 React 闭包陷阱场景。
第二层:IntersectionObserver disconnect
useEffect(() => {
if (!hasMore) {
// 没有更多数据时直接断开观察,彻底避免触发
observer.disconnect();
}
}, [hasMore]);
第三层:后端幂等 + 去重
即使前端有防护,网络抖动可能导致请求重发。后端应基于 (userId, page, cursor) 做幂等校验。
Q6: 下拉刷新和虚拟列表如何结合使用?
答案:
核心挑战在于 滚动容器的归属权:虚拟列表(如 react-window)自己管理滚动容器,而下拉刷新也需要监听和控制滚动。
集成策略:
import { FixedSizeList } from 'react-window';
import { useRef } from 'react';
import { usePullRefresh } from '../hooks/usePullRefresh';
function IntegratedList() {
// 关键:通过 outerRef 获取虚拟列表的真实滚动容器
const outerRef = useRef<HTMLDivElement>(null);
const { state, translateY } = usePullRefresh({
onRefresh: handleRefresh,
// 将虚拟列表的滚动容器传给 PullRefresh
containerRef: outerRef,
});
return (
<div>
{/* 刷新指示器放在虚拟列表外部 */}
<RefreshIndicator state={state} translateY={translateY} />
<FixedSizeList
height={window.innerHeight}
itemCount={items.length}
itemSize={80}
width="100%"
// outerRef 暴露内部滚动容器的 DOM 引用
outerRef={outerRef}
>
{Row}
</FixedSizeList>
</div>
);
}
注意事项:
- 刷新指示器要放在虚拟列表 外部,否则会被虚拟列表的固定高度截断
- 使用
outerRef而非innerRef,outerRef指向有overflow: auto的滚动容器 - 虚拟列表方案详见 长列表优化
Q7: 如何用 React Hook 封装一个通用的 useInfiniteScroll?
答案:
一个通用的 useInfiniteScroll 应满足以下设计目标:
| 特性 | 说明 |
|---|---|
| IntersectionObserver | 首选方案,性能好 |
| 自动加载 | 进入视口自动触发,无需手动调用 |
| 防重复 | 内置 loading 锁 |
| 错误处理 | 加载失败时提供 retry 方法 |
| 可配置 | threshold、root、rootMargin 可定制 |
import { useRef, useEffect, useCallback, useState } from 'react';
interface UseInfiniteScrollOptions<T> {
// 泛型设计,支持任意数据类型
fetchData: (page: number) => Promise<{ data: T[]; hasMore: boolean }>;
initialPage?: number;
}
interface UseInfiniteScrollReturn<T> {
data: T[];
loading: boolean;
error: boolean;
hasMore: boolean;
sentinelRef: React.RefObject<HTMLDivElement | null>;
retry: () => void;
refresh: () => void;
}
export function useInfiniteScroll<T>({
fetchData,
initialPage = 1,
}: UseInfiniteScrollOptions<T>): UseInfiniteScrollReturn<T> {
const [data, setData] = useState<T[]>([]);
const [page, setPage] = useState(initialPage);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [hasMore, setHasMore] = useState(true);
const sentinelRef = useRef<HTMLDivElement>(null);
const loadingRef = useRef(false);
const loadMore = useCallback(async () => {
if (loadingRef.current || !hasMore) return;
loadingRef.current = true;
setLoading(true);
setError(false);
try {
const result = await fetchData(page);
setData((prev) => [...prev, ...result.data]);
setHasMore(result.hasMore);
setPage((p) => p + 1);
} catch {
setError(true);
} finally {
loadingRef.current = false;
setLoading(false);
}
}, [fetchData, page, hasMore]);
// 刷新:清空数据,回到第一页
const refresh = useCallback(async () => {
loadingRef.current = false;
setData([]);
setPage(initialPage);
setHasMore(true);
setError(false);
}, [initialPage]);
const retry = useCallback(() => {
setError(false);
loadMore();
}, [loadMore]);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel || !hasMore) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadMore();
},
{ rootMargin: '0px 0px 300px 0px' }
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore, loadMore]);
return { data, loading, error, hasMore, sentinelRef, retry, refresh };
}
使用方式非常简洁:
function App() {
const { data, loading, hasMore, sentinelRef, error, retry, refresh } =
useInfiniteScroll<FeedItem>({
fetchData: async (page) => {
const res = await fetch(`/api/items?page=${page}`);
return res.json();
},
});
return (
<div>
{data.map((item) => <Card key={item.id} {...item} />)}
<div ref={sentinelRef} />
{loading && <Spinner />}
{error && <button onClick={retry}>重试</button>}
{!hasMore && <p>没有更多了</p>}
</div>
);
}
Q8: passive 事件监听器在下拉刷新中的作用是什么?
答案:
背景:Chrome 56 起,为了优化滚动性能,将 touchstart 和 touchmove 的事件监听器默认设为 passive: true。这意味着浏览器 假定 回调不会调用 preventDefault(),因此不必等待回调执行完毕就开始滚动,消除了滚动延迟。
问题:下拉刷新组件必须在 touchmove 回调中调用 e.preventDefault() 来阻止浏览器默认的下拉行为(如 Chrome 自带刷新、iOS 橡皮筋回弹)。如果监听器是 passive 的,preventDefault() 会被忽略并报 warning。
// 错误:passive 模式下 preventDefault 无效
el.addEventListener('touchmove', handler); // 默认 passive: true
// 正确:显式声明 passive: false
el.addEventListener('touchmove', handler, { passive: false });
性能影响:passive: false 意味着浏览器必须等待回调执行完毕才能决定是否滚动,可能导致滚动延迟。因此应该 精确控制:
const handleTouchMove = (e: TouchEvent) => {
if (isAtTop && isPullingDown) {
// 只在需要下拉刷新时才调用 preventDefault
e.preventDefault();
// 处理下拉逻辑...
}
// 其他情况不调用 preventDefault,浏览器正常滚动
};
最佳实践:
touchstart用passive: true(不需要阻止默认行为)touchmove用passive: false(需要条件性地阻止默认行为)touchend用passive: true(不需要阻止默认行为)- 仅在确实需要下拉刷新时(列表在顶部 + 向下拖拽)才调用
preventDefault()
相关链接
内部文档
- 长列表优化 - 虚拟滚动、react-window 方案
- requestAnimationFrame - 帧率控制与动画驱动
- 事件机制 - 事件冒泡、捕获、passive
- React Hooks 原理 - useState/useEffect、闭包陷阱
- Feed 信息流系统 - 无限滚动与虚拟列表的综合应用
- 动画性能优化 - GPU 加速、合成层优化
外部文档
- MDN - Touch Events - Touch 事件完整 API
- MDN - IntersectionObserver - 交叉观察器 API
- MDN - overscroll-behavior - 禁止滚动链与弹性效果
- MDN - EventTarget.addEventListener: passive - passive 参数详解
- Chrome - Making touch scrolling fast by default - Chrome passive 默认行为说明
- antd-mobile PullToRefresh - antd-mobile 下拉刷新组件
- antd-mobile InfiniteScroll - antd-mobile 无限滚动组件