分頁是列表頁面核心元件。本文提供功能完整的 PHP 分頁類別,支援參數保持、多種樣式框架整合、AJAX 分頁、SEO 友善結構與單元測試。
<?php
class Paginator {
public int $currentPage = 1;
public int $perPage = 20;
public int $totalItems = 0;
public string $baseUrl = '';
public array $queryParams = [];
public int $visiblePages = 5; // 顯示頁碼數量
public string $parameterName = 'page';
// 模板可自訂
public string $prevText = '« 上一頁';
public string $nextText = '下一頁 »';
public string $firstText = '首頁';
public string $lastText = '末頁';
public string $ellipsis = '…';
public function __construct(array $config = []) {
foreach ($config as $key => $value) {
if (property_exists($this, $key)) {
$this->$key = $value;
}
}
// 自動偵測當前頁
if (empty($this->baseUrl)) {
$this->baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
}
$this->currentPage = max(1, (int)($_GET[$this->parameterName] ?? 1));
$this->queryParams = $_GET;
unset($this->queryParams[$this->parameterName]);
}
public function setTotalItems(int $total): self {
$this->totalItems = max(0, $total);
return $this;
}
public function getTotalPages(): int {
return max(1, (int)ceil($this->totalItems / $this->perPage));
}
public function getOffset(): int {
return ($this->currentPage - 1) * $this->perPage;
}
public function getLimit(): int {
return $this->perPage;
}
public function getPageRange(): array {
$totalPages = $this->getTotalPages();
$half = (int)floor($this->visiblePages / 2);
$start = max(1, $this->currentPage - $half);
$end = min($totalPages, $start + $this->visiblePages - 1);
// 調整 start 以填滿 visiblePages
$start = max(1, $end - $this->visiblePages + 1);
return range($start, $end);
}
public function buildUrl(int $page): string {
$params = $this->queryParams;
$params[$this->parameterName] = $page;
$separator = str_contains($this->baseUrl, '?') ? '&' : '?';
return $this->baseUrl . $separator . http_build_query($params);
}
// 資料庫查詢輔助
public function applyToQuery(PDOStatement|mysqli_stmt $stmt): void {
$stmt->execute(array_merge($stmt->boundParams ?? [], [
':offset' => $this->getOffset(),
':limit' => $this->getLimit()
]));
}
// 陣列分頁(小資料集)
public function paginateArray(array $items): array {
return array_slice($items, $this->getOffset(), $this->getLimit());
}
}
?>
<?php
interface PaginatorRenderer {
public function render(Paginator $paginator): string;
}
class Bootstrap5Renderer implements PaginatorRenderer {
public function render(Paginator $p): string {
$totalPages = $p->getTotalPages();
if ($totalPages <= 1) return '';
$html = '';
// 資訊列
$start = $p->totalItems ? $p->getOffset() + 1 : 0;
$end = min($p->getOffset() + $p->perPage, $p->totalItems);
$html .= "";
$html .= "顯示 {$start} - {$end} 筆,共 {$p->totalItems} 筆(第 {$p->currentPage} / {$totalPages} 頁)";
$html .= "";
return $html;
}
private function pageItem(string $text, int $page, bool $disabled, Paginator $p, bool $isNumber = false): string {
$attrs = ['class' => 'page-link'];
if ($disabled) $attrs['class'] .= ' disabled';
if ($isNumber && $page === $p->currentPage) {
$attrs['class'] .= ' active';
$attrs['aria-current'] = 'page';
}
$href = $disabled ? '#' : $p->buildUrl($page);
$attrStr = ' ' . implode(' ', array_map(fn($k, $v) => "$k=\"$v\"", array_keys($attrs), $attrs));
return "currentPage ? ' active' : '') . "'>"
. "{$text}"
. " ";
}
}
class TailwindRenderer implements PaginatorRenderer {
public function render(Paginator $p): string {
$totalPages = $p->getTotalPages();
if ($totalPages <= 1) return '';
$html = '';
$start = $p->totalItems ? $p->getOffset() + 1 : 0;
$end = min($p->getOffset() + $p->perPage, $p->totalItems);
$html .= "";
$html .= "顯示 {$start} - {$end} 筆,共 {$p->totalItems} 筆";
$html .= "
";
return $html;
}
}
?>
<?php
// 控制器
$paginator = new Paginator([
'perPage' => 15,
'parameterName' => 'p'
]);
// 取得總數
$stmt = $pdo->query("SELECT COUNT(*) FROM articles WHERE status = 'published'");
$paginator->setTotalItems((int)$stmt->fetchColumn());
// 查詢資料
$stmt = $pdo->prepare("SELECT * FROM articles WHERE status = 'published' ORDER BY created_at DESC LIMIT :limit OFFSET :offset");
$stmt->bindValue(':limit', $paginator->getLimit(), PDO::PARAM_INT);
$stmt->bindValue(':offset', $paginator->getOffset(), PDO::PARAM_INT);
$stmt->execute();
$articles = $stmt->fetchAll();
// 視圖
$renderer = new Bootstrap5Renderer();
echo $renderer->render($paginator);
?>
<?php
class Paginate {
public static function make(int $total, int $perPage = 20, array $options = []): Paginator {
$paginator = new Paginator(array_merge([
'perPage' => $perPage,
'baseUrl' => url()->current(), // Laravel helper
'queryParams' => request()->except('page')
], $options));
$paginator->setTotalItems($total);
return $paginator;
}
}
// 使用
$articles = Article::query()->latest()->paginate(15);
// 視圖直接用 $articles->links()(Laravel 內建)
?>
<?php
// api/articles.php
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = 10;
$offset = ($page - 1) * $perPage;
$stmt = $pdo->prepare("SELECT * FROM articles WHERE status = 'published' ORDER BY created_at DESC LIMIT ? OFFSET ?");
$stmt->execute([$perPage, $offset]);
$articles = $stmt->fetchAll();
$totalStmt = $pdo->query("SELECT COUNT(*) FROM articles WHERE status = 'published'");
$total = (int)$totalStmt->fetchColumn();
header('Content-Type: application/json');
echo json_encode([
'data' => $articles,
'pagination' => [
'current_page' => $page,
'per_page' => $perPage,
'total' => $total,
'last_page' => (int)ceil($total / $perPage),
'has_more' => $page * $perPage < $total
]
]);
?>
<div id="articles-container"></div>
<button id="load-more" class="btn btn-primary" style="display:none">載入更多</button>
<script>
let currentPage = 1;
let loading = false;
async function loadArticles(append = true) {
if (loading) return;
loading = true;
document.getElementById('load-more').textContent = '載入中...';
try {
const res = await fetch(`/api/articles.php?page=${currentPage}`);
const {data, pagination} = await res.json();
const container = document.getElementById('articles-container');
if (append) {
data.forEach(article => container.insertAdjacentHTML('beforeend', renderArticle(article)));
} else {
container.innerHTML = data.map(renderArticle).join('');
}
if (pagination.has_more) {
currentPage++;
document.getElementById('load-more').style.display = 'block';
document.getElementById('load-more').textContent = '載入更多';
} else {
document.getElementById('load-more').style.display = 'none';
}
} catch (e) {
console.error(e);
} finally {
loading = false;
}
}
function renderArticle(article) {
return `<article class="card mb-3"><div class="card-body"><h5>${escapeHtml(article.title)}</h5><p>${escapeHtml(article.excerpt)}</p></div></article>`;
}
document.getElementById('load-more').addEventListener('click', () => loadArticles());
loadArticles(false); // 初始載入
</script>
<?php
function renderPaginationSchema(Paginator $p): string {
$totalPages = $p->getTotalPages();
if ($totalPages <= 1) return '';
$schema = [
'@context' => 'https://schema.org',
'@type' => 'ItemList',
'itemListElement' => []
];
foreach ($p->getPageRange() as $i => $page) {
$schema['itemListElement'][] = [
'@type' => 'ListItem',
'position' => $i + 1,
'item' => $p->buildUrl($page)
];
}
return '';
}
?>
<?php
// tests/PaginatorTest.php
class PaginatorTest extends TestCase {
public function testPageRange(): void {
$p = new Paginator(['totalItems' => 100, 'perPage' => 10, 'currentPage' => 5, 'visiblePages' => 5]);
$this->assertEquals([3,4,5,6,7], $p->getPageRange());
// 邊界測試
$p->currentPage = 1;
$this->assertEquals([1,2,3,4,5], $p->getPageRange());
$p->currentPage = 10;
$this->assertEquals([6,7,8,9,10], $p->getPageRange());
}
public function testBuildUrl(): void {
$p = new Paginator(['baseUrl' => 'https://example.com/list', 'queryParams' => ['sort' => 'desc']]);
$this->assertEquals('https://example.com/list?sort=desc&page=3', $p->buildUrl(3));
}
}
?>
這個分頁元件解決了實務上常見問題:查詢參數保持、多框架樣式切換、AJAX 整合、SEO 結構化資料、邊界條件處理。建議專案建立統一的 `PaginationService` 封裝常用邏輯,減少控制器重複代碼。