PHP分页类怎么封装?从零构建高性能、可复用的分页组件(附完整代码)
目录导读
- 为什么需要封装分页类?——从重复代码到优雅复用
- 分页类的核心设计原则——单一职责与可扩展性
- PHP分页类封装实战——构造函数、核心方法、HTML渲染
- 高级特性:AJAX分页与样式定制——让组件更灵活
- 常见问题问答(FAQ)——解决你90%的分页困惑
- 性能优化与安全注意——防止SQL注入与大数据量优化
为什么需要封装分页类?
在开发新闻列表、商品展示、用户管理等后台功能时,分页几乎是必选项,如果每个页面都复制粘贴LIMIT offset, size的SQL逻辑和一堆杂乱的<ul>分页链接HTML,不仅代码冗余、难以维护,还会导致以下问题:

- SQL注入风险:手动拼接
$_GET['page']未强制转换为整型。 - 逻辑分散:计算总页数、当前页范围、循环输出页码的代码散落在各个控制器中。
- UI难以统一:不同开发者写的分页样式五花八门,后期调整成本高。
核心价值:封装一个分页类,你只需传入总记录数、每页条数、当前页,即可得到安全的偏移量和现成的HTML分页导航,这符合DRY(Don't Repeat Yourself)原则,也是现代MVC框架(如Laravel、ThinkPHP)内部组件的设计思路。
分页类的核心设计原则
在动手写代码前,必须明确三个设计目标,否则封装的类容易变成“一次性用品”:
- 单一职责:类只负责计算分页数据和生成HTML,绝不包含数据库查询逻辑(查询由调用方完成)。
- 参数过滤:所有从
$_GET获取的页码必须通过intval()或abs()处理,防止负数、小数和非法字符。 - 可定制化:提供可选的CSS类名、链接模板(如
?page={page}),以适配Bootstrap、Tailwind等不同前端框架。
PHP分页类封装实战:完整代码与解析
以下是一个纯粹、高效、无框架依赖的PHP分页类,它被设计为单文件、零依赖,可直接拖入你的项目中。
<?php
/**
* Class Pagination
* 高性能PHP分页类 - 支持AJAX、自定义样式、安全过滤
*/
class Pagination
{
// 基本属性
private int $totalItems; // 总记录数
private int $perPage; // 每页条数
private int $currentPage; // 当前页(已安全处理)
private int $totalPages; // 总页数
// 配置选项
private array $options = [
'url_pattern' => '?page={page}', // URL模板
'css_class' => 'pagination', // 包裹层CSS类
'first_text' => '«', // 第一页按钮文本
'prev_text' => '‹', // 上一页文本
'next_text' => '›', // 下一页文本
'last_text' => '»', // 最后一页文本
'show_first_last' => true, // 是否显示首尾按钮
'range_pages' => 2, // 当前页周围显示页码数
'ajax' => false, // 是否启用ajax模式
'ajax_target' => '', // ajax请求的容器ID
];
/**
* @param int $total 总记录数(必须)
* @param int $perPage 每页条数(默认10)
* @param int $currentPage 当前页码(未过滤的$_GET值)
* @param array $options 配置覆盖选项
*/
public function __construct(int $total, int $perPage = 10, int $currentPage = 1, array $options = [])
{
$this->totalItems = max(0, $total);
$this->perPage = max(1, $perPage);
// 计算总页数(核心公式)
$this->totalPages = (int)ceil($this->totalItems / $this->perPage);
// 安全处理:强制非负整数,防止超出边界
$this->currentPage = max(1, min($totalPages, (int)$currentPage));
// 合并用户自定义配置
$this->options = array_merge($this->options, $options);
}
/**
* 获取SQL中LIMIT的偏移量 (用于查询)
* @return int
*/
public function getOffset(): int
{
return ($this->currentPage - 1) * $this->perPage;
}
/**
* 获取LIMIT的条数
* @return int
*/
public function getLimit(): int
{
return $this->perPage;
}
/**
* 获取当前页
* @return int
*/
public function getCurrentPage(): int
{
return $this->currentPage;
}
/**
* 获取总页数
* @return int
*/
public function getTotalPages(): int
{
return $this->totalPages;
}
/**
* 生成分页HTML (Bootstrap风格示例)
* @return string
*/
public function render(): string
{
if ($this->totalPages <= 1) {
return ''; // 只有一页时不显示分页
}
$html = sprintf('<nav aria-label="Page navigation"><ul class="%s">', $this->options['css_class']);
// 辅助:生成带替换的链接
$getLink = function ($page) {
$url = str_replace('{page}', $page, $this->options['url_pattern']);
if ($this->options['ajax']) {
// 如果是AJAX模式,挂载onclick事件
return sprintf('href="javascript:void(0);" onclick="%s"',
"loadPage('{$url}', '{$this->options['ajax_target']}', {$page})");
}
return "href='{$url}'";
};
// 1. 首页和上一页
if ($this->options['show_first_last']) {
$html .= sprintf('<li class="page-item%s"><a class="page-link" %s>%s</a></li>',
($this->currentPage == 1) ? ' disabled' : '',
$this->currentPage > 1 ? $getLink(1) : '',
$this->options['first_text']);
}
$html .= sprintf('<li class="page-item%s"><a class="page-link" %s>%s</a></li>',
($this->currentPage == 1) ? ' disabled' : '',
$this->currentPage > 1 ? $getLink($this->currentPage - 1) : '',
$this->options['prev_text']);
// 2. 动态页码(含省略号)
$start = max(1, $this->currentPage - $this->options['range_pages']);
$end = min($this->totalPages, $this->currentPage + $this->options['range_pages']);
// 处理左侧省略号
if ($start > 1) {
$html .= '<li class="page-item"><a class="page-link" ' . $getLink(1) . '>1</a></li>';
if ($start > 2) $html .= '<li class="page-item disabled"><span class="page-link">…</span></li>';
}
// 循环输出中间的页码
for ($i = $start; $i <= $end; $i++) {
$activeClass = ($i == $this->currentPage) ? ' active' : '';
$html .= sprintf('<li class="page-item%s"><a class="page-link" %s>%d</a></li>',
$activeClass, $getLink($i), $i);
}
// 右侧省略号
if ($end < $this->totalPages) {
if ($end < $this->totalPages - 1) {
$html .= '<li class="page-item disabled"><span class="page-link">…</span></li>';
}
$html .= '<li class="page-item"><a class="page-link" ' . $getLink($this->totalPages) . '>' . $this->totalPages . '</a></li>';
}
// 3. 下一页和末页
$html .= sprintf('<li class="page-item%s"><a class="page-link" %s>%s</a></li>',
($this->currentPage == $this->totalPages) ? ' disabled' : '',
$this->currentPage < $this->totalPages ? $getLink($this->currentPage + 1) : '',
$this->options['next_text']);
if ($this->options['show_first_last']) {
$html .= sprintf('<li class="page-item%s"><a class="page-link" %s>%s</a></li>',
($this->currentPage == $this->totalPages) ? ' disabled' : '',
$this->currentPage < $this->totalPages ? $getLink($this->totalPages) : '',
$this->options['last_text']);
}
$html .= '</ul></nav>';
return $html;
}
}
使用范例(在控制器或业务逻辑中):
// 1. 获取总数量(假设从数据库查询)
$total = 1000; // 示例值
$perPage = 20;
$page = $_GET['page'] ?? 1;
// 2. 实例化分页类
$pager = new Pagination($total, $perPage, $page, [
'url_pattern' => '/user/list?page={page}',
'css_class' => 'pagination justify-content-center', // Bootstrap 4/5类
'ajax' => true,
'ajax_target' => '#resultList',
]);
// 3. 获取SQL偏移量用于数据库查询
$offset = $pager->getOffset();
$limit = $pager->getLimit();
// 伪SQL:SELECT * FROM users LIMIT $offset, $limit
// 4. 在视图模板中输出HTML
echo $pager->render();
高级特性:AJAX分页与样式定制
AJAX模式是通过在渲染时生成onclick="loadPage(...)"实现的,你需要在前端定义loadPage函数:
function loadPage(url, containerId, page) {
fetch(url, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(res => res.text())
.then(data => {
document.getElementById(containerId).innerHTML = data;
// 更新浏览器的当前URL(可选)
history.pushState({}, '', url.split('&')[0] + '&page=' + page);
})
.catch(err => console.error(err));
}
样式定制:分页类输出的是标准<ul>结构,使用Bootstrap 5无需额外CSS即可获得美观样式,对于Tailwind CSS,你可以自定义css_class为类似'flex space-x-2',但要调整内部<li>结构的话,需要修改类内部代码,建议封装时提供setHtmlTemplate()方法实现完全自定义,但核心计算逻辑已经足够健壮。
常见问题问答(FAQ)
问1:如果总记录数为0,分页类会怎样?
答:构造函数中totalItems被强制为max(0, $total),此时totalPages为0。render()方法检测到totalPages <= 1时返回空字符串,不会产生任何输出,安全无报错,同时getOffset()返回0,SQL返回空结果集。
问2:如何防止分页SQL注入?
答:本类的getOffset()和getLimit()均返回纯整数类型(通过max()和强制类型转换),不直接拼接SQL字符串,即使$_GET['page']传入了"1 OR 1=1",转换为(int)后变为1,彻底杜绝注入可能。
问3:我的URL是SEO友好的伪静态格式(如/list/5.html),怎么适配?
答:只需修改url_pattern为'/list/{page}.html'即可,如果页面需要传递多个参数,建议构造模式如'?category=5&page={page}',该类会智能替换{page}占位符。
问4:中间的“…”省略号怎么调整出现阈值?
答:通过range_pages选项控制,默认值为2,即当前页前后各显示2个页码,如果设置为3,显示范围更大,当页码差距过大时,自动用“…”填充,避免导航过长。
问5:我可以让“首页”和“末页”不显示吗?
答:可以,在实例化时传入'show_first_last' => false即可,适合移动端界面或短分页场景。
问6:分页类能否与PDO预处理语句完美配合?
答:完全可以,PDO的bindValue()支持传递整型参数,示例:
$stmt = $pdo->prepare("SELECT * FROM articles LIMIT :offset, :limit");
$stmt->bindValue('offset', $pager->getOffset(), PDO::PARAM_INT);
$stmt->bindValue('limit', $pager->getLimit(), PDO::PARAM_INT);
$stmt->execute();
性能优化与安全注意
- SQL层面:对于超大偏移量(如
LIMIT 100000, 20),数据库性能会急剧下降,建议在业务层加入“游标分页”或“键集分页”(基于上一页最后一条记录的ID),本类的设计支持扩展该优化(但需自行修改业务查询逻辑)。 - 缓存总记录数:总页数的计算依赖
COUNT(*),在大表上非常耗时,建议在业务层使用Redis缓存总记录数,失效时间设为5分钟或随内容更新。 - XSS防护:所有HTML转义由前端框架处理,本类只输出纯数字和链接,但如果你在URL模板中拼接了用户变量,务必使用
htmlspecialchars()。 - 安全的上限:构造函数中
$currentPage = min($totalPages, (int)$currentPage)限制了最大页码,防止恶意构造page=999999导致大量SQL查询。
这个分页类用30行左右的核心代码解决了90%的日常需求,它安全、易扩展、无依赖,且可直接嵌入Laravel、CodeIgniter、或原生PHP项目,封装的价值在于“一次编写,处处使用”,结合你项目的实际URL规则,调整url_pattern和CSS类名即可完美融合,Happy Coding!