上一篇我們接上了前台 JavaScript 的入口 viewScript,這一篇用它做一個會動的東西:客戶評價的輪轉 Carousel,點左右箭頭可以切換不同評價,下面還有一排圓點顯示現在看到第幾則。
要做前端互動效果,傳統上你會想到 jQuery,抓 DOM、綁事件、手動改 class。但 WordPress 6.5 之後有了官方的解法 Interactivity API,寫法比 jQuery 乾淨很多,核心區塊(搜尋、導覽、圖片燈箱)跟 WooCommerce 的迷你購物車都是用這套做的。
用 jQuery 做 Carousel 要自己 querySelector 抓元素、addEventListener 綁點擊、用變數記住現在第幾張,再手動去改每張的顯示隱藏,jQuery 沒寫好很容易造成狀態難以管理,還有可能因為多個 Carousel 同時存在同一頁面而互相干擾。
Interactivity API 換成「宣告式」的思路:你在 HTML 上用 data-wp- 屬性標好「這個按鈕點了要做什麼」、「這個元素要跟著哪個狀態顯示或隱藏」,然後在一個 store 裡寫那些狀態和動作。狀態一改畫面自動更新,你不用手動碰 DOM。
用過 Alpine.js 的話會覺得這套很熟悉,因為 WordPress 官方就是借鏡它的理念來設計 Interactivity API。
Interactivity API 簡單來說只有三樣東西:
一、Directives(指令):寫在 HTML 標籤上的 data-wp-* 屬性,用來描述「這個元素跟哪個狀態有關」。它們是伺服器端 PHP 印出來的,不是 JavaScript 產生的。
二、Context(區域狀態):掛在 data-wp-context 上的一小包 JSON,屬於這個元素和它的子孫。每個 Carousel 有自己的一份,所以同一頁放三個 Carousel 也不會互相干擾,這正是 jQuery 寫法最容易出包的地方。
三、Store(狀態與行為):view.js 裡用 store() 註冊的物件,放 state(衍生狀態)、actions(使用者觸發的動作)、callbacks(副作用)。用 namespace 跟 HTML 上的 data-wp-interactive 對應。
常用的 directive 大致是這幾個,這次的 Carousel 會用到前四個:
| Directive | 用途 | 範例 |
|---|---|---|
data-wp-interactive |
宣告這一塊歸哪個 store 管(namespace) | data-wp-interactive="block-theme/testimonial-carousel" |
data-wp-context |
帶入這一塊的區域狀態 | data-wp-context='{"active":0}' |
data-wp-on--[event] |
綁事件到 action | data-wp-on--click="actions.next" |
data-wp-bind--[attr] |
把 HTML 屬性綁到狀態 | data-wp-bind--hidden="!state.isActive" |
data-wp-class--[name] |
依狀態加上或移除 class | data-wp-class--is-current="state.isActive" |
data-wp-text |
把元素內容綁到狀態 | data-wp-text="state.label" |
data-wp-style--[prop] |
綁 inline style | data-wp-style--color="state.color" |
data-wp-watch |
狀態變動時執行副作用 | data-wp-watch="callbacks.logIsOpen" |
data-wp-init |
元素建立時執行一次 | data-wp-init="callbacks.init" |
data-wp-each |
依陣列重複渲染 <template> |
data-wp-each="state.items" |
事件的部分不用另外背,data-wp-on-- 後面接的就是原生 DOM 事件名稱:click、keydown、input、change、submit、mouseenter 都可以,寫法一律是 data-wp-on--click="actions.next"。另外還有 data-wp-on-window--resize 與 data-wp-on-document--keydown,用來監聽 window 和 document 層級的事件。
值得注意的是 data-wp-bind--hidden="!state.isActive" 前面那個驚嘆號,directive 的值不是完整的 JavaScript 表達式,不能寫 state.a && state.b 這種運算,唯一支援的運算子就是取反的 !,要更複雜的判斷需要寫成 store 裡的一個 getter。
我們預期要做出以下的客戶評價輪轉,每次顯示一則,有左右箭頭以及圓點導覽作跳頁切換:

