將 AJAX 分頁拆解為可維護的模組化結構:前端組件化、後端 API 標準化、資料庫遷移腳本、樣式隔離。適合團隊協作與長期維護的專案架構。
/ajax-pager-demo ├── index.php # 入口頁 ├── api/ │ ├── page.php # 分頁 API 端點 │ └── config.php # 資料庫配置 ├── assets/ │ ├── js/ │ │ └── ajax-pager.js # 前端組件 │ └── css/ │ └── pager.css # 分頁樣式 ├── templates/ │ └── page-fragment.php # HTML 片段模板 ├── sql/ │ └── brand.sql # 測試資料 └── README.md
[
'dsn' => 'mysql:host=127.0.0.1;dbname=demo;charset=utf8mb4',
'user' => 'root',
'pass' => '123456',
'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
],
],
'page' => [
'default_size' => 10,
'max_size' => 100,
],
];
query('SELECT COUNT(*) FROM brand')->fetchColumn();
$pageCount = max(1, (int)ceil($total / $size));
$page = min($page, $pageCount);
$offset = ($page - 1) * $size;
$stmt = $pdo->prepare('SELECT * FROM brand ORDER BY id DESC LIMIT ?, ?');
$stmt->execute([$offset, $size]);
$rows = $stmt->fetchAll();
// 回傳 JSON 格式(現代化:前端渲染)
if (isset($_GET['format']) && $_GET['format'] === 'json') {
header('Content-Type: application/json');
echo json_encode([
'data' => $rows,
'pagination' => [
'page' => $page,
'size' => $size,
'total' => $total,
'pageCount' => $pageCount,
],
], JSON_UNESCAPED_UNICODE);
exit;
}
// 相容模式:直接輸出 HTML 片段
require __DIR__ . '/../templates/page-fragment.php';
class AjaxPager {
#container = null;
#apiUrl = '';
#page = 1;
#size = 10;
#abortController = null;
constructor(containerSelector, apiUrl, options = {}) {
this.#container = document.querySelector(containerSelector);
this.#apiUrl = apiUrl;
this.#size = options.pageSize || 10;
this.#render = options.render || this.#defaultRender.bind(this);
this.#bindEvents();
this.load(1);
}
#bindEvents() {
this.#container.addEventListener('click', e => {
const link = e.target.closest('[data-page]');
if (link) {
e.preventDefault();
this.load(parseInt(link.dataset.page, 10));
}
});
}
async load(page) {
if (this.#abortController) this.#abortController.abort();
this.#abortController = new AbortController();
this.#showLoading();
try {
const url = `${this.#apiUrl}?page=${page}&size=${this.#size}&format=json`;
const resp = await fetch(url, { signal: this.#abortController.signal });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const { data, pagination } = await resp.json();
this.#page = pagination.page;
this.#render(data, pagination);
} catch (err) {
if (err.name !== 'AbortError') this.#showError(err.message);
}
}
#defaultRender(data, pagination) {
let html = '| ID | 品牌 |
|---|---|
| ${row.id} | ${this.#escape(row.sp_brand)} |
/* assets/css/pager.css */
.data-table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; }
.data-table th, .data-table td { padding: 0.5rem; border: 1px solid #ddd; text-align: left; }
.data-table th { background: #f5f5f5; }
.pagination ul { display: flex; gap: 0.5rem; list-style: none; padding: 0; }
.pagination a { padding: 0.4rem 0.8rem; border: 1px solid #007bff; color: #007bff;
text-decoration: none; border-radius: 4px; transition: all 0.2s; }
.pagination a:hover { background: #007bff; color: #fff; }
.pagination .current { padding: 0.4rem 0.8rem; color: #666; }
.loading, .error { padding: 2rem; text-align: center; color: #666; }
.error { color: #dc3545; }
AJAX 分頁模組化演示
品牌列表(AJAX 分頁)
CREATE TABLE `brand` (
`id` int(7) NOT NULL AUTO_INCREMENT,
`sp_brand` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO `brand` (`sp_brand`) VALUES
('hello world'), ('你好'), ('恩'), ('fdsafdsafdsa'), ('fdsafdafdsafdas'),
('fdsafdsa'), ('fdsafdsafdas'), ('恩'), ('恩'), ('恩'), ('恩11'),
('恩'), ('恩'), ('恩'), ('恩'), ('恩'), ('恩'), ('恩18');
| 維度 | 傳統混寫 | 本模組化方案 |
|---|---|---|
| 前後端耦合 | 強(HTML 直出) | 弱(JSON API) |
| 測試性 | 難測試 | API 可獨立測試 |
| 複用性 | 低 | 高(組件可多實例) |
| 維護性 | 代碼糾結 | 職責清晰分離 |
| 擴展性 | 困難 | 易加入搜尋/排序/導出 |
此架構為後續接入 Vue/React/Alpine.js 等前端框架奠定基礎,只需替換 #render 方法即可無縫遷移。