⭐ 推荐:社区规则条款 V1.0

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

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

围绕封装可直接复制使用的 PHP 分页类,说明用 COUNT 查总数并计算总页数、offset、规范化当前页,生成带省略号的页码窗口,配合 SQL LIMIT 与前端 CSS;强调边界夹取防止页码越界、每页条数白名单校验,以及用 http_build_query 安全拼接保留筛选条件的 URL,最终实现安全可复用的分页组件。

学完这篇你能得到一个可直接复制使用的 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
转载请注明出处,版权归原作者所有。
他们都看过 1 人浏览过
CLARA轻量论坛系统
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 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 这种路径才不用回来改类。