打開瀏覽器的開發者工具,點一次某個網站的「加入收藏」按鈕,Network 分頁跳出一筆請求,狀態碼寫著 200,畫面上的數字卻完全沒有變化。檢查回應內容,只有孤零零一個「0」,沒有任何錯誤訊息可以對照。這是很多人第一次自己動手寫 WordPress 局部更新功能時會撞到的畫面。按鈕確實按下去了,網址列也沒有跳轉,畫面卻像被凍住一樣,而這個「0」比任何錯誤訊息都更讓人摸不著頭緒。
這種按一個按鈕、畫面局部更新、網址列卻完全沒變的做法,統稱為 AJAX(Asynchronous JavaScript and XML)。WordPress 沒有為它另外發明一套機制,而是提供 wp-admin/admin-ajax.php 這一支固定檔案,當作前台與後台共用的唯一入口。不管是哪個外掛或主題發出的請求,只要帶對 action 參數,核心就會把請求轉發給對應的 PHP 函式處理。整套流程固定只有 5 個步驟,漏掉其中一步,例如忘記驗證身份、忘記回傳格式,畫面就會停在送出了卻沒有反應,或直接吐回前面那個令人困惑的「0」。
搞懂這 5 步彼此的先後關係,不管是要做按讚、載入更多文章,還是表單送出後局部刷新,都能照同一套邏輯寫出來,也能在功能出問題時知道該從哪裡查起。先從 admin-ajax.php 這支檔案本身在做什麼講起,再一步一步往下拆。
WordPress AJAX 是什麼?
「加入購物車」的數字跳動、留言送出後頁面不刷新、點「載入更多」文章不斷出現,這些操作背後其實都是同一件事,瀏覽器在背景送出一個 HTTP 請求,等後端處理完再把回應塞回目前的畫面,網址列全程不會變動。這套做法在網頁開發圈統稱為 AJAX,WordPress 並沒有為它另外發明一套專屬機制,而是把所有請求都導向同一支檔案,也就是 wp-admin/admin-ajax.php。
這支檔案的角色單純到近乎笨拙,它自己不處理任何業務邏輯,做的只是接收送進來的請求、讀出裡面的 action 參數值,再依這個值把請求轉發給對應的 PHP 函式而已。請求多半用 POST 方法送出,不管是哪個外掛在處理購物車、哪個主題在處理留言表單,全部經過這同一個入口,核心再依 action 參數把工作分派下去。很多不相干的功能,打開瀏覽器的開發者工具去看 Network 分頁,請求的目標網址其實都長得一模一樣,差別只在 data 裡帶的 action 值不同。
WordPress 外掛開發手冊(Plugin Handbook)對這套機制講得很直接,所有 AJAX 請求都必須送到 wp-admin/admin-ajax.php,不要把請求直接送到外掛自己寫的頁面;而且要用 wp_enqueue_script() 讓 WordPress 自動把腳本連結插入頁面,不要自己在頁首樣板裡寫死連結網址。這條規則背後的用意,是讓 WordPress 統一管理腳本載入的順序與相依關係,避免不同外掛各自寫死連結互相衝突。
admin-ajax.php 本身不知道任何一個具體功能要怎麼做,它只是把 action 參數值轉換成一個固定命名的 action hook,再由外掛或主題自己掛上對應的函式去處理。換句話說,admin-ajax.php 是分派中心,不是執行者,實際處理請求的邏輯,寫在外掛或主題自己的程式碼裡。
適合用 admin-ajax.php 處理的 3 種前台情境
不是所有網頁互動都值得動用 AJAX,但確實有幾種前台情境,用 admin-ajax.php 處理起來特別順手。第一種是單一動作立即回饋,按讚、收藏、加入購物車這類操作,使用者按下去只期待畫面上某個數字或狀態馬上變化,不需要也不該整頁重新載入,每按一次讚都要等頁面刷新的話,體驗反而變差。第二種是清單的篩選與排序,例如商品依分類、標籤、價格區間切換顯示內容,但網址不需要跟著換頁;這類情境讓畫面局部換掉商品清單,比每次篩選都重新載入整個頁面來得輕量。第三種是表單送出後的局部更新,訂閱表單送出成功後只換掉表單周圍的提示文字、留言送出後只在留言列表插入新的一筆、購物車頁面輸入郵遞區號後只更新運費試算的那一小塊,使用者不需要因為一次小小的送出動作,就被丟回頁面最頂端重新開始。
這 3 種情境有一個共通點,使用者要的只是局部反應,而不是換一個頁面。反過來說,需要整頁跳轉、或內容本身需要被搜尋引擎索引的部分,就不該只靠 AJAX 呈現。AJAX 換出來的內容,是使用者點擊後才由 JavaScript 插入畫面的,搜尋引擎在抓取網頁當下未必會執行這段腳本,也未必會把這段內容當成頁面本身的內容收錄進去。如果一個分類頁面的主要商品清單只靠 AJAX 換出來,等於把這個頁面最重要的內容藏在搜尋引擎看不到的地方;這類情境應該讓網址本身也能反映篩選條件,例如透過換頁或查詢字串,而不是全部交給 AJAX 局部處理。
5 個步驟串起 admin-ajax.php 的請求與回應
一個按鈕從被按下、到畫面局部更新完成,中間固定會經過 5 個動作,而且彼此環環相扣。前端要先有腳本可以執行,腳本才能在使用者按下按鈕的當下發出請求;請求要帶著 action 與 nonce 這兩個必要參數,action 決定要交給哪個函式處理,nonce 則證明這個請求真的來自這個頁面本身,不是別人偽造的;後端接收請求後,先靠 nonce 驗證身份,再實際執行業務邏輯,最後把結果包成 JSON 傳回前端。少了前面任何一步,後面就沒有東西可以運作,沒有第一步的 enqueue,瀏覽器根本不會載入這支腳本;沒有第二步產生的 nonce,第五步的驗證就沒有東西可以核對。
這 5 步依序是:用 wp_enqueue_script() 把腳本掛進頁面、建立 nonce 並把它連同 admin-ajax.php 的網址一起交給前端、前端在按鈕被按下時組出請求送出去、後端用 wp_ajax_ 與 wp_ajax_nopriv_ 這組 hook 接收請求、最後驗證通過後用 wp_send_json 系列函式回傳結果。這 5 步任何一步順序錯了或漏掉,畫面局部更新就會停在原地沒有反應,甚至連後端有沒有真的收到請求都無法確認。

