前端分页组件实战踩坑记性能优化与用户体验双管齐下
先说说我为什么写这篇文章
最近做项目又遇到分页组件了,说实话,这玩意儿看起来简单,但真要做得用户友好还是有不少坑的。之前我都是直接拿现成的UI库里的分页,但这次产品提了一些特殊需求,比如需要显示当前页码范围、自定义每页条数等等,搞了一圈发现还是得自己写一个比较灵活。
写完之后觉得有必要记录一下,毕竟分页这东西太常见了,每次都要重新造轮子确实麻烦。
基础版本,先跑起来再说
最基本的分页组件,其实逻辑很简单:计算总页数、生成页码按钮、处理点击事件。我先写个最简单的版本:
function Pagination({
total, // 总数据条数
current, // 当前页码
pageSize, // 每页条数
onChange // 页码变化回调
}) {
const totalPages = Math.ceil(total / pageSize);
// 生成页码数组
function generatePages() {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const pages = [];
// 总是显示第一页
pages.push(1);
// 显示省略号的情况
if (current > 4) {
pages.push('...');
}
// 显示当前页附近的页码
const start = Math.max(2, current - 2);
const end = Math.min(totalPages - 1, current + 2);
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (current < totalPages - 3) {
pages.push('...');
}
// 总是显示最后一页
if (totalPages > 1) {
pages.push(totalPages);
}
return pages;
}
return (
<div className="pagination">
<button
onClick={() => onChange(Math.max(1, current - 1))}
disabled={current === 1}
>
上一页
</button>
{generatePages().map((page, index) => (
<button
key={index}
onClick={() => typeof page === 'number' && onChange(page)}
className={page === current ? 'active' : ''}
disabled={typeof page !== 'number'}
>
{page}
</button>
))}
<button
onClick={() => onChange(Math.min(totalPages, current + 1))}
disabled={current === totalPages}
>
下一页
</button>
</div>
);
}
样式部分,看着舒服就行
CSS这块没啥特别的,主要是让分页按钮居中显示,当前页高亮:
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin: 20px 0;
}
.pagination button {
padding: 8px 12px;
border: 1px solid #ddd;
background: white;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
}
.pagination button:hover:not(:disabled) {
background: #f5f5f5;
}
.pagination button.active {
background: #007bff;
color: white;
border-color: #007bff;
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
踩坑提醒:这几点一定注意
边界条件处理:这是最容易出错的地方。比如总共只有1页,或者当前页超出范围。我在测试的时候就发现,当总页数小于等于7时,原来的逻辑会导致重复渲染某些页码。所以我在generatePages函数里加了个判断,只有总页数大于7才走复杂的省略逻辑。
页码输入验证:如果用户手动输入页码,一定要做验证:
// 验证页码有效性
function validatePage(page, totalPages) {
const num = parseInt(page);
if (isNaN(num) || num < 1 || num > totalPages) {
return 1; // 返回默认值
}
return num;
}
防抖处理:用户可能会疯狂点击页码按钮,这时候需要防抖:
import { debounce } from 'lodash';
const debouncedChange = debounce((page) => {
onChange(page);
}, 300);
// 在按钮点击事件中使用
onClick={() => typeof page === 'number' && debouncedChange(page)}
高级功能,让产品满意
刚才那个基础版本只能满足基本需求,产品经常会问能不能显示总数、能不能自定义每页条数。这些功能加起来也不是很复杂:
function AdvancedPagination({
total,
current,
pageSize = 10,
pageSizeOptions = [10, 20, 50, 100],
showQuickJumper = false,
showSizeChanger = false,
showTotal = true,
onChange,
onPageSizeChange
}) {
const totalPages = Math.ceil(total / pageSize);
// 显示总数据信息
const totalInfo = showTotal && (
<span className="pagination-total">
共 {total} 条,第 {current} / {totalPages} 页
</span>
);
// 页数选择器
const sizeSelector = showSizeChanger && (
<select
value={pageSize}
onChange={(e) => onPageSizeChange?.(parseInt(e.target.value))}
className="pagination-size"
>
{pageSizeOptions.map(size => (
<option key={size} value={size}>{size} 条/页</option>
))}
</select>
);
// 快速跳转
const quickJumper = showQuickJumper && (
<span className="pagination-jumper">
跳至
<input
type="number"
min="1"
max={totalPages}
defaultValue={current}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const targetPage = parseInt(e.target.value);
if (targetPage >= 1 && targetPage <= totalPages) {
onChange(targetPage);
}
}
}}
/>
页
</span>
);
return (
<div className="advanced-pagination">
{totalInfo}
<div className="pagination-controls">
{/* 前面的基础分页组件 */}
</div>
{sizeSelector}
{quickJumper}
</div>
);
}
性能优化的一些想法
大部分情况下,分页数据都是通过API获取的,这时候要考虑loading状态和错误处理:
function usePagination(apiCall, initialParams = {}) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0
});
const fetchData = async (params = {}) => {
setLoading(true);
try {
const result = await apiCall({
page: params.current || pagination.current,
limit: params.pageSize || pagination.pageSize,
...initialParams
});
setData(result.data);
setPagination(prev => ({
...prev,
...params,
total: result.total
}));
setError(null);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [JSON.stringify(initialParams)]);
return {
data,
loading,
error,
pagination,
onChange: (current, pageSize) => fetchData({ current, pageSize }),
refresh: () => fetchData()
};
}
这样封装后,在业务组件中就可以这么用了:
function ProductList() {
const { data, loading, error, pagination, onChange } = usePagination(
(params) => fetch('/api/products', params)
);
if (loading) return <div>加载中...</div>;
if (error) return <div>错误:{error}</div>;
return (
<div>
<ProductTable data={data} />
<AdvancedPagination
total={pagination.total}
current={pagination.current}
pageSize={pagination.pageSize}
onChange={onChange}
/>
</div>
);
}
实际项目中的使用感受
我在实际项目中用下来,发现几个需要注意的地方:
- 移动端的分页按钮要适当增大,方便点击
- 对于大数据量的列表,建议加上虚拟滚动,不然页面会卡
- 如果列表支持筛选,记得在筛选条件改变时重置页码为1
- 分页组件的状态管理如果比较复杂,建议用Redux或Context来统一管理
还有个小技巧,有时候后端返回的数据格式不太一样,可以在请求的时候做个适配器:
async function fetchProducts(params) {
const response = await fetch(https://jztheme.com/api/products, {
method: 'POST',
body: JSON.stringify(params)
});
const result = await response.json();
// 适配不同后端返回格式
return {
data: result.items || result.data,
total: result.count || result.total || result.totalCount
};
}
一些特殊场景的处理
有些业务场景比较特殊,比如表格组件内部的分页,可能需要和表格的状态联动。这时候建议把分页作为一个独立的子组件,通过props传递状态:
function TableWithPagination({ columns, dataSource, pagination }) {
return (
<div className="table-container">
<table>
{/* 表格内容 */}
</table>
<Pagination {...pagination} />
</div>
);
}
这样既保持了组件的独立性,又能在需要的时候轻松替换分页逻辑。
最后的思考
分页组件看似简单,但要做得好用还是有很多细节要考虑。特别是用户体验方面,比如页码过多时的省略策略、快速跳转的便捷性、当前页的视觉反馈等等。
我觉得一个好的分页组件应该具备这几个特点:易用、稳定、可定制、性能好。当然,具体怎么实现还是要根据项目需求来定,有时候简单粗暴的实现就够了,没必要一开始就搞得很复杂。
以上是我踩坑后的总结,希望能对你有帮助。这个技巧的拓展用法还有很多,后续会继续分享这类博客。

暂无评论