学完这篇你能得到一个可直接复制使用的 PHP 分页类:它会算好总页数、offset 和页码窗口,拼出带省略号的页码条,并且和 SQL 的 LIMIT 查询、前端 CSS 一整套配合起来。
第一步:先定清楚分页类要干什么
分页组件其实只解决两件事:给数据库算 offset(后端),给用户画页码条(前端)。所以在动手写代码前,先把输入输出列出来:
- 输入:总记录数 `total`、每页条数 `perPage`、当前页 `current`、URL 模板
- 输出:`offset`、`lastPage`、规范化后的 `current`、一段 HTML 页码条
`total` 必须单独用 `SELECT COUNT()` 查,不要用 `SELECT ` 再数数组长度,数据量一大就会拖慢页面。
第二步:写核心类,重点是边界处理
class Paginator
{
public int $total;
public int $perPage;
public int $current;
public int $lastPage;
public int $offset;
public function __construct(int $total, int $perPage = 20, int $current = 1)
{
$this->total = max(0, $total);
$this->perPage = max(1, $perPage);
$this->lastPage = max(1, (int)ceil($this->total / $this->perPage));
// 当前页夹在 1 ~ 最后一页之间,防止手改 URL 越界
$this->current = min(max(1, $current), $this->lastPage);
$this->offset = ($this->current - 1) * $this->perPage;
}
}
这几行 `max/min` 是整个类最值钱的部分。用户手输 `?page=99999` 或 `?page=-1` 时,页面不会空数据也不会报错。
注意:`perPage` 不能直接读 `$_GET['per_page']`。一定要用白名单过一遍,比如 `in_array($n, [10,20,50], true)`,否则有人传 `per_page=100000`,等于给数据库上一记重锤。
第三步:生成页码窗口(带省略号)
全量列出 500 个页码没人看,主流做法是「首页 + 当前页附近 + 末页 + 省略号」:
public function pages(int $side = 2): array
{
if ($this->lastPage <= 1) return [1];
$from = max(2, $this->current - $side);
$to = min($this->lastPage - 1, $this->current + $side);
$out = [1];
if ($from > 2) $out[] = '...';
for ($i = $from; $i <= $to; $i++) $out[] = $i;
if ($to < $this->lastPage - 1) $out[] = '...';
if ($this->lastPage > 1) $out[] = $this->lastPage;
return $out;
}
第四步:安全地拼 URL
跳页链接最怕两件事:丢掉原有的筛选条件、参数没转义。用 `http_build_query` 一次性解决:
public function url(int $page): string
{
$q = $_GET;
$q['page'] = $page;
return '?' . http_build_query($q); // 自动 urlencode,中文关键词也不会断
}
注意:不要写 `'?cat='.$_GET['cat'].'&page='.$page` 这种拼接。用户搜「PHP 教程」时,空格和中文会直接让链接失效;更糟的是把未转义的输入回显进 HTML,等于开了一个 XSS 口子。
第五步:渲染 HTML 页码条
public function render(): string
{
if ($this->lastPage <= 1) return '';
$h = '<nav class="pager" aria-label="分页导航">';
$h .= $this->current > 1
? '<a href="'.$this->url($this->current - 1).'" rel="prev">上一页</a>'
: '<span class="disabled">上一页</span>';
foreach ($this->pages() as $p) {
if ($p === '...') {
$h .= '<span class="gap">…</span>';
} elseif ($p === $this->current) {
$h .= '<span class="active" aria-current="page">'.$p.'</span>';
} else {
$h .= '<a href="'.$this->url($p).'">'.$p.'</a>';
}
}
$h .= $this->current < $this->lastPage
? '<a href="'.$this->url($this->current + 1).'" rel="next">下一页</a>'
: '<span class="disabled">下一页</span>';
return $h . '</nav>';
}
配套 CSS 只要三行核心(其余按站点风格调):
.pager { display:flex; flex-wrap:wrap; gap:6px; align-items:center; }
.pager a, .pager span { padding:6px 11px; border:1px solid #ddd; border-radius:6px; }
.pager .active { background:#2b7de9; color:#fff; border-color:#2b7de9; }
.pager .disabled { color:#bbb; }
`flex-wrap: wrap` 这一步别省,手机上 40 个页码不换行会直接撑破布局。
第六步:接上数据库查询
$total = (int)$pdo->query("SELECT COUNT(*) FROM posts WHERE status = 1")->fetchColumn();
$pg = new Paginator($total, 20, (int)($_GET['page'] ?? 1));
$stmt = $pdo->prepare(
"SELECT id, title FROM posts WHERE status = 1 ORDER BY id DESC LIMIT :limit OFFSET :offset"
);
$stmt->bindValue(':limit', $pg->perPage, PDO::PARAM_INT);
$stmt->bindValue(':offset', $pg->offset, PDO::PARAM_INT);
$stmt->execute();
注意:`LIMIT` 和 `OFFSET` 的占位符必须 `bindValue` 成 `PDO::PARAM_INT`。如果按默认字符串绑定,在关闭模拟预处理(`ATTR_EMULATE_PREPARES => false`)的环境下 MySQL 会直接报语法错误,这是最常见的踩坑点。
深分页还有个性能问题:`LIMIT 100000, 20` 时 MySQL 仍要扫描并丢弃前 10 万行。数据上十万条后,可以改成游标式翻页——记住上一页最后一行的 id,用 `WHERE id < :last_id ORDER BY id DESC LIMIT 20`,翻页速度恒定。
第七步:别忘了 SEO 那点事
同一份内容会出现 `?page=1` 和裸地址两个 URL,搜索引擎算重复内容。建议:
- 第 1 页统一 301 到不带 `page` 参数的地址;
- 每页 `<head>` 输出 `<link rel="canonical" href="第1页地址">`;
- `rel="prev"` / `rel="next"` 保持真实链接,方便爬虫顺着翻。
小结
- 分页类只干两件事:算 offset、画页码,别把业务逻辑塞进去
- `total` 用 COUNT 查,`perPage` 走白名单,`current` 用 `min/max` 夹紧
- URL 一律用 `http_build_query` 生成,杜绝拼接和中文参数问题
- `LIMIT/OFFSET` 必须整数绑定;数据量大后改游标翻页
- 页码条加 `flex-wrap` 适配手机,加 canonical 处理重复内容
- 封装完成后,任何列表页都只用三行代码:查总数 → new Paginator → 拼 HTML