步驟一,用 wp_enqueue_script 把腳本掛進前台頁面
WordPress 依頁面類型分成 3 個專門的 hook 來掛載腳本:前台頁面用 wp_enqueue_scripts、後台管理頁用 admin_enqueue_scripts、登入頁則用 login_enqueue_scripts。三者互不相通,掛錯地方腳本就是不會出現在頁面上,這也是新手最常在這一步出錯:明明程式碼看起來沒錯,腳本卻怎麼樣都載入不了,原因往往就是掛到了錯的 hook。要在前台做局部更新功能,一律要用 wp_enqueue_scripts;這個 hook 本身不會傳入任何參數給回呼函式,需要判斷只在特定頁面載入腳本時,要靠 is_home()、is_single() 這類樣板函式自己判斷,跟後台的 admin_enqueue_scripts 會直接把當前頁面檔名傳進來不一樣。
實際呼叫時,wp_enqueue_script() 是唯一一定要執行的函式,wp_register_script() 只是可選的中介步驟,用來先註冊一個 handle 供之後重複引用。最小可用的寫法如下:
function pongo_enqueue_frontend_scripts() {
wp_enqueue_script(
'pongo-ajax-demo',
get_template_directory_uri() . '/assets/js/ajax-demo.js',
array(), // 若要用 jQuery 改成 array('jquery')
'1.0.0',
true // 印在頁尾,不擋住頁面其他資源載入
);
}
add_action('wp_enqueue_scripts', 'pongo_enqueue_frontend_scripts');Code language: PHP (php)
網址一律用 get_template_directory_uri()(主題)或 plugins_url()(外掛)組出來,不要自己寫死一段網址字串,換了網域、搬了主機、甚至只是把網站從 http 換成 https,寫死的網址都會跟著失效,用函式組出來則完全不受影響。依賴陣列決定這支腳本要不要先載入 jQuery:真的需要操作 jQuery 物件才填 array('jquery'),打算全部改用瀏覽器原生的 fetch() 則可以留空陣列,少背一個依賴,頁面也會輕一點。
步驟二,建立 nonce 並把設定值傳進 JavaScript
腳本掛進頁面後,前端還缺兩樣東西才能發出請求:admin-ajax.php 的網址,以及證明這次請求真的來自這個頁面的一次性密鑰,也就是 nonce。要把這兩樣資料從 PHP 送進 JavaScript,最常見的寫法是 wp_localize_script():
wp_localize_script('pongo-ajax-demo', 'pongoAjax', array(
'ajax_url' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('pongo_like_action'),
));Code language: PHP (php)
這段程式碼執行後,前端就能透過一個叫 pongoAjax 的全域變數,讀到 pongoAjax.ajax_url 和 pongoAjax.nonce。這個寫法語法簡單,幾乎每一篇講 WordPress AJAX 的教學都在用,但它原本是 WordPress 設計給字串翻譯用的函式,拿來傳一般資料只是借用而已。
WordPress 的函式文件現在更建議的做法,是改用 4.5 版加入的 wp_add_inline_script() 搭配 wp_json_encode():
function pongo_enqueue_frontend_scripts() {
wp_enqueue_script('pongo-ajax-demo', get_template_directory_uri() . '/assets/js/ajax-demo.js', array(), '1.0.0', true);
$pongo_ajax_data = array(
'ajax_url' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('pongo_like_action'),
);
wp_add_inline_script(
'pongo-ajax-demo',
'const pongoAjax = ' . wp_json_encode($pongo_ajax_data) . ';',
'before'
);
}
add_action('wp_enqueue_scripts', 'pongo_enqueue_frontend_scripts');Code language: PHP (php)
兩者的差別不只是語法。wp_localize_script() 用同一個物件名稱重複呼叫時,後面的呼叫會直接覆蓋前面寫進去的值,要分批塞資料只能另取物件名稱;wp_add_inline_script() 則可以對同一個 handle 呼叫多次,內容會依序疊加,比較適合一支腳本需要從好幾個地方分別塞資料進來的情境。而且 wp_add_inline_script() 要輸出什麼程式碼由自己決定,可以改用 const 宣告,不會像 wp_localize_script() 固定用 var 宣告、讓變數變成全域 window 物件的屬性;不過頂層的 const 仍然位在全域範圍,外部腳本才讀得到它,變數名稱一樣要加上自己的前綴,免得跟別的外掛或主題撞名。WordPress 核心自己在後台組 meta box 網址時,用的正是 wp_add_inline_script() 這個寫法。
不管選哪一種傳值方式,nonce 本身都是用 wp_create_nonce() 建立,參數要帶一個能代表這個動作的字串,字串寫得愈明確,保護力愈強,不要圖方便讓全部動作共用同一個字串。這裡傳進去的 pongo_like_action 這個字串,後面步驟五驗證時要原封不動再用一次,兩邊對不上,驗證一定會失敗。
步驟三,前端送出帶著 action 與 nonce 的請求
不管後端設計成什麼樣子,前端送出的請求物件裡一定要有兩個鍵:action(值要跟後端要掛載的 hook 名稱字尾對上)和 nonce(就是步驟二產生的那個值)。除了這兩個必要欄位,其他才是這次請求真正要送的資料,例如要處理的貼文 ID、要送出的表單內容。多數公開教學示範的是 jQuery 寫法:
jQuery(document).ready(function ($) {
$('.pongo-like-button').on('click', function () {
var postId = $(this).data('post-id');
$.ajax({
url: pongoAjax.ajax_url,
type: 'POST',
data: {
action: 'pongo_process_like',
nonce: pongoAjax.nonce,
post_id: postId,
},
success: function (response) {
if (response.success) {
$('.pongo-like-count').text(response.data.count);
}
},
});
});
});Code language: JavaScript (javascript)
如果這支主題或外掛打算盡量少背相依套件,也可以完全不載入 jQuery,改用瀏覽器原生的 fetch() 搭配 URLSearchParams 組出請求內容:
document.querySelectorAll('.pongo-like-button').forEach(function (button) {
button.addEventListener('click', function () {
var params = new URLSearchParams();
params.append('action', 'pongo_process_like');
params.append('nonce', pongoAjax.nonce);
params.append('post_id', button.dataset.postId);
fetch(pongoAjax.ajax_url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params,
})
.then(function (response) { return response.json(); })
.then(function (data) {
if (data.success) {
button.closest('.pongo-like-widget').querySelector('.pongo-like-count').textContent = data.data.count;
}
});
});
});Code language: JavaScript (javascript)
這 2 種寫法功能完全等價,差別只在要不要多背一個 jQuery 相依。要留意的是 fetch() 版本一定要用 URLSearchParams 組 body,並把 Content-Type 設成 application/x-www-form-urlencoded,admin-ajax.php 靠的是 PHP 原生的 $_POST 去解析請求內容,不是解析 JSON 格式的 body,如果直接把物件丟進 JSON.stringify() 當 body 送出去,後端的 $_POST 會是空的,範例程式碼本身就會是錯的。另外不管用哪一種寫法,範例都用 POST 送出,這也是一般的慣例;admin-ajax.php 雖然 GET 也收,但它不像標準的 Web API 那樣區分 GET、PUT、DELETE 各自的語意,所有操作,不管是查詢還是新增,都送到同一支檔案,差別只在 action 參數帶的值不同。
步驟四,用 wp_ajax_ 與 wp_ajax_nopriv_ 掛住請求
後端要接收步驟三送出的請求,靠的是一套固定的命名規則,把 action 參數的值接在 wp_ajax_ 後面,組成 hook 名稱。如果步驟三送出的 action 是 pongo_process_like,後端就要這樣掛:
function pongo_handle_like_request() {
// 處理邏輯寫這裡
}
add_action('wp_ajax_pongo_process_like', 'pongo_handle_like_request');Code language: PHP (php)
這一步最容易被忽略的地方是,wp_ajax_ 開頭的 hook 只對已登入的使用者生效。如果這個功能是給前台所有訪客用的,例如公開文章頁面的按讚按鈕,未登入的訪客理論上也該按得動,就一定要另外多掛一個 wp_ajax_nopriv_pongo_process_like,兩個 hook 可以指向同一個處理函式:
add_action('wp_ajax_pongo_process_like', 'pongo_handle_like_request');
add_action('wp_ajax_nopriv_pongo_process_like', 'pongo_handle_like_request');Code language: PHP (php)
只掛了 wp_ajax_ 版本、漏掉 nopriv 版本,是很多人踩過的坑。功能在自己登入狀態下測試起來完全正常,一上線讓訪客實際使用,按下去卻毫無反應,因為訪客的請求根本沒有對應的 hook 可以接收,admin-ajax.php 找不到任何處理函式,只能回傳前面提過的那個孤零零的「0」。寫這一步之前,先想清楚這個功能的受眾是不是包含未登入的訪客,兩個 hook 要嘛同時掛上,要嘛清楚知道自己刻意只服務登入使用者。
步驟五,驗證通過後用 wp_send_json 回傳結果
請求送到後端後,收尾這一步要固定照順序做 3 件事:先驗證 nonce、再視情況檢查權限、最後才回傳結果。驗證 nonce 用 check_ajax_referer(),把步驟二建立 nonce 時用的同一個動作字串傳進去:
function pongo_handle_like_request() {
check_ajax_referer('pongo_like_action', 'nonce');
// 通過驗證才會執行到這裡
}Code language: PHP (php)
要留意的是 check_ajax_referer() 沒指定第二個參數時,預設只會去 $_REQUEST['_ajax_nonce'] 或 $_REQUEST['_wpnonce'] 這兩個鍵名找值,不會自動認得前面步驟三送出的 nonce 這個鍵名。不管前端送出的資料用什麼鍵名裝 nonce,這裡都要把同一個鍵名當第二個參數傳進去;兩邊對不上,就算 nonce 本身沒過期,驗證一樣會失敗,這是很多人抄範例程式碼時漏掉、卻查半天查不出原因的地方。check_ajax_referer() 驗證失敗時會自動中止腳本執行、直接回應 403 Forbidden,不用自己再寫一段失敗處理的程式碼。
但 nonce 驗證通過,不代表這個使用者有權限執行這個動作,nonce 只證明這個請求真的是從網站自己頁面上的表單或腳本發出的,不代表發出請求的這個人就有權限做這件事。牽涉權限的動作(例如修改或刪除內容),一定要另外用 current_user_can() 檢查,兩件事分開做。按讚這種開放給未登入訪客的功能沒有權限可以檢查,要是在這裡用 current_user_can() 擋,訪客的請求會一律失敗,等於白掛了 nopriv 那個 hook;這種情況要改成檢查傳進來的資料合不合法,例如文章是否存在、是否已公開:
function pongo_handle_like_request() {
check_ajax_referer('pongo_like_action', 'nonce');
$post_id = isset($_POST['post_id']) ? absint($_POST['post_id']) : 0;
if (!$post_id || get_post_status($post_id) !== 'publish') {
wp_send_json_error(array('message' => '找不到這篇文章'));
}
// 實際的按讚邏輯寫在這裡,例如更新自訂欄位的數字
wp_send_json_success(array('count' => 43));
}
add_action('wp_ajax_pongo_process_like', 'pongo_handle_like_request');
add_action('wp_ajax_nopriv_pongo_process_like', 'pongo_handle_like_request');Code language: PHP (php)
wp_send_json_success() 與 wp_send_json_error() 會把資料轉成結構化的 JSON 印出,前端只要判斷 response.success 是 true 還是 false,就知道這次請求成功還是失敗,不用自己再拆解回應格式。這兩個函式,連同底層的 wp_send_json(),都會在印出內容後自動呼叫 wp_die() 收尾。如果不是用這系列函式、而是自己 echo 內容,結尾就一定要自己呼叫 wp_die(),漏了這一步,WordPress 核心會在回應後面自動多印出一個 0 字元,前端解析回應的時候就會拿到這個多餘的字元,正是引言提到的那個畫面的成因。
從 5 個步驟到一個能用的載入更多按鈕
把前面 5 個步驟串成一個讀者能直接複製使用的功能,比抽象的按下去跳出提示訊息更有說服力,這裡用點擊按鈕載入更多文章、網址列不跳轉、頁面也不刷新這個很多網站真的在用的功能當例子。後端要先把目前是第幾頁、總共有幾頁透過 inline script 傳給前端:
function pongo_enqueue_load_more_assets() {
if (!is_home() && !is_archive()) {
return;
}
global $wp_query;
wp_enqueue_script('pongo-load-more', get_template_directory_uri() . '/assets/js/load-more.js', array(), '1.0.0', true);
$pongo_load_more_data = array(
'ajax_url' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('pongo_load_more_action'),
'current_page' => max(1, get_query_var('paged')),
'max_pages' => $wp_query->max_num_pages,
);
wp_add_inline_script(
'pongo-load-more',
'const pongoLoadMore = ' . wp_json_encode($pongo_load_more_data) . ';',
'before'
);
}
add_action('wp_enqueue_scripts', 'pongo_enqueue_load_more_assets');Code language: PHP (php)
網頁上放一個按鈕,只有還有下一頁時才顯示:
<?php if ($wp_query->max_num_pages > 1) : ?>
<button id="pongo-load-more-btn" data-page="1">載入更多文章</button>
<?php endif; ?>Code language: PHP (php)
按鈕被點擊時,前端組出請求,帶著 action、nonce、目前頁碼送出去,成功後把新內容接在既有清單後面,並更新按鈕記住的頁碼:
document.getElementById('pongo-load-more-btn')?.addEventListener('click', function () {
var button = this;
var nextPage = parseInt(button.dataset.page, 10) + 1;
var params = new URLSearchParams();
params.append('action', 'pongo_load_more_posts');
params.append('nonce', pongoLoadMore.nonce);
params.append('page', nextPage);
fetch(pongoLoadMore.ajax_url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params,
})
.then(function (response) { return response.json(); })
.then(function (data) {
if (!data.success) return;
document.getElementById('pongo-post-list').insertAdjacentHTML('beforeend', data.data.html);
button.dataset.page = nextPage;
if (nextPage >= pongoLoadMore.max_pages) {
button.remove();
}
});
});Code language: JavaScript (javascript)
後端接收請求後,用 WP_Query 重新查出對應頁碼的文章,直接輸出組好的 HTML 字串,不回傳陣列或物件,讀者拿到的就是可以直接塞進頁面的現成內容,前端不用再自己組一次樣板:
function pongo_handle_load_more_posts() {
check_ajax_referer('pongo_load_more_action', 'nonce');
$page = isset($_POST['page']) ? absint($_POST['page']) : 1;
$query = new WP_Query(array(
'post_type' => 'post',
'posts_per_page' => 6,
'paged' => $page,
));
ob_start();
while ($query->have_posts()) {
$query->the_post();
get_template_part('template-parts/content', 'excerpt');
}
wp_reset_postdata();
$html = ob_get_clean();
wp_send_json_success(array('html' => $html));
}
add_action('wp_ajax_pongo_load_more_posts', 'pongo_handle_load_more_posts');
add_action('wp_ajax_nopriv_pongo_load_more_posts', 'pongo_handle_load_more_posts');Code language: PHP (php)
按鈕上記住的頁碼等於或超過 max_pages 那一刻,前端直接把按鈕整個移除,不能讓訪客點了一個沒有反應的按鈕,那種點了沒事發生的體驗,比根本沒有這個按鈕還更讓人困惑。這個流程從前台的 HTML、JS 到後端的 PHP,正好走完前面 5 個步驟:先掛腳本、傳設定值、送請求、後端接收、驗證後回傳,缺一步都跑不起來。
AJAX 請求回傳 0、400 或 403,最常見的 3 個原因
照著前面的步驟寫完,第一次測試卻常常還是撞到問題,最常見的 3 個原因,每一個都有辦法快速確認,也都有對應的修法。第一個原因要拆成兩種情況看:一種是 action 名稱前後端兜不起來、或漏掛對應的 wp_ajax_ / wp_ajax_nopriv_ hook,這時候 admin-ajax.php 完全找不到能處理這個 action 的函式,會直接回傳 400 狀態碼,內容是孤零零一個 0;另一種是 hook 有掛對、處理函式也真的執行了,卻忘記呼叫 wp_die(),也沒有用 wp_send_json 系列函式收尾,這時請求會一路跑到 admin-ajax.php 檔案最後那行預設的收尾動作,狀態碼通常是 200,內容一樣是孤零零一個 0。這兩種情況打開瀏覽器開發者工具的 Network 分頁都看得到請求真的送出去了,差別要看狀態碼;前面提過 wp_send_json_success() 與 wp_send_json_error() 會自動處理收尾,只要確定 action 名稱前後端一致、wp_ajax_ 與 wp_ajax_nopriv_ 都有掛好,並且一定用這系列函式收尾,這個原因通常就能排除。
第二個原因是 nonce 過期,或跟目前的頁面對不上。nonce 的有效期預設落在 12 到 24 小時之間,實際長度要看建立當下落在哪個 12 小時區段,如果頁面被快取外掛存成靜態內容,裡面嵌的 nonce 也會一起被凍結在那個當下,使用者隔天打開的仍然是快取住的舊頁面,帶著已經過期的 nonce,check_ajax_referer() 驗證當然會失敗。排查這個原因,一樣先看 Network 分頁,check_ajax_referer() 驗證失敗固定回傳 403 狀態碼;確認方式是清除頁面快取後重新整理再試一次,如果功能就恢復正常,問題幾乎可以確定出在快取住的舊 nonce。
第三個原因是快取外掛或 CDN 把 admin-ajax.php 本身也當成一般內容處理掉。admin-ajax.php 本質上是一支動態端點,每次都該重新執行,不該被當成靜態資源快取,但有些快取設定或安全性規則會把帶著特定參數的 POST 請求一併擋下,或是把回應快取住,導致請求根本沒有真正送到 PHP 那一端去執行。這種情況從 Network 分頁看,請求狀態碼可能正常,但回應內容看起來像是快取過的舊結果,或是完全沒有預期中的資料;確認方法是暫時停用快取外掛或調整 CDN 規則後重新測試。

