採用 **後端渲染 HTML 片段 + 前端替換容器** 模式 (而非純 JSON + 前端模板),優勢:SEO 友好、模板復用、邏輯集中、開發效率高。
CREATE TABLE `tb_user` (
`id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL DEFAULT '',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO `tb_user` (`username`) VALUES
('Alice'), ('Bob'), ('Charlie'), ('David'), ('Eve'),
('Frank'), ('Grace'), ('Heidi'), ('Ivan'), ('Judy');
<?php
// index.php
require_once __DIR__ . '/config/db.php'; // PDO 連接
$page = max(1, (int)($_GET['page'] ?? 1));
$pageSize = 3; // 每頁條數
$offset = ($page - 1) * $pageSize;
// 總數查詢 (可緩存)
$totalStmt = $pdo->query('SELECT COUNT(*) FROM tb_user');
$total = (int)$totalStmt->fetchColumn();
$pageCount = max(1, (int)ceil($total / $pageSize));
$page = min($page, $pageCount);
// 數據查詢
$dataStmt = $pdo->prepare('SELECT id, username, created_at FROM tb_user ORDER BY id DESC LIMIT :offset, :limit');
$dataStmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$dataStmt->bindValue(':limit', $pageSize, PDO::PARAM_INT);
$dataStmt->execute();
$users = $dataStmt->fetchAll(PDO::FETCH_ASSOC);
// 分頁導航構建
$nav = '';
$nav .= "<span class='page-info'>共 {$total} 條 / {$pageCount} 頁</span> ";
$prevPage = $page > 1 ? $page - 1 : 1;
$nextPage = $page < $pageCount ? $page + 1 : $pageCount;
$nav .= "<a href='?page=1' class='page-btn' data-page='1'>首頁</a> ";
$nav .= "<a href='?page={$prevPage}' class='page-btn' data-page='{$prevPage}'>上一頁</a> ";
$nav .= "<a href='?page={$nextPage}' class='page-btn' data-page='{$nextPage}'>下一頁</a> ";
$nav .= "<a href='?page={$pageCount}' class='page-btn' data-page='{$pageCount}'>尾頁</a> ";
// AJAX 請求只返回片段;普通請求返回完整頁面
$isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest';
if ($isAjax) {
header('Content-Type: text/html; charset=utf-8');
// 僅輸出表格 + 分頁 (無外層容器)
include __DIR__ . '/templates/user_list_partial.php';
exit;
}
// 完整頁面
include __DIR__ . '/templates/user_list_full.php';
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>用戶列表 - AJAX 分頁演示</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
th, td { padding: 0.75rem; border: 1px solid #e0e0e0; text-align: left; }
th { background: #f5f5f5; }
.pagination { margin-top: 1.5rem; display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
.page-btn { padding: 0.4rem 0.8rem; border: 1px solid #ccc; background: #fff; border-radius: 4px; text-decoration: none; color: #333; cursor: pointer; transition: all .2s; }
.page-btn:hover:not(.disabled) { background: #007bff; color: #fff; border-color: #007bff; }
.page-btn.disabled { pointer-events: none; opacity: 0.5; }
.page-info { color: #666; font-size: 0.9rem; }
.loading { color: #999; font-style: italic; }
</style>
</head>
<body>
<h1>用戶列表 (AJAX 無刷新分頁)</h1>
<!-- 核心容器:AJAX 將替換此處內容 -->
<div id="user-list-container">
<?php include __DIR__ . '/user_list_partial.php'; ?>
</div>
<script src="assets/js/pagination.js"></script>
</body>
</html>
<table id="user-table">
<thead>
<tr><th>ID</th><th>用戶名</th><th>註冊時間</th></tr>
</thead>
<tbody>
<?php foreach ($users as $user): ?>
<tr>
<td><?= $user['id'] ?></td>
<td><?= htmlspecialchars($user['username']) ?></td>
<td><?= $user['created_at'] ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($users)): ?>
<tr><td colspan="3" style="text-align:center;">暫無數據</td></tr>
<?php endif; ?>
</tbody>
</table>
<div class="pagination">
<?= $nav ?>
</div>
// pagination.js
(function () {
'use strict';
const container = document.getElementById('user-list-container');
if (!container) return;
// 事件代理:處理動態生成的分頁鏈接
container.addEventListener('click', function (e) {
const link = e.target.closest('.page-btn');
if (!link) return;
e.preventDefault();
const page = link.dataset.page;
if (!page) return;
loadPage(page);
});
// 支持瀏覽器前進/後退
window.addEventListener('popstate', function (e) {
if (e.state && e.state.page) {
loadPage(e.state.page, false); // 不再 pushState
}
});
function loadPage(page, pushState = true) {
// 1. 顯示加載狀態
container.innerHTML = '<div class="loading">正在加載第 ' + page + ' 頁...</div>';
// 2. 發送 AJAX 請求 (帶 X-Requested-With 標頭)
fetch('?page=' + encodeURIComponent(page), {
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html'
},
credentials: 'same-origin' // 攜帶 Cookie/Session
})
.then(response => {
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.text();
})
.then(html => {
// 3. 替換容器內容
container.innerHTML = html;
// 4. 更新 URL (不刷新)
if (pushState) {
const url = new URL(window.location);
url.searchParams.set('page', page);
history.pushState({ page }, '', url);
}
// 5. 滾動到頂部 (可選)
// container.scrollIntoView({ behavior: 'smooth' });
})
.catch(err => {
console.error(err);
container.innerHTML = '<div style="color:red; padding:1rem;">加載失敗:' + err.message + '</div>';
});
}
// 初始化:高亮當前頁按鈕 (可選)
function highlightCurrentPage() {
const currentPage = new URLSearchParams(window.location.search).get('page') || '1';
container.querySelectorAll('.page-btn').forEach(btn => {
btn.classList.toggle('disabled', btn.dataset.page === currentPage);
});
}
highlightCurrentPage();
})();
<?php
// 在 pagination.js 中擴展
// 1. 簡單內存緩存 (避免重複請求同一頁)
const pageCache = new Map();
function loadPage(page, pushState = true) {
if (pageCache.has(page)) {
container.innerHTML = pageCache.get(page);
if (pushState) updateUrl(page);
highlightCurrentPage();
return;
}
// ... fetch 後
.then(html => {
pageCache.set(page, html); // 緩存
container.innerHTML = html;
// ...
});
}
// 2. 預加載鄰近頁 (鼠標懸停預取)
container.addEventListener('mouseover', function (e) {
const link = e.target.closest('.page-btn');
if (link && !pageCache.has(link.dataset.page)) {
// 低優先級預取
fetch('?page=' + link.dataset.page, { headers: { 'X-Requested-With': 'XMLHttpRequest' }, priority: 'low' })
.then(r => r.text())
.then(html => pageCache.set(link.dataset.page, html));
}
});
// 3. 防抖快速點擊 (可選)
let lastClick = 0;
container.addEventListener('click', function (e) {
const link = e.target.closest('.page-btn');
if (link && Date.now() - lastClick < 300) e.preventDefault(); // 300ms 內忽略
lastClick = Date.now();
});
| 環節 | 關鍵點 | 常見錯誤 |
|---|---|---|
| SQL | 使用 `LIMIT :offset, :limit` 參數綁定;總數查詢可緩存 | 字符串拼接 SQL 注入;`OFFSET` 大時性能急劇下降 (建議游標分頁) |
| 後端識別 AJAX | `$_SERVER['HTTP_X_REQUESTED_WITH'] === 'xmlhttprequest'` (jQuery/axios/fetch 默認發送) | 未發送標頭導致返回完整頁面;原生 fetch 需手動添加 |
| 模板分離 | 片段模板**無外層容器**,直接輸出表格+分頁 | 片段包含 `` 導致嵌套非法結構 |
| 前端替換 | 使用 `innerHTML` 替換**容器內部**;事件代理處理動態元素 | `outerHTML` 替換容器自身導致引用丟失;重複綁定事件 |
| URL 同步 | `history.pushState` 更新地址欄,支持刷新/分享/後退 | 不更新 URL 導致刷新回第一頁;`replaceState` 覆蓋歷史 |
| 錯誤處理 | 統一返回 4xx/5xx + 友好提示;前端 catch 顯示 | PHP 致命錯誤輸出 HTML 破壞片段結構 |
| XSS 防護 | 輸出變量**必須** `htmlspecialchars()` | 直接輸出用戶輸入導致存儲型 XSS |
選型建議: 簡單列表分頁 → **原生 fetch + 後端片段渲染** (本文方案);中等複雜度 → **Alpine.js/htmx**;SPA/富交互 → **Vue/React + JSON API**;Laravel 團隊 → **Livewire**。核心原則:模板復用、邏輯集中、漸進增強。