前端分页与瀑布流最佳实践笔记 - React Antd 版
前端分页与瀑布流最佳实践笔记 - React Antd 版
1. 分页与瀑布流对比
分页(Pagination) | 瀑布流(Infinite Scroll) | |
---|---|---|
展示方式 | 按页分批加载,有明确页码控件 | 滚动到底部时自动加载更多内容,无明显分页 |
用户控制 | 用户可主动翻页和跳转到任意页 | 用户只能通过滚动浏览,无法直接跳转 |
数据加载时机 | 用户点击翻页按钮时加载 | 滚动接近底部时自动加载 |
适用场景 | 数据量大且需精准定位(电商、后台管理系统) | 信息流浏览(社交媒体、图片墙) |
体验特点 | 更可控、易跳转,适合严肃场景 | 更流畅、自然,适合娱乐、轻松浏览 |
技术实现 | 通过current 和pageSize 参数请求 | 监听滚动事件,触发加载 |
核心区别:分页更适合管理和精准查找,瀑布流更适合浏览体验和沉浸式内容。
2. 分页实现 - React Antd
基本实现
import { Table, Pagination } from 'antd';
import { useState, useEffect } from 'react';
import axios from 'axios';function PaginatedTable() {const [data, setData] = useState([]);const [loading, setLoading] = useState(false);const [pagination, setPagination] = useState({current: 1,pageSize: 10,total: 0});const fetchData = async (params = {}) => {setLoading(true);try {const response = await axios.get('/api/items', {params: {page: params.current || pagination.current,pageSize: params.pageSize || pagination.pageSize}});setData(response.data.items);setPagination({...pagination,current: params.current || pagination.current,total: response.data.total});} catch (error) {console.error('Error fetching data:', error);} finally {setLoading(false);}};useEffect(() => {fetchData();}, []);const handleTableChange = (paginate) => {fetchData({current: paginate.current,pageSize: paginate.pageSize});};const columns = [// 定义列{ title: 'Name', dataIndex: 'name', key: 'name' },{ title: 'Age', dataIndex: 'age', key: 'age' },// ...其他列];return (<Tablecolumns={columns}dataSource={data}pagination={pagination}loading={loading}onChange={handleTableChange}rowKey="id"/>);
}
分页优化技巧
- 缓存已加载的页面数据
const [cachedData, setCachedData] = useState({});const fetchData = async (params = {}) => {const current = params.current || pagination.current;const pageSize = params.pageSize || pagination.pageSize;const cacheKey = `${current}-${pageSize}`;// 检查缓存中是否已有此页数据if (cachedData[cacheKey]) {setData(cachedData[cacheKey]);return;}setLoading(true);try {const response = await axios.get('/api/items', {params: { page: current, pageSize }});// 更新数据和缓存setData(response.data.items);setCachedData({...cachedData,[cacheKey]: response.data.items});setPagination({...pagination,current,total: response.data.total});} catch (error) {console.error('Error:', error);} finally {setLoading(false);}
};
- 预加载下一页数据
const prefetchNextPage = (current, pageSize) => {const nextPage = current + 1;const cacheKey = `${nextPage}-${pageSize}`;// 如果下一页已缓存或正在加载,则不预加载if (cachedData[cacheKey] || loading) return;// 静默加载下一页axios.get('/api/items', {params: { page: nextPage, pageSize }}).then(response => {setCachedData({...cachedData,[cacheKey]: response.data.items});}).catch(err => {console.error('Prefetch error:', err);});
};// 在数据加载完成后调用预加载
useEffect(() => {if (!loading && data.length > 0) {prefetchNextPage(pagination.current, pagination.pageSize);}
}, [data, loading]);
- 处理搜索和筛选
import { Table, Pagination, Input, Form, Button, Select } from 'antd';
import { debounce } from 'lodash';function EnhancedTable() {// ...之前的状态const [filters, setFilters] = useState({});// 使用防抖处理搜索const handleSearch = debounce((searchText) => {setFilters(prev => ({ ...prev, searchText }));fetchData({ current: 1 }); // 搜索时重置到第一页}, 300);const fetchData = async (params = {}) => {setLoading(true);try {const response = await axios.get('/api/items', {params: {page: params.current || pagination.current,pageSize: params.pageSize || pagination.pageSize,...filters // 添加所有筛选条件}});// 更新数据和分页信息// ...} catch (error) {console.error('Error:', error);} finally {setLoading(false);}};return (<><Form layout="inline" style={{ marginBottom: 16 }}><Form.Item label="搜索"><Input.Search placeholder="输入关键词" onSearch={value => handleSearch(value)}onChange={e => handleSearch(e.target.value)}/></Form.Item><Form.Item label="状态"><Selectplaceholder="选择状态"style={{ width: 120 }}onChange={value => {setFilters(prev => ({ ...prev, status: value }));fetchData({ current: 1 });}}allowClear><Select.Option value="active">活跃</Select.Option><Select.Option value="inactive">非活跃</Select.Option></Select></Form.Item></Form><Table// ...之前的Table属性/></>);
}
3. 瀑布流实现 - React Antd
基于Marker/Cursor的瀑布流
import { List, Spin, message } from 'antd';
import { useState, useEffect, useRef } from 'react';
import InfiniteScroll from 'react-infinite-scroll-component';
import axios from 'axios';function InfiniteScrollList() {const [data, setData] = useState([]);const [loading, setLoading] = useState(false);const [hasMore, setHasMore] = useState(true);const marker = useRef(null);const loadMoreData = async () => {if (loading || !hasMore) return;setLoading(true);try {const response = await axios.get('/api/items', {params: {limit: 20,marker: marker.current}});const newData = response.data.data;if (newData.length === 0) {setHasMore(false);} else {setData([...data, ...newData]);marker.current = response.data.marker;// 如果没有返回marker,说明没有更多数据了if (!response.data.marker) {setHasMore(false);}}} catch (error) {console.error('Error fetching data:', error);message.error('加载失败,请重试');setHasMore(false);} finally {setLoading(false);}};useEffect(() => {loadMoreData();}, []);return (<divid="scrollableDiv"style={{height: 400,overflow: 'auto',padding: '0 16px',border: '1px solid rgba(140, 140, 140, 0.35)'}}><InfiniteScrolldataLength={data.length}next={loadMoreData}hasMore={hasMore}loader={<Spin tip="加载中..." />}endMessage={<div style={{ textAlign: 'center' }}>没有更多数据了</div>}scrollableTarget="scrollableDiv"><ListdataSource={data}renderItem={item => (<List.Item key={item.id}><List.Item.Metatitle={item.title}description={item.description}/></List.Item>)}/></InfiniteScroll></div>);
}
虚拟列表优化
对于大量数据,可以结合虚拟列表以提高性能:
import { List, Avatar, Spin } from 'antd';
import { useState, useEffect, useRef } from 'react';
import VirtualList from 'rc-virtual-list';
import axios from 'axios';function VirtualizedInfiniteList() {const [data, setData] = useState([]);const [loading, setLoading] = useState(false);const [hasMore, setHasMore] = useState(true);const marker = useRef(null);const containerRef = useRef(null);const itemHeight = 47; // 每项的高度const loadMoreData = async () => {if (loading || !hasMore) return;setLoading(true);try {const response = await axios.get('/api/items', {params: {limit: 20,marker: marker.current}});const newData = response.data.data;if (newData.length === 0) {setHasMore(false);} else {setData(prevData => [...prevData, ...newData]);marker.current = response.data.marker;if (!response.data.marker) {setHasMore(false);}}} catch (error) {console.error('Error:', error);setHasMore(false);} finally {setLoading(false);}};useEffect(() => {loadMoreData();}, []);const onScroll = (e) => {if (e.target.scrollHeight - e.target.scrollTop <= e.target.clientHeight + 100) {loadMoreData();}};return (<List>{loading && data.length === 0 ? <Spin /> : null}<VirtualListdata={data}height={400}itemHeight={itemHeight}itemKey="id"onScroll={onScroll}ref={containerRef}>{(item) => (<List.Item key={item.id}><List.Item.Metaavatar={<Avatar src={item.avatar} />}title={item.title}description={item.description}/></List.Item>)}</VirtualList>{loading && data.length > 0 ? (<div style={{ textAlign: 'center', padding: '12px 0' }}><Spin /></div>) : null}</List>);
}
4. 优化策略总结
性能优化
-
虚拟滚动:仅渲染可见区域内的元素,大幅提升性能
// 使用React Window (安装: npm install react-window) import { FixedSizeList as List } from 'react-window';const Row = ({ index, style }) => (<div style={style}>Row {index}</div> );const VirtualizedList = () => (<Listheight={400}itemCount={1000}itemSize={35}width={300}>{Row}</List> );
-
数据预取:提前加载下一页数据,提升用户体验
// 当用户滚动到接近底部时预加载 const handleScroll = (e) => {const { scrollTop, scrollHeight, clientHeight } = e.target;// 当滚动到距离底部20%的位置时预加载if (scrollTop > (scrollHeight - clientHeight) * 0.8) {prefetchNextData();} };
-
请求优化:使用防抖和节流控制请求频率
import { debounce, throttle } from 'lodash';// 防抖:用于搜索输入 const debouncedSearch = debounce((value) => {fetchData(value); }, 300);// 节流:用于滚动事件 const throttledScroll = throttle((e) => {handleScroll(e); }, 200);
用户体验优化
-
加载状态反馈
// 使用骨架屏代替简单的加载指示器 import { Skeleton } from 'antd';{loading ? (<Skeleton active paragraph={{ rows: 4 }} /> ) : (<ContentComponent data={data} /> )}
-
错误处理与重试
const [error, setError] = useState(null);const fetchData = async () => {setLoading(true);setError(null);try {// 数据请求...} catch (err) {setError(err.message || '加载失败');} finally {setLoading(false);} };// 在UI中展示错误信息和重试按钮 {error && (<div className="error-container"><p>{error}</p><Button onClick={fetchData}>重试</Button></div> )}
-
记住滚动位置
// 保存滚动位置到sessionStorage const saveScrollPosition = () => {sessionStorage.setItem('scrollPosition', container.current.scrollTop); };// 组件卸载前保存位置 useEffect(() => {return () => {saveScrollPosition();}; }, []);// 组件挂载时恢复位置 useEffect(() => {const savedPosition = sessionStorage.getItem('scrollPosition');if (savedPosition && container.current) {container.current.scrollTop = parseInt(savedPosition);} }, []);
5. 最佳实践
分页场景选择
- 传统分页(页码导航)适用场景:
- 后台管理系统
- 数据表格/数据列表展示
- 需要精确定位到特定页面的场景
- 数据总量明确的场景
- 瀑布流/无限滚动适用场景:
- 社交媒体信息流
- 图片/卡片展示墙
- 新闻阅读页面
- 产品类目浏览
- 强调浏览体验的场景
实现决策树
选择分页方式
├── 需要精确页码跳转?
│ ├── 是 → 使用传统分页 (Pagination)
│ │ └── 数据量大?
│ │ ├── 是 → 启用虚拟滚动
│ │ └── 否 → 使用标准Table组件
│ └── 否 → 考虑瀑布流/无限滚动
│ ├── 需要保持列表位置?
│ │ ├── 是 → 使用marker/cursor分页
│ │ └── 否 → 可使用offset分页
│ └── 列表项数量可能很大?
│ ├── 是 → 必须启用虚拟滚动
│ └── 否 → 可以使用简单InfiniteScroll
6. React Antd 常用组件示例
完整的Table分页组件(含搜索、筛选、缓存)
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Table, Input, Button, Form, Select, Space, message, Card } from 'antd';
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons';
import axios from 'axios';
import { debounce } from 'lodash';const AdvancedPaginationTable = () => {// 状态管理const [data, setData] = useState([]);const [loading, setLoading] = useState(false);const [pagination, setPagination] = useState({current: 1,pageSize: 10,total: 0,showSizeChanger: true,pageSizeOptions: ['10', '20', '50', '100'],showTotal: (total) => `共 ${total} 条数据`});const [filters, setFilters] = useState({});const [cacheData, setCacheData] = useState({});const [form] = Form.useForm();// 生成缓存键const getCacheKey = useCallback((page, pageSize, currentFilters) => {const filtersKey = Object.keys(currentFilters).sort().map(key => `${key}:${currentFilters[key]}`).join('|');return `${page}-${pageSize}-${filtersKey}`;}, []);// 处理数据获取const fetchData = useCallback(async (params = {}) => {const current = params.current || pagination.current;const pageSize = params.pageSize || pagination.pageSize;const currentFilters = params.filters || filters;// 检查缓存中是否有数据const cacheKey = getCacheKey(current, pageSize, currentFilters);if (cacheData[cacheKey]) {setData(cacheData[cacheKey].data);setPagination(prev => ({...prev,current,pageSize,total: cacheData[cacheKey].total}));return;}setLoading(true);try {const response = await axios.get('/api/items', {params: {page: current,pageSize,...currentFilters}});const newData = response.data.items || [];const total = response.data.total || 0;setData(newData);setPagination(prev => ({...prev,current,pageSize,total}));// 更新缓存setCacheData(prev => ({...prev,[cacheKey]: { data: newData, total }}));// 预加载下一页if (current < Math.ceil(total / pageSize)) {prefetchNextPage(current, pageSize, currentFilters);}} catch (error) {console.error('Failed to fetch data:', error);message.error('获取数据失败,请重试');} finally {setLoading(false);}}, [pagination, filters, cacheData, getCacheKey]);// 预加载下一页数据const prefetchNextPage = useCallback((current, pageSize, currentFilters) => {const nextPage = current + 1;const cacheKey = getCacheKey(nextPage, pageSize, currentFilters);// 已缓存则不再请求if (cacheData[cacheKey]) return;// 后台静默加载,不影响当前UIaxios.get('/api/items', {params: {page: nextPage,pageSize,...currentFilters}}).then(response => {setCacheData(prev => ({...prev,[cacheKey]: { data: response.data.items || [], total: response.data.total || 0 }}));}).catch(err => {console.error('预加载下一页失败:', err);});}, [cacheData, getCacheKey]);// 防抖搜索处理const handleSearch = debounce((values) => {const newFilters = {};Object.entries(values).forEach(([key, value]) => {if (value !== undefined && value !== '') {newFilters[key] = value;}});setFilters(newFilters);// 搜索时重置到第一页fetchData({ current: 1, filters: newFilters });}, 300);// 重置筛选const handleReset = () => {form.resetFields();setFilters({});fetchData({ current: 1, filters: {} });};// 处理表格变更 (分页、筛选、排序)const handleTableChange = (pagination, filters, sorter) => {fetchData({current: pagination.current,pageSize: pagination.pageSize,filters: {...form.getFieldsValue(),// 添加表格内置筛选...(filters && Object.keys(filters).length > 0 ? Object.fromEntries(Object.entries(filters).filter(([_, value]) => value && value.length > 0).map(([key, value]) => [key, value.join(',')])) : {}),// 添加排序...(sorter.field ? {sortField: sorter.field,sortOrder: sorter.order} : {})}});};// 初始加载useEffect(() => {fetchData();}, []);// 表格列定义const columns = [{title: 'ID',dataIndex: 'id',key: 'id',width: 80,sorter: true},{title: '名称',dataIndex: 'name',key: 'name',sorter: true,defaultSortOrder: 'ascend'},{title: '状态',dataIndex: 'status',key: 'status',filters: [{ text: '活跃', value: 'active' },{ text: '非活跃', value: 'inactive' }],render: status => (<span style={{ color: status === 'active' ? 'green' : 'gray'}}>{status === 'active' ? '活跃' : '非活跃'}</span>)},{title: '创建时间',dataIndex: 'createdAt',key: 'createdAt',sorter: true},{title: '操作',key: 'action',render: (_, record) => (<Space size="middle"><a onClick={() => console.log('查看', record)}>查看</a><a onClick={() => console.log('编辑', record)}>编辑</a></Space>)}];return (<Card><Formform={form}layout="inline"style={{ marginBottom: 16 }}onFinish={handleSearch}><Form.Item name="keyword" label="关键词"><Input placeholder="搜索名称"prefix={<SearchOutlined />}allowClearonChange={e => form.submit()}/></Form.Item><Form.Item name="dateRange" label="日期范围"><Selectstyle={{ width: 200 }}placeholder="选择日期范围"allowClearonChange={() => form.submit()}><Select.Option value="today">今天</Select.Option><Select.Option value="week">最近一周</Select.Option><Select.Option value="month">最近一个月</Select.Option></Select></Form.Item><Form.Item><Button icon={<ReloadOutlined />} onClick={handleReset}>重置</Button></Form.Item></Form><Tablecolumns={columns}dataSource={data}rowKey="id"pagination={pagination}loading={loading}onChange={handleTableChange}scroll={{ x: 800 }}/></Card>);
};export default AdvancedPaginationTable;
高性能瀑布流组件(虚拟滚动+Marker分页)
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { List, Card, Avatar, Spin, Empty, Button, message } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import VirtualList from 'rc-virtual-list';
import axios from 'axios';
import { throttle } from 'lodash';const ITEM_HEIGHT = 180; // 每个卡片高度
const CONTAINER_HEIGHT = 600; // 容器高度
const BATCH_SIZE = 15; // 每次加载条数const HighPerformanceInfiniteScroll = () => {const [data, setData] = useState([]);const [loading, setLoading] = useState(false);const [initialLoading, setInitialLoading] = useState(true);const [error, setError] = useState(null);const [hasMore, setHasMore] = useState(true);const markerRef = useRef(null);const containerRef = useRef(null);// 加载数据的核心函数const loadData = useCallback(async (reset = false) => {// 如果已在加载或没有更多数据,则跳过if ((loading && !reset) || (!hasMore && !reset)) return;try {setLoading(true);if (reset) {setInitialLoading(true);setError(null);markerRef.current = null;}const response = await axios.get('/api/feed', {params: {limit: BATCH_SIZE,marker: reset ? null : markerRef.current}});const { items, marker } = response.data;if (items && items.length > 0) {setData(prevData => reset ? items : [...prevData, ...items]);markerRef.current = marker;setHasMore(!!marker); // 如果没有返回marker,表示没有更多数据} else {setHasMore(false);}} catch (err) {console.error('Failed to fetch data:', err);setError('加载数据失败,请重试');setHasMore(false);} finally {setLoading(false);setInitialLoading(false);}}, [loading, hasMore]);// 初始加载useEffect(() => {loadData(true);}, []);// 保存滚动位置useEffect(() => {const saveScrollPosition = () => {if (containerRef.current) {sessionStorage.setItem('infiniteScrollPos', containerRef.current.scrollTop);}};// 页面关闭或组件卸载时保存位置window.addEventListener('beforeunload', saveScrollPosition);return () => {saveScrollPosition();window.removeEventListener('beforeunload', saveScrollPosition);};}, []);// 恢复滚动位置useEffect(() => {const savedPosition = sessionStorage.getItem('infiniteScrollPos');if (savedPosition && containerRef.current && data.length > 0) {setTimeout(() => {containerRef.current.scrollTop = parseInt(savedPosition);}, 100);}}, [initialLoading, data.length]);// 节流处理的滚动事件const onScroll = throttle(e => {if (e.target.scrollHeight - e.target.scrollTop