当前位置: 首页 > news >正文

前端分页与瀑布流最佳实践笔记 - React Antd 版

前端分页与瀑布流最佳实践笔记 - React Antd 版

1. 分页与瀑布流对比

分页(Pagination)瀑布流(Infinite Scroll)
展示方式按页分批加载,有明确页码控件滚动到底部时自动加载更多内容,无明显分页
用户控制用户可主动翻页和跳转到任意页用户只能通过滚动浏览,无法直接跳转
数据加载时机用户点击翻页按钮时加载滚动接近底部时自动加载
适用场景数据量大且需精准定位(电商、后台管理系统)信息流浏览(社交媒体、图片墙)
体验特点更可控、易跳转,适合严肃场景更流畅、自然,适合娱乐、轻松浏览
技术实现通过currentpageSize参数请求监听滚动事件,触发加载

核心区别:分页更适合管理和精准查找,瀑布流更适合浏览体验和沉浸式内容。

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"/>);
}

分页优化技巧

  1. 缓存已加载的页面数据
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);}
};
  1. 预加载下一页数据
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]);
  1. 处理搜索和筛选
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. 优化策略总结

性能优化

  1. 虚拟滚动:仅渲染可见区域内的元素,大幅提升性能

    // 使用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>
    );
    
  2. 数据预取:提前加载下一页数据,提升用户体验

    // 当用户滚动到接近底部时预加载
    const handleScroll = (e) => {const { scrollTop, scrollHeight, clientHeight } = e.target;// 当滚动到距离底部20%的位置时预加载if (scrollTop > (scrollHeight - clientHeight) * 0.8) {prefetchNextData();}
    };
    
  3. 请求优化:使用防抖和节流控制请求频率

    import { debounce, throttle } from 'lodash';// 防抖:用于搜索输入
    const debouncedSearch = debounce((value) => {fetchData(value);
    }, 300);// 节流:用于滚动事件
    const throttledScroll = throttle((e) => {handleScroll(e);
    }, 200);
    

用户体验优化

  1. 加载状态反馈

    // 使用骨架屏代替简单的加载指示器
    import { Skeleton } from 'antd';{loading ? (<Skeleton active paragraph={{ rows: 4 }} />
    ) : (<ContentComponent data={data} />
    )}
    
  2. 错误处理与重试

    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>
    )}
    
  3. 记住滚动位置

    // 保存滚动位置到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. 最佳实践

分页场景选择

  1. 传统分页(页码导航)适用场景
    • 后台管理系统
    • 数据表格/数据列表展示
    • 需要精确定位到特定页面的场景
    • 数据总量明确的场景
  2. 瀑布流/无限滚动适用场景
    • 社交媒体信息流
    • 图片/卡片展示墙
    • 新闻阅读页面
    • 产品类目浏览
    • 强调浏览体验的场景

实现决策树

选择分页方式
├── 需要精确页码跳转?
│   ├── 是 → 使用传统分页 (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

相关文章:

  • ADC读取异常情况汇总
  • pcm数据不支持存储在json里面,需要先转base64
  • 机器学习——Seaborn练习题
  • 怎样给MP3音频重命名?是时候管理下电脑中的音频文件名了
  • 月之暗面开源-音频理解、生成和对话生成模型:Kimi-Audio-7B-Instruct
  • 【Java面试笔记:进阶】23.请介绍类加载过程,什么是双亲委派模型?
  • 第二章、在Windows上部署Dify:从修仙小说到赛博飞升的硬核指南
  • AI在医疗领域的10大应用:从疾病预测到手术机器人
  • madvise MADV_FREE对文件页统计的影响及原理
  • Java求职面试:从Spring Boot到微服务架构的全面解析
  • NGINX upstream、stream、四/七层负载均衡以及案例示例
  • qt编译报错error: ‘VideoSrcCtrl‘ does not name a type
  • vue中将html2canvas转成的图片传递给后台java
  • idea软件配置移动到D盘
  • 20250427在ubuntu16.04.7系统上编译NanoPi NEO开发板的FriendlyCore系统解决问题mkimage not found
  • Jetpack Compose多布局实现:状态驱动与自适应UI设计全解析
  • 数字巴别塔:全栈多模态开发框架如何用自然语言重构软件生产关系?
  • 基于单片机的智能药盒系统
  • 树莓派超全系列教程文档--(43)树莓派内核简介及更新
  • django admin AttributeError: ‘UserResorce‘ object has no attribute ‘ID‘
  • 伊朗港口爆炸已致46人死亡
  • 外交部回应涉长江和记出售巴拿马运河港口交易:望有关各方审慎行事,充分沟通
  • 朝鲜证实出兵俄罗斯协助收复库尔斯克
  • 保时捷中国研发中心落户上海虹桥商务区,计划下半年投入运营
  • 上海虹桥至福建三明直飞航线开通,飞行时间1小时40分
  • 第三款在美获批的国产PD-1肿瘤药来了,影响多大?