ExtJS (Sencha Ext JS) 作為企業級前端框架,與 PHP 後端透過 AJAX 進行 JSON 資料交換是標準模式。本文從前端 `Ext.Ajax.request`、表單提交、Grid Store 載入,到後端 PHP 接收、驗證、回應 JSON 的完整流程,提供可直接落地的最佳實踐範本。
ExtJS (前端) HTTP/JSON PHP (後端)
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ View/Grid │─────▶│ API 路由 │────▶│ Controller │
│ Form/Store │◀────│ JSON 響應 │◀────│ Service/DAO │
└─────────────┘ └─────────────┘ └─────────────┘
<?php
// api.php 或 Controller 基類
function jsonResponse(bool $success, $data = null, string $message = '', int $httpCode = 200): void {
http_response_code($httpCode);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'success' => $success,
'data' => $data,
'message' => $message,
'timestamp' => time(),
], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
exit;
}
// 成功
jsonResponse(true, ['id' => 123], '建立成功');
// 驗證失敗
jsonResponse(false, ['field' => 'email', 'error' => 'Email 格式錯誤'], '驗證失敗', 422);
// 伺服器錯誤
jsonResponse(false, null, '內部伺服器錯誤', 500);
Ext.define('App.util.Ajax', {
singleton: true,
request: function(config) {
Ext.Ajax.request(Ext.apply({
headers: { 'Accept': 'application/json' },
timeout: 30000,
success: function(response, opts) {
var result = Ext.decode(response.responseText, true);
if (!result) {
Ext.Msg.alert('錯誤', '伺服器回應格式異常');
if (config.failure) config.failure.call(config.scope || this, response, opts);
return;
}
if (result.success) {
if (config.callback) config.callback.call(config.scope || this, result.data, result.message);
} else {
// 統一錯誤處理:顯示 message,若有 data.field 則標記表單欄位
Ext.Msg.alert('操作失敗', result.message || '未知錯誤');
if (config.failure) config.failure.call(config.scope || this, result.data, result.message);
}
},
failure: function(response, opts) {
var msg = '網路錯誤';
if (response.status === 422) {
var r = Ext.decode(response.responseText, true);
msg = r?.message || '驗證失敗';
} else if (response.status >= 500) {
msg = '伺服器內部錯誤';
}
Ext.Msg.alert('錯誤', msg);
if (config.failure) config.failure.call(config.scope || this, null, msg);
}
}, config));
}
});
Ext.create('Ext.form.Panel', {
title: '用戶登入',
renderTo: Ext.getBody(),
width: 350,
bodyPadding: 10,
defaultType: 'textfield',
items: [{
fieldLabel: '帳號',
name: 'username',
allowBlank: false,
msgTarget: 'side'
}, {
fieldLabel: '密碼',
name: 'password',
inputType: 'password',
allowBlank: false
}],
buttons: [{
text: '登入',
handler: function(btn) {
var form = this.up('form').getForm();
if (form.isValid()) {
btn.disable();
App.util.Ajax.request({
url: '/api/auth/login',
method: 'POST',
params: form.getValues(),
callback: function(data, msg) {
btn.enable();
// 登入成功:儲存 token、導向主頁
localStorage.setItem('token', data.token);
window.location.href = '/dashboard';
},
failure: function(data, msg) {
btn.enable();
// data 可能包含欄位錯誤,可用 form.markInvalid(data)
}
});
}
}
}]
});
<?php
// api/auth/login.php
require_once '../bootstrap.php'; // PDO, Session, 函式庫
// 僅允許 POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(false, null, 'Method Not Allowed', 405);
}
// 讀取 JSON 或表單資料
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) $input = $_POST;
$username = trim($input['username'] ?? '');
$password = $input['password'] ?? '';
// 驗證
$errors = [];
if (!$username) $errors['username'] = '請輸入帳號';
if (!$password) $errors['password'] = '請輸入密碼';
if ($errors) jsonResponse(false, $errors, '驗證失敗', 422);
// 查詢用戶
$stmt = $pdo->prepare('SELECT id, username, password_hash, role FROM users WHERE username = ?');
$stmt->execute([$username]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password_hash'])) {
jsonResponse(false, null, '帳號或密碼錯誤', 401);
}
// 產生 JWT 或 Session
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
$_SESSION['role'] = $user['role'];
// 回應
jsonResponse(true, [
'user' => ['id' => $user['id'], 'username' => $user['username'], 'role' => $user['role']],
'token' => bin2hex(random_bytes(32)), // 簡易 token 示意
], '登入成功');
Ext.define('App.store.Users', {
extend: 'Ext.data.Store',
alias: 'store.users',
fields: ['id', 'username', 'email', 'role', 'created_at', 'status'],
pageSize: 20,
proxy: {
type: 'ajax',
url: '/api/users',
reader: {
type: 'json',
rootProperty: 'data',
totalProperty: 'total',
successProperty: 'success',
messageProperty: 'message'
},
// 將分頁/排序/篩選參數轉為查詢字串
simpleSortMode: true,
filterParam: 'filters',
sortParam: 'sort',
pageParam: 'page',
limitParam: 'limit',
startParam: 'start',
},
autoLoad: true,
listeners: {
load: function(store, records, successful) {
if (!successful) Ext.Msg.alert('錯誤', store.getProxy().getReader().rawData.message);
}
}
});
// Grid 使用
Ext.create('Ext.grid.Panel', {
title: '用戶管理',
store: { type: 'users' },
columns: [
{ text: 'ID', dataIndex: 'id', width: 60 },
{ text: '帳號', dataIndex: 'username', flex: 1 },
{ text: 'Email', dataIndex: 'email', flex: 1 },
{ text: '角色', dataIndex: 'role', width: 100 },
{ text: '狀態', dataIndex: 'status', width: 80, renderer: v => v ? '啟用' : '停用' },
],
dockedItems: [{
xtype: 'pagingtoolbar',
dock: 'bottom',
displayInfo: true,
store: 'users'
}],
renderTo: Ext.getBody(),
width: 800,
height: 400
});
<?php
// api/users.php
require_once '../bootstrap.php';
$page = max(1, (int)($_GET['page'] ?? 1));
$limit = min(100, max(1, (int)($_GET['limit'] ?? 20)));
$start = ($page - 1) * $limit;
$sort = $_GET['sort'] ?? 'id';
$dir = strtoupper($_GET['dir'] ?? 'ASC');
$filters = json_decode($_GET['filters'] ?? '[]', true);
// 白名單排序欄位
$allowedSort = ['id', 'username', 'email', 'created_at'];
$sort = in_array($sort, $allowedSort) ? $sort : 'id';
$dir = in_array($dir, ['ASC', 'DESC']) ? $dir : 'ASC';
// 建構 WHERE
$where = ['1=1'];
$params = [];
if (!empty($filters)) {
foreach ($filters as $f) {
$field = $f['property'] ?? '';
$value = $f['value'] ?? '';
if (in_array($field, ['username', 'email', 'role'])) {
$where[] = "$field LIKE ?";
$params[] = "%$value%";
}
}
}
$whereSql = implode(' AND ', $where);
// 總筆數
$countStmt = $pdo->prepare("SELECT COUNT(*) FROM users WHERE $whereSql");
$countStmt->execute($params);
$total = $countStmt->fetchColumn();
// 分頁資料
$dataStmt = $pdo->prepare("SELECT id, username, email, role, created_at, status FROM users WHERE $whereSql ORDER BY $sort $dir LIMIT ? OFFSET ?");
$params[] = $limit;
$params[] = $start;
$dataStmt->execute($params);
$rows = $dataStmt->fetchAll(PDO::FETCH_ASSOC);
jsonResponse(true, [
'total' => (int)$total,
'data' => $rows,
'page' => $page,
'limit' => $limit,
]);
// 前端:Form submit (標準 multipart/form-data)
form.submit({
url: '/api/upload',
waitMsg: '上傳中...',
success: function(form, action) {
jsonResponse(true, action.result.data, '上傳成功');
},
failure: function(form, action) {
jsonResponse(false, null, action.result?.message || '上傳失敗');
}
});
// 後端:處理 $_FILES
$file = $_FILES['file'] ?? null;
// ... 驗證、移動、儲存資料庫 ...
jsonResponse(true, ['path' => '/uploads/abc123.jpg', 'url' => 'https://cdn.example.com/abc123.jpg']);
| 問題 | 原因 | 解決 |
|---|---|---|
| PHP 收不到 `$_POST` 資料 | Content-Type 為 application/json | `json_decode(file_get_contents('php://input'), true)` |
| ExtJS Store 不發送分頁參數 | Proxy 設定錯誤或 `remoteSort`/`remoteFilter` 未開啟 | 檢查 proxy 參數對應 |
| 日期格式不一致 | PHP `date('c')` vs ExtJS `dateFormat` | 統一用 ISO 8601 (`Y-m-d\TH:i:sP`),Model 指定 `dateFormat: 'c'` |
| 大數據量 Grid 卡頓 | 一次性渲染千行 DOM | 啟用 `bufferedrenderer`、分頁、虛擬滾動 |
| CSRF Token 驗證失敗 | ExtJS 未自動發送 Token | 在 `Ext.Ajax.defaultHeaders` 設定 `X-CSRF-Token` 或表單隱藏欄位 |
遵循「統一 JSON 回應格式、前端統一錯誤處理、後端參數化查詢與情境驗證」三大原則,ExtJS + PHP 專案即可建立穩健、可維護的前後端協作架構。