PHP 分页类封装:好用的前端分页组件开发教程

晁铭
晁铭 正式会员正式会员认证极客认证极客
发布于 2026-10-03 10:57 ·2 浏览 ·2 回复

学完这篇你能得到一个可直接复制使用的 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. 第 1 页统一 301 到不带 `page` 参数的地址;
  2. 每页 `<head>` 输出 `<link rel="canonical" href="第1页地址">`;
  3. `rel="prev"` / `rel="next"` 保持真实链接,方便爬虫顺着翻。

小结

  • 分页类只干两件事:算 offset、画页码,别把业务逻辑塞进去
  • `total` 用 COUNT 查,`perPage` 走白名单,`current` 用 `min/max` 夹紧
  • URL 一律用 `http_build_query` 生成,杜绝拼接和中文参数问题
  • `LIMIT/OFFSET` 必须整数绑定;数据量大后改游标翻页
  • 页码条加 `flex-wrap` 适配手机,加 canonical 处理重复内容
  • 封装完成后,任何列表页都只用三行代码:查总数 → new Paginator → 拼 HTML
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-686.html
转载请注明出处,版权归原作者所有。

全部回复 2

yipeng
yipeng 正式会员正式会员认证极客认证极客 1楼 2026-10-03 11:03

帖子思路很正,不过内容在 `for ($` 那里被截断了,楼主记得补全。另外补三个这类分页类最容易踩的坑,都是能直接省掉一次线上排查的。

COUNT 单独查 + 加缓存。这点楼主已经点到了,再补一句:论坛列表页 COUNT(*) 最好挂个 60 秒缓存(帖子增删时主动清),不然每次翻页都对大表全扫一遍,首页 QPS 一高就顶不住。

PDO 里 LIMIT 必须绑定整数。用预处理时 `:limit` 和 `:offset` 默认按字符串绑定,MySQL 会生成 `LIMIT '20'` 直接语法错误。正确做法是 `$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);`,或者干脆把已经强转成 int 的值内插进 SQL——反正 `max(1, (int)$x)` 之后已经没有注入面了。

页码数组里混了字符串 `'...'`,渲染时一定要判类型。别写成 `foreach ($pages as $p) echo '<a href="?page='.$p.'">'`,否则省略号也变成链接、点了跳 `?page=...`。渲染时用 `is_int($p)` 分开走 `<span class="gap">…</span>`,顺带把当前页加 `aria-current="page"`,无障碍和 SEO 都友好。

再提醒一个小边界:`total = 0` 时你的 `lastPage` 被 `max(1, ...)` 兜到 1 了,页码条会显示「1」,但列表页应当直接走空状态组件,别渲染分页条——建议在渲染层判断 `if ($total === 0) return '';`。

补完全文后建议把 `pages()` 的返回改成结构化数组(`['type' => 'page'|'gap', 'num' => n]`),前端模板好使得多。

XiaoC
XiaoC 正式会员正式会员认证极客认证极客 #345 2楼 2026-10-03 11:08
yipeng:帖子思路很正,不过内容在 `for ($` 那里被截断了,楼主记得补全。另外补三个这类分页类最容易踩的坑,都是能直接省掉一次线上排查的。 **COUNT 单独…

你补充的这几条都在点上,尤其 PDO 绑定那条,是那种不踩一次线上根本想不到的坑——我先把截断的 `pages()` 补全,末尾再改成你说的结构化返回。

$out = [1];
if ($from > 2) $out[] = ['type' => 'gap'];
for ($i = $from; $i <= $to; $i++) $out[] = ['type' => 'page', 'num' => $i];
if ($to < $this->lastPage - 1) $out[] = ['type' => 'gap'];
if ($this->lastPage > 1) $out[] = ['type' => 'page', 'num' => $this->lastPage];
return $out;

改成 `['type' => 'page'|'gap', 'num' => n]` 之后,模板里 `gap` 走 `<span class="gap">…</span>`、`page` 走链接,类型判断从「靠字符串猜」变成显式分支,顺手把 `aria-current="page"` 加上也就一行的事,比事后补无障碍省心得多。

LIMIT 那条再补个细节:光加 `PARAM_INT` 有时还不够,如果开了 `ATTR_EMULATE_PREPARES = true`(PDO 默认值),MySQL 侧收到的仍是拼接后的字符串,得把模拟预处理关掉才稳;图省事就直接内插 int,反正 `max(1, (int)$x)` 之后没有注入面。COUNT 缓存我这边是挂 60 秒 + 发帖回帖时主动失效,如果你们用的是 Clara,直接走 `Cache::remember` 就行,省得自己写一套 key 管理。

`total === 0` 返回空串这点我同意,另外 `lastPage === 1` 时也建议一并返回空——只有一页还显示个「1」,用户点着没反馈,体验反而怪。

延伸一句:这类分页类建议把「页码窗口长度」和「URL 模板」也做成构造参数,别在方法里硬编码 `?page=`,伪静态下 `page/2` 这种路径才不用回来改类。