要用這套 API,先在 block.json 打開支援,並把前台 JS 宣告成 module:
{
"supports": { "interactivity": true },
"viewScriptModule": "file:./view.js"
}
注意這裡是 viewScriptModule 不是昨天的 viewScript。Interactivity API 走的是 ES module(import 語法),WordPress 為它準備了另一套 Script Modules 的註冊機制,跟傳統的 wp_enqueue_script 是兩條路。
這一步漏掉的話整個功能不會動:wp-scripts 預設不會編譯 viewScriptModule,要加旗標 --experimental-modules 才會:
{
"scripts": {
"build": "wp-scripts build --webpack-src-dir=blocks --output-path=build/blocks --experimental-modules",
"start": "wp-scripts start --webpack-src-dir=blocks --output-path=build/blocks --experimental-modules"
}
}
加了之後建置會多跑一輪,產出的 build/blocks/testimonial-carousel/view.asset.php 裡會看到 'type' => 'module' 和相依的 @wordpress/interactivity,這是成功的標記:
<?php return array( 'dependencies' => array( '@wordpress/interactivity' ), 'version' => 'e6792b58ae9fef9b58e9', 'type' => 'module' );
沒加旗標的話 view.js 根本不會被編譯出來,前台當然沒反應,而這種錯不會有任何錯誤訊息,只會安靜地什麼都不發生。
在 render.php 的 markup 上,用 data-wp- 屬性描述互動。做 Carousel 需要一個「現在是第幾張」的狀態,還有上一張、下一張兩個動作:
$total = count( $items );
// 整個 Carousel 的初始 context:現在顯示第幾張、總共幾張。
$carousel_context = wp_interactivity_data_wp_context(
array(
'active' => 0,
'total' => $total,
)
);
?>
<div
data-wp-interactive="block-theme/testimonial-carousel"
<?php echo $carousel_context; ?>
>
<?php foreach ( $items as $index => $item ) : ?>
<figure
<?php echo wp_interactivity_data_wp_context( array( 'index' => (int) $index ) ); ?>
data-wp-bind--hidden="!state.isActive"
>
<blockquote>「<?php echo esc_html( $item['quote'] ); ?>」</blockquote>
<figcaption><?php echo esc_html( $item['author'] ); ?></figcaption>
</figure>
<?php endforeach; ?>
<button data-wp-on--click="actions.prev">上一則</button>
<button data-wp-on--click="actions.next">下一則</button>
</div>
這裡用的是 wp_interactivity_data_wp_context() 而不是自己 wp_json_encode() 手動拼字串,它會幫你處理跳脫,直接印出完整的 data-wp-context='{"active":0,"total":3}' 屬性。
請特別留意 context 出現了兩層:外層 <div> 帶 active 和 total,每個 <figure> 各自帶自己的 index。這個設計是接下來理解 view.js 的關鍵。
最後在 view.js 定義那些動作和衍生狀態:
import { store, getContext } from '@wordpress/interactivity';
store( 'block-theme/testimonial-carousel', {
state: {
get isActive() {
const context = getContext();
return context.active === context.index;
},
},
actions: {
next() {
const context = getContext();
context.active = ( context.active + 1 ) % context.total;
},
prev() {
const context = getContext();
context.active = ( context.active - 1 + context.total ) % context.total;
},
},
} );
這段程式碼第一次看會有個疑問:context.total 這個變數是從哪裡冒出來的? view.js 從頭到尾沒有宣告過它。
答案是它來自 PHP。getContext() 讀的不是 JavaScript 裡的變數,而是上一步 render.php 印在 HTML 上那份 data-wp-context JSON。所以整條路徑是這樣:
render.php 的 wp_interactivity_data_wp_context( array( 'active' => 0, 'total' => 3 ) )
→ HTML 上的 data-wp-context='{"active":0,"total":3}'
→ view.js 裡 getContext() 回傳的 { active: 0, total: 3 }
換句話說 PHP 決定有哪些狀態、初始值是什麼,JavaScript 只負責改它。你想多一個狀態(例如自動播放的秒數),就在 PHP 那個 array 多加一個 key,view.js 立刻讀得到,不用在 JS 裡重複宣告一次。
第二個要理解的是 getContext() 會依照元素在 DOM 上的位置,回傳合併後的結果。剛才 render.php 有兩層 context,於是:
呼叫 getContext() 的元素 |
拿到的內容 |
|---|---|
上一則/下一則按鈕(在外層 <div> 裡) |
{ active, total } |
每個 <figure>(外層 + 自己那層) |
{ active, total, index } |
這就解釋了為什麼 next() 用得到 total 卻用不到 index(按鈕不屬於任何一則評價),而 isActive 這個 getter 用得到 index(它是在每個 <figure> 上求值的,各自比對自己的 index 是不是等於共用的 active)。內層有同名的 key 會蓋掉外層的,沒有的話就往上找,跟 CSS 的繼承是同一個直覺。
第三個要解釋的是 isActive 前面那個 get。它是 JavaScript 的 getter 語法,意思是「這個屬性被讀取時執行這段函式,把回傳值當成它的值」:
const obj = {
a: 1,
b: 2,
get sum() {
return this.a + this.b;
},
};
obj.sum; // 3 ← 注意不用寫 obj.sum()
obj.a = 10;
obj.sum; // 12 ← 每次讀取都重算一次
用起來像一個普通屬性(不加括號),實際上每次存取都會重新執行。這裡非用它不可的理由有兩個。
一是 HTML 上不能寫函式呼叫。directive 的值只能是屬性參照,data-wp-bind--hidden="!state.isActive()" 這種寫法不支援。如果 view.js 把 isActive 寫成一般的 method,HTML 這邊就沒有辦法呼叫它;加上 get 之後它看起來是屬性、用起來是屬性,骨子裡卻是一段會跑的邏輯。
二是 它每次求值的答案本來就不一樣。同一個 isActive,在三個 <figure> 上會得到三個不同結果:
figure #0 求值 → getContext() 回傳 { active: 0, total: 3, index: 0 } → true
figure #1 求值 → getContext() 回傳 { active: 0, total: 3, index: 1 } → false
figure #2 求值 → getContext() 回傳 { active: 0, total: 3, index: 2 } → false
getContext() 回傳什麼,取決於現在正在替哪個元素求值,所以這段邏輯不能在註冊 store 的當下算一次就定案,必須每次讀取時計算,要是寫成 isActive: false 這種固定值,三個 <figure> 拿到的答案都一樣,Carousel 就不會動了。
再加上 Interactivity API 底層用的是 signals,getter 執行時會自動記錄「我讀了 context.active 和 context.index」之後 actions.next() 一改動 context.active,runtime 就知道要重跑這個 getter、更新對應的 DOM。
官方文件把這種由其他狀態算出來的狀態叫做 derived state(衍生狀態),getter 就是它在 JavaScript 這一側的寫法。
PHP 那一側其實是同一件事,只是 PHP 沒有 getter 這種語法糖,改用 closure 表達(等一下講伺服器端渲染時就會用到):
'isActive' => function () { // ← 跟 JS 的 get isActive() 扮演同樣角色
$context = wp_interactivity_get_context();
return $context['active'] === $context['index'];
},
兩邊都是同一個意思:不要現在算,等到有人要用的時候再算。
再看 actions.next() 做的事:它只改 context.active 這個數字,其他什麼都沒碰。可是畫面上三個被 data-wp-bind--hidden 綁著的 <figure>,會各自重新求值一次 state.isActive,然後該藏的藏、該顯示的顯示。你從頭到尾沒有寫一行「抓 DOM、改 class」的程式碼,這就是宣告式的乾淨之處。
那個取餘數的 % context.total 則是讓它循環:第三則按下一則會回到第一則,第一則按上一則會跳到第三則(- 1 + total 是為了避免出現負數)。
順帶一提,下面那排圓點也是同一套邏輯,只是把 hidden 換成 class,再多一個 goTo 動作:
<button
<?php echo wp_interactivity_data_wp_context( array( 'index' => (int) $index ) ); ?>
data-wp-on--click="actions.goTo"
data-wp-class--is-current="state.isActive"
></button>
goTo() {
const context = getContext();
context.active = context.index;
},
圓點自己那層 context 有 index,而 active 是從外層繼承來的,寫入時會寫回外層那一份,所以三個圓點跟兩個箭頭改的是同一個狀態,彼此同步。
做到這裡我在瀏覽器打開頁面,畫面上一則評價都沒有,直到 JavaScript 載入完才出現,看 HTML 原始碼才發現三個 <figure> 全部都被加上了 hidden:
<figure hidden data-wp-context='{"index":0}' data-wp-bind--hidden="!state.isActive">
<figure hidden data-wp-context='{"index":1}' data-wp-bind--hidden="!state.isActive">
<figure hidden data-wp-context='{"index":2}' data-wp-bind--hidden="!state.isActive">
原因是 WordPress 在輸出頁面前,會用 PHP 先跑一遍 directives(這叫伺服器端指令處理,目的是讓沒有 JavaScript 也能看到正確的初始畫面)。但 state.isActive 那時只存在於 view.js,PHP 這邊查無此狀態,求值結果是空的,!空的 就是 true,於是三則全被判定要隱藏。
解法是在 PHP 也補一份同樣的 getter,用 wp_interactivity_state() 註冊:
// 在伺服器端也算一次同樣的判斷,讓第一則在 JS 載入前就正確顯示。
wp_interactivity_state(
'block-theme/testimonial-carousel',
array(
'isActive' => function () {
$context = wp_interactivity_get_context();
return $context['active'] === $context['index'];
},
)
);
namespace 要跟 view.js 的 store() 完全一致,PHP 這邊用 wp_interactivity_get_context() 拿 context,作用跟 JS 的 getContext() 一模一樣。加上之後重新整理 HTML 變成只有第二、第三則帶 hidden,第一則直接就在畫面上,關掉 JavaScript 也讀得到第一則評價。
Interactivity API 的 state 是前後端各寫一份的,PHP 那份負責首次渲染,JS 那份負責之後的互動。AI 產出的程式碼幾乎不會主動幫你補 PHP 這一半,因為它在瀏覽器裡「看起來會動」,錯誤只出現在 JS 載入前的那零點幾秒以及檢視原始碼的時候。
Interactivity API 的語法比較新,data-wp- 這串屬性、store 的結構,很多人(包括我)不會背,這正是交給 AI 的好時機:我會說「幫 testimonial 區塊加一個輪播,用 Interactivity API,左右箭頭加圓點指示器切換,view.js 用 store 管目前索引」,它會照官方文件把 block.json 的 supports、render.php 的標記、view.js 的 store 三段生齊。
但你 review 時注意以下事項:
data-wp-interactive="block-theme/testimonial-carousel"、store( 'block-theme/testimonial-carousel', ... )、wp_interactivity_state( 'block-theme/testimonial-carousel', ... ),名字對不上就整個沒反應,而且不會報錯。--experimental-modules 有沒有加:AI 常常只改 block.json 就說完成了,但 package.json 沒改,view.js 根本沒被編譯出來。單一區塊做到這邊差不多了,但一個網站終究不是一堆散裝的區塊堆在那裡,得把它們組成完整的頁面才有意義。下一篇我們把前面做的這些區塊,用 AI 拼成一個完整的首頁。
文章目錄:https://oberonlai.blog/category/2026-ithome/