3 個原因用的排查手法是共通的,先開開發者工具的 Network 分頁,看請求送出去了沒有、狀態碼是什麼、回應內容長怎樣,縮小範圍後再往下查。如果懷疑後端處理函式本身噴出了看不到的 PHP 錯誤,在 wp-config.php 裡把 WP_DEBUG 與 WP_DEBUG_LOG 都設成 true,錯誤與警告就會被寫進 wp-content/debug.log,可以直接打開這個檔案確認後端在驗證或處理過程中是不是噴了錯誤。
功能複雜或要對外開放時,REST API 是更值得評估的選項
admin-ajax.php 好用,但也有幾個動手寫久了會感受到的局限。第一,請求慣例一律走 POST,就算改用 GET 也只是換個傳參數的方式,沒有 GET、PUT、DELETE 各自對應的語意,查詢資料跟修改資料在同一支檔案裡長得一模一樣,全靠 action 參數的命名去區分,規模一大就容易亂。第二,action 說到底只是一個字串,本身沒有內建的路由機制,也沒有參數驗證,傳進來的 post_id 是不是數字、是不是必填,這些檢查全部要自己手動寫,忘記檢查也不會有任何提示。第三,每一次 admin-ajax.php 的請求都會載入完整的 WordPress 環境,跟一般前台頁面的請求一樣重,就算這次請求只是要查一個簡單的數字,後端也得把整套 WordPress 核心與已啟用的外掛都跑一遍。
WordPress 自 4.7 版(2016 年)起,REST API 已經是核心內建的一部分,不需要額外安裝任何外掛。用 register_rest_route() 註冊一支路由時,參數陣列通常要包含 methods 和 callback,如果這支路由要開放給所有人存取,permission_callback 也要明確設定,例如直接填 __return_true:
add_action('rest_api_init', function () {
register_rest_route('pongo/v1', '/likes/(?P<id>\d+)', array(
'methods' => 'POST',
'callback' => 'pongo_rest_handle_like',
'permission_callback' => '__return_true',
));
});Code language: PHP (php)
光是一定要填 permission_callback 這一點,就逼著寫這支路由的人一開始就想清楚權限規則是什麼,不像 admin-ajax.php 那樣,權限檢查很容易被寫程式碼的人順手漏掉。路由註冊後會自動出現在 /wp-json/ 底下,不只自己人方便除錯,也方便未來要給別的應用程式或前端框架呼叫。
該選哪一個,判斷原則並不複雜。如果只是單純為自己的主題做一兩個小互動,按讚、載入更多文章、表單送出後的局部提示,admin-ajax.php 仍然夠用,學習成本也比較低,前面 5 個步驟寫熟了就能重複套用。但如果這個功能以後可能要給別的應用程式呼叫,或者本來就是在寫一支要供其他網站使用的外掛,直接從一開始就用 REST API,會比日後從 admin-ajax.php 搬家來得省事。
寫到最後,admin-ajax.php 從來不是一支需要害怕的檔案,它固定只做轉發這件事,真正決定功能會不會順利跑起來的,是前面那 5 個環環相扣的步驟有沒有照順序、照規矩做完。掛錯 hook、漏掉 nopriv、忘記驗證 nonce,這些錯誤都不會讓 WordPress 直接當掉,只會讓畫面停在一個說不清楚原因的沉默狀態,這也是為什麼先搞懂每一步的因果關係,比死背程式碼語法更重要。等到有一天這支檔案開始裝不下愈來愈複雜的權限規則,或是外部系統也想串接進來,那就是評估 REST API 的時機;在那之前,把這 5 步做好,admin-ajax.php 依然是最直接、成本最低的做法。
