跳到主要内容

设计移动端上拉加载和下拉刷新

问题

如何从零设计一个移动端的上拉加载更多(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 核心实现

components/PullRefresh.tsx
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>
);
}
为什么使用 transform 而不是 top/margin?

topmargin 的变化会触发浏览器的重排(Layout),而 transform 只触发合成(Composite),由 GPU 直接处理,性能差异巨大。在 60fps 的要求下,每帧只有约 16ms 的预算,重排很容易导致掉帧。详见 动画性能优化

2.3 阻尼效果详解

阻尼效果的目的是让用户越往下拉,感觉越"吃力",提供真实的物理反馈:

utils/damping.ts
// 方案一:幂函数(推荐)
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 性能优势

IntersectionObserver 在浏览器主线程之外异步计算元素可见性,不会阻塞主线程。而 scroll 事件的回调在主线程执行,如果回调中包含 getBoundingClientRect() 等操作还会强制触发重排。详见 事件机制

3.2 IntersectionObserver 方案(推荐)

components/InfiniteScroll.tsx
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 事件方案(兜底)

hooks/useScrollLoad.ts
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]);
}
requestAnimationFrame 节流

传统的 setTimeout / throttle 节流无法精确对齐浏览器渲染帧,而 requestAnimationFrame 可以确保回调在浏览器下一帧绘制前执行,是动画和滚动场景的最佳节流方案。


四、手势处理细节

4.1 passive 事件监听器

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?

在移动端浏览器中,手指下拉默认会触发浏览器自带的「下拉刷新」或「橡皮筋回弹」效果。如果不调用 preventDefault(),自定义的下拉刷新组件会与浏览器默认行为冲突,出现"双重下拉"的问题。

4.2 方向判断

实际场景中,用户的手指很少纯垂直滑动,需要区分垂直和水平方向:

utils/direction.ts
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 SafariAndroid 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 scrollTop 负值处理

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 不要滥用

will-change 会让浏览器提前分配 GPU 内存创建合成层,如果所有元素都加上会导致内存暴增。正确做法是只在动画进行中设置,动画结束后移除(设为 auto)。

5.3 与虚拟列表结合

在大数据量场景下,上拉加载会不断追加数据,列表越来越长。当数据量超过几百条时,需要引入 虚拟列表 来保证滚动性能:

VirtualPullRefreshList.tsx
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-windowVariableSizeList 配合 预估高度 + 动态修正 策略。这比固定高度方案复杂得多,需要在 itemSize 回调中返回已知高度或预估值,并在渲染后通过 ResizeObserver 修正。


六、与虚拟列表结合的深入讨论

6.1 挑战

挑战说明
滚动容器选择虚拟列表自身管理滚动容器,下拉刷新也需要控制滚动容器,两者可能冲突
scrollTop 判断下拉刷新需要判断 scrollTop <= 0,但虚拟列表的滚动容器是内部管理的
数据追加时的滚动位置上拉加载新数据后,虚拟列表需要正确维护滚动位置
动态高度不定高列表项需要额外的高度缓存和修正机制

6.2 集成策略

关键点:通过 react-windowouterRef 获取真实滚动容器的 DOM 引用,将其传递给下拉刷新组件用于判断滚动位置。


七、Hook 封装

7.1 usePullRefresh

hooks/usePullRefresh.ts
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

hooks/useInfiniteScroll.ts
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 组合使用

pages/FeedPage.tsx
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-mobilevant(Vue)react-pull-to-refresh自研方案
框架ReactVue 3React任意
下拉刷新PullToRefreshPullRefreshReactPullToRefresh自定义
上拉加载InfiniteScrollList无(需自行实现)自定义
虚拟列表VirtualList需搭配需自行集成
阻尼效果内置内置基础可定制
自定义指示器支持支持有限完全自定义
TypeScript原生支持原生支持类型不完善完全可控
包大小~5KB(按需)~4KB(按需)~2KB0(无额外依赖)
选型建议
  • 业务项目:优先使用 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)
IntersectionObserver 方案
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~72px144%
100px~104px104%
150px~131px87%
200px~200px × 0.6^0.6 ≈ 152px76%

可以看到,初始阶段偏移甚至略大于下拉距离(给人"跟手"的感觉),后期增长明显变慢。

其他可选函数:

  • 对数函数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;
}
};
为什么用 useRef 而不是 useState?

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 而非 innerRefouterRef 指向有 overflow: auto 的滚动容器
  • 虚拟列表方案详见 长列表优化

Q7: 如何用 React Hook 封装一个通用的 useInfiniteScroll?

答案

一个通用的 useInfiniteScroll 应满足以下设计目标:

特性说明
IntersectionObserver首选方案,性能好
自动加载进入视口自动触发,无需手动调用
防重复内置 loading 锁
错误处理加载失败时提供 retry 方法
可配置threshold、root、rootMargin 可定制
hooks/useInfiniteScroll.ts
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 起,为了优化滚动性能,将 touchstarttouchmove 的事件监听器默认设为 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,浏览器正常滚动
};

最佳实践

  1. touchstartpassive: true(不需要阻止默认行为)
  2. touchmovepassive: false(需要条件性地阻止默认行为)
  3. touchendpassive: true(不需要阻止默认行为)
  4. 仅在确实需要下拉刷新时(列表在顶部 + 向下拖拽)才调用 preventDefault()

相关链接

内部文档

外部文档