設計目標
- 零依賴、單檔案、可直接 `require` 使用
- 支援 **任意查詢參數保留** (如 `?search=foo&cat=bar&page=3`)
- 靈活配置:每頁筆數、顯示頁碼數量、首尾/上下頁、下拉跳轉
- 輸出語義化 HTML,易於 CSS 美化
- 防 XSS:所有動態輸出經 `htmlspecialchars`
完整類代碼 (Pager.php)
<?php
/**
* 通用分頁器
* 用法:
* $pager = new Pager($totalRows, $perPage, $currentPage, $options);
* echo $pager->render();
*/
class Pager
{
private int $totalRows;
private int $perPage;
private int $currentPage;
private int $totalPages;
private array $options;
public function __construct(
int $totalRows,
int $perPage = 20,
?int $currentPage = null,
array $options = []
) {
$this->totalRows = max(0, $totalRows);
$this->perPage = max(1, $perPage);
$this->totalPages = (int)ceil($this->totalRows / $this->perPage);
// 解析當前頁
if ($currentPage === null) {
$currentPage = isset($_GET['page']) ? (int)$_GET['page'] : 1;
}
$this->currentPage = max(1, min($currentPage, max(1, $this->totalPages)));
// 預設選項
$defaults = [
'showPageCount' => 9, // 顯示幾個頁碼 (建議奇數)
'paramName' => 'page', // URL 參數名
'preserveParams' => true, // 保留其他 GET 參數
'firstText' => '首頁',
'lastText' => '尾頁',
'prevText' => '上一頁',
'nextText' => '下一頁',
'dropdown' => true, // 顯示下拉跳轉
'dropdownText' => '跳轉至',
'wrapperClass' => 'pagination',
'activeClass' => 'active',
'disabledClass' => 'disabled',
'linkClass' => 'page-link',
];
$this->options = array_merge($defaults, $options);
}
/** 產生單頁連結 */
private function link(int $page, string $text, array $attrs = []): string
{
$url = $this->buildUrl($page);
$attrs['href'] = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
$attrs['class'] = ($attrs['class'] ?? '') . ' ' . $this->options['linkClass'];
$attrStr = '';
foreach ($attrs as $k => $v) {
$attrStr .= ' ' . $k . '="' . htmlspecialchars($v, ENT_QUOTES, 'UTF-8') . '"';
}
return '<a' . $attrStr . '>' . htmlspecialchars($text) . '</a>';
}
/** 建構保留參數的 URL */
private function buildUrl(int $page): string
{
$params = $_GET;
if (!$this->options['preserveParams']) {
$params = [];
}
$params[$this->options['paramName']] = $page;
// 移除空值
$params = array_filter($params, fn($v) => $v !== '' && $v !== null);
$base = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST'] . $_SERVER['SCRIPT_NAME'];
return $base . '?' . http_build_query($params);
}
/** 計算顯示頁碼範圍 */
private function getPageRange(): array
{
$show = $this->options['showPageCount'];
$half = (int)floor($show / 2);
$start = max(1, $this->currentPage - $half);
$end = min($this->totalPages, $start + $show - 1);
// 向前修正
if ($end - $start + 1 < $show) {
$start = max(1, $end - $show + 1);
}
return [$start, $end];
}
/** 主渲染方法 */
public function render(): string
{
if ($this->totalPages <= 1) return ''; // 無需分頁
$opt = $this->options;
$html = '<nav aria-label="Pagination"><ul class="' . $opt['wrapperClass'] . '">';
// 總筆數資訊
$html .= '<li class="page-info">共 ' . $this->totalRows . ' 筆 / ' . $this->perPage . ' 筆每頁,第 ' . $this->currentPage . ' / ' . $this->totalPages . ' 頁</li>';
// 首頁
if ($this->currentPage > 1) {
$html .= '<li>' . $this->link(1, $opt['firstText']) . '</li>';
} else {
$html .= '<li class="' . $opt['disabledClass'] . '"><span>' . $opt['firstText'] . '</span></li>';
}
// 上一頁
if ($this->currentPage > 1) {
$html .= '<li>' . $this->link($this->currentPage - 1, $opt['prevText']) . '</li>';
} else {
$html .= '<li class="' . $opt['disabledClass'] . '"><span>' . $opt['prevText'] . '</span></li>';
}
// 頁碼區間
[$start, $end] = $this->getPageRange();
for ($i = $start; $i <= $end; $i++) {
if ($i === $this->currentPage) {
$html .= '<li class="' . $opt['activeClass'] . '" aria-current="page"><span>' . $i . '</span></li>';
} else {
$html .= '<li>' . $this->link($i, (string)$i) . '</li>';
}
}
// 下一頁
if ($this->currentPage < $this->totalPages) {
$html .= '<li>' . $this->link($this->currentPage + 1, $opt['nextText']) . '</li>';
} else {
$html .= '<li class="' . $opt['disabledClass'] . '"><span>' . $opt['nextText'] . '</span></li>';
}
// 尾頁
if ($this->currentPage < $this->totalPages) {
$html .= '<li>' . $this->link($this->totalPages, $opt['lastText']) . '</li>';
} else {
$html .= '<li class="' . $opt['disabledClass'] . '"><span>' . $opt['lastText'] . '</span></li>';
}
// 下拉跳轉
if ($opt['dropdown'] && $this->totalPages > 1) {
$html .= '<li class="page-dropdown">';
$html .= '<label for="pager-dropdown">' . $opt['dropdownText'] . '</label>';
$html .= '<select id="pager-dropdown" onchange="window.location.href=this.value">';
for ($i = 1; $i <= $this->totalPages; $i++) {
$selected = ($i === $this->currentPage) ? ' selected' : '';
$html .= '<option value="' . htmlspecialchars($this->buildUrl($i)) . '"' . $selected . '>第 ' . $i . ' 頁</option>';
}
$html .= '</select></li>';
}
$html .= '</ul></nav>';
return $html;
}
/** 靜態快速建立 */
public static function create(int $totalRows, int $perPage = 20, array $options = []): self
{
return new self($totalRows, $perPage, null, $options);
}
}
使用範例
<?php
require_once 'Pager.php';
// 模擬資料庫查詢
$pdo = new PDO('mysql:host=localhost;dbname=test', 'user', 'pass');
$total = $pdo->query('SELECT COUNT(*) FROM articles WHERE status=1')->fetchColumn();
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
$perPage = 15;
$offset = ($page - 1) * $perPage;
$stmt = $pdo->prepare('SELECT * FROM articles WHERE status=1 ORDER BY id DESC LIMIT ?, ?');
$stmt->execute([$offset, $perPage]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
// 實例化分頁器 (保留 search、cat 參數)
$pager = new Pager($total, $perPage, $page, [
'showPageCount' => 7,
'preserveParams' => true,
'wrapperClass' => 'pagination justify-content-center',
'linkClass' => 'page-link text-dark',
'activeClass' => 'active bg-primary',
'disabledClass' => 'disabled',
]);
// 輸出列表
foreach ($rows as $row) { /* ... */ }
// 輸出分頁
echo $pager->render();
?>
CSS 美化範例 (Bootstrap 5 風格)
.pagination { display:flex; flex-wrap:wrap; gap:0.25rem; list-style:none; padding:0; margin:1rem 0; }
.pagination li { display:flex; align-items:center; }
.pagination .page-link { display:block; padding:0.375rem 0.75rem; color:#0d6efd; text-decoration:none; border:1px solid #dee2e6; border-radius:0.25rem; transition:all .2s; }
.pagination .page-link:hover { background:#e9ecef; }
.pagination .active .page-link { background:#0d6efd; border-color:#0d6efd; color:#fff; z-index:3; }
.pagination .disabled .page-link { color:#6c757d; pointer-events:none; background:#fff; border-color:#dee2e6; }
.pagination .page-info { align-self:center; padding:0 0.5rem; font-size:0.875rem; color:#6c757d; }
.pagination .page-dropdown select { padding:0.25rem 0.5rem; border:1px solid #dee2e6; border-radius:0.25rem; }
進階擴展建議
- Ajax 分頁:`render()` 回傳 JSON (`{html:'...', current:3, total:10}`),前端 `fetch` 替換列表區塊。
- 游標分頁:大數據量 (百萬級) 避免 `OFFSET` 深分頁效能問題,改用 `WHERE id > ? LIMIT ?` + `next_cursor`。
- SEO 友善:加入 `rel="prev/next"`、``、結構化資料 `ItemList`。