Ett interaktivt block i WordPress behöver inte ett eget JavaScript-ramverk, och det behöver inte npm. Sedan WordPress 6.5 finns Interactivity API i kärnan: du skriver blockets HTML i render.php, märker upp den med data-wp--attribut och lägger logiken i en liten ES-modul som importerar store från @wordpress/interactivity. Servern renderar starttillståndet, och webbläsaren tar över när modulen laddats.
Blocket nedan är ett ”Visa mer”: en rubrik som alltid syns, en knapp och en text som är hopfälld tills besökaren klickar. Jag bygger den sortens block i kundprojekt när produktsidor och villkorstexter blir långa. Hela koden finns i inlägget, är skriven utan byggsteg och testad i WordPress 7.1. Den ligger i ett eget litet plugin, inte i temat, så att blocket finns kvar vid ett temabyte.
Det här bygger vi
Besökaren ser en rubrik, till exempel ”Så räknar vi ut fraktkostnaden”, och en knapp med texten ”Visa mer”. Ett klick fäller ut texten, byter knappens etikett till ”Visa mindre” och sätter aria-expanded till true så att skärmläsare förstår vad som hände. Två block på samma sida fälls ut oberoende av varandra.
Pluginet består av sju filer.
wpu-visa-mer/
├── wpu-visa-mer.php registrerar blocket
└── visa-mer/
├── block.json metadata, attribut, viewScriptModule
├── render.php HTML med direktiv, körs på servern
├── view.js ES-modul med store och actions
├── view.asset.php beroendet @wordpress/interactivity
├── index.js redigeraren, utan JSX
└── index.asset.php redigerarens beroenden
Inget av det kompileras. Filerna laddas som de är.
Så hänger delarna ihop
Interactivity API skiljer på två sorters data. Context är lokal för ett element och dess barn, och det är där isOpen för ett enskilt block hör hemma. State är global för en namnrymd och delas av alla block på sidan, så där lägger jag knappens etiketter. Namnrymden är en vanlig sträng, här wpu/visa-mer, och den binder ihop tre saker: attributet data-wp-interactive i HTML:en, anropet store() i view.js och wp_interactivity_state() i PHP.
Direktiven är attribut som pekar på värden i context eller state. data-wp-bind--hidden="context.isOpen" sätter attributet hidden när värdet är sant och tar bort det när värdet är falskt. data-wp-class--ar-oppen gör samma sak med en CSS-klass, data-wp-text fyller elementets text, och data-wp-on--click="actions.toggle" kopplar klicket till en funktion i storen. Ett utropstecken framför vägen vänder värdet, så !context.isOpen fungerar som negation.
Det som gör API:t värt besväret är att samma direktiv utvärderas två gånger. Först på servern, när render_block kör HTML:en genom wp_interactivity_process_directives() och skriver in hidden, aria-expanded="false" och knapptexten i markupen. Sedan i webbläsaren, där modulen läser samma attribut och tar över. Besökaren får färdig HTML direkt och ingen omritning när skriptet laddas.
Varför inte tio rader vanlig JavaScript?
En utfällning går att skriva med en addEventListener i ett klassiskt skript, och för ett enda block är det inte fel. Jag väljer ändå Interactivity API av tre skäl. Starttillståndet renderas på servern med samma regler som webbläsaren sedan följer, så jag slipper skriva logiken två gånger, en gång i PHP för första visningen och en gång i JavaScript för klicket. Kärnans egna block, som navigationen, sökblocket och frågeloopen, använder samma API, så ett eget block beter sig som de och kan dela state med dem. Och modulen laddas bara på sidor där blocket faktiskt finns, med låg prioritet och i sidfoten, utan att jag behöver skriva en rad wp_enqueue_script med villkor.
Det som talar emot är ett beroende till: kärnans modul för Interactivity API laddas på sidan, den minifierade filen i WordPress 7.1 är 40 kB, omkring 15 kB gzip-komprimerad. På en sajt med hård prestandabudget väger jag det mot vad blocket ger. För en sida som redan använder navigationsblocket är modulen där ändå.
Steg 1 – pluginfilen
Huvudfilen gör en sak: registrerar blocket på init. Sökvägen pekar på mappen med block.json, och register_block_type() läser allt annat därifrån, inklusive vilka skript som ska laddas och var render.php ligger.
<?php
/**
* Plugin Name: WPU Visa mer
* Description: Ett hopfällbart textblock byggt med Interactivity API, utan byggsteg.
* Requires at least: 6.5
* Requires PHP: 8.2
* Text Domain: wpu-visa-mer
*/
defined( 'ABSPATH' ) || exit;
// The block directory holds block.json, so register_block_type() reads everything from there.
add_action( 'init', 'wpu_visa_mer_register_block' );
function wpu_visa_mer_register_block() {
register_block_type( __DIR__ . '/visa-mer' );
}
Har du ett plugin med flera block loopar du över mapparna på samma sätt.
Steg 2 – block.json
Tre rader i block.json är specifika för Interactivity API. "interactivity": true under supports talar om för WordPress att blockets HTML ska köras genom direktivprocessorn på servern. viewScriptModule registrerar view.js som en skriptmodul, inte som ett klassiskt skript, vilket är vad som gör import möjligt. render pekar på PHP-filen som skapar HTML:en.
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "wpu/visa-mer",
"title": "Visa mer",
"category": "text",
"icon": "arrow-down-alt2",
"description": "En hopfälld text som fälls ut med en knapp.",
"textdomain": "wpu-visa-mer",
"attributes": {
"rubrik": { "type": "string", "default": "" },
"text": { "type": "string", "default": "" }
},
"supports": {
"html": false,
"interactivity": true
},
"editorScript": "file:./index.js",
"viewScriptModule": "file:./view.js",
"render": "file:./render.php"
}
apiVersion 3 är den senaste versionen av block-API:t och kom i WordPress 6.3. Den gör att redigeraren renderar blocket inuti sin iframe, med temats stilar och utan att admin-CSS läcker in. "html": false tar bort valet att redigera blocket som HTML, vilket inte är meningsfullt för ett block som saknar sparad HTML.
Attributen rubrik och text sparas i blockkommentaren i inläggets innehåll, eftersom blocket är dynamiskt och inte har någon egen sparad HTML. Det är också därför det går att skapa inlägg med blocket från kommandoraden, vilket jag använder i testet längre ned.
När supports.interactivity är true registrerar WordPress dessutom modulen med fetchpriority="low" och lägger den i sidfoten. Det syns i kärnans register_block_script_module_id() och är avsiktligt: blocket är serverrenderat, så skriptet ligger inte på den kritiska vägen.
Steg 3 – render.php med direktiven
Här skapas allt besökaren ser. Jag börjar med att göra variablerna säkra att läsa även om filen laddas utanför blockkontext, sätter den globala staten med knappetiketterna och bygger omslagsattributen med get_block_wrapper_attributes(), som också ger blocket klassen wp-block-wpu-visa-mer. Kontexten skrivs med wp_interactivity_data_wp_context(), som JSON-kodar arrayen och returnerar hela attributet, redo att skriva ut.
<?php
/**
* Render: wpu/visa-mer. Everything the visitor sees is produced here;
* view.js only flips context.isOpen.
*
* @var array $attributes Block attributes.
*/
$attributes = $attributes ?? array();
$rubrik = $attributes['rubrik'] ?? '';
$text = $attributes['text'] ?? '';
// Global state is shared by every block instance on the page. Here it only holds the button labels.
wp_interactivity_state(
'wpu/visa-mer',
array(
'visaText' => __( 'Visa mer', 'wpu-visa-mer' ),
'doljText' => __( 'Visa mindre', 'wpu-visa-mer' ),
)
);
$wrapper = get_block_wrapper_attributes(
array(
'class' => 'wpu-visa-mer',
'data-wp-interactive' => 'wpu/visa-mer',
)
);
?>
<div <?php echo $wrapper; // phpcs:ignore WordPress.Security.EscapeOutput ?> <?php echo wp_interactivity_data_wp_context( array( 'isOpen' => false ) ); // phpcs:ignore WordPress.Security.EscapeOutput ?>>
<?php if ( '' !== $rubrik ) : ?>
<p class="wpu-visa-mer__rubrik"><?php echo esc_html( $rubrik ); ?></p>
<?php endif; ?>
<button
type="button"
class="wpu-visa-mer__knapp"
data-wp-on--click="actions.toggle"
data-wp-bind--aria-expanded="context.isOpen"
data-wp-class--ar-oppen="context.isOpen"
>
<span data-wp-bind--hidden="context.isOpen" data-wp-text="state.visaText"></span>
<span data-wp-bind--hidden="!context.isOpen" data-wp-text="state.doljText"></span>
</button>
<div class="wpu-visa-mer__text" data-wp-bind--hidden="!context.isOpen">
<?php echo wp_kses_post( $text ); ?>
</div>
</div>
Knappen har två spann, ett per etikett, och bara ett av dem är synligt åt gången. Det är enklare än att räkna ut etiketten i kod, och det fungerar utan någon härledd state. Alternativet med en härledd knappText visar jag under ”Nästa steg”.
Värdet för aria-expanded är en boolean i context, men attributet blir strängen "false" i HTML:en. Kärnan gör den omvandlingen för alla aria-– och data--attribut, både på servern och i webbläsaren, så att de två renderingarna blir identiska.
Steg 4 – view.js och view.asset.php
Modulen är kort. store() registrerar namnrymden och dess actions, och getContext() ger den context som gäller för elementet som klickades. Att ändra context.isOpen räcker: varje direktiv som läser värdet uppdateras av sig självt.
En action som kopplas med data-wp-on--click får händelseobjektet som första argument, så toggle( event ) fungerar om du behöver event.preventDefault(), till exempel när knappen är en länk som ska fungera utan JavaScript. Returvärdet ignoreras. Behöver du veta vilket element som klickades ger getElement() dig en referens och dess attribut.
import { store, getContext } from '@wordpress/interactivity';
store( 'wpu/visa-mer', {
actions: {
toggle() {
// Context is local to this block instance, so two blocks never affect each other.
const context = getContext();
context.isOpen = ! context.isOpen;
},
},
} );
Importen @wordpress/interactivity är en bar modulspecifikation, inte en URL. Webbläsaren kan bara lösa upp den om sidan har en importkarta som pekar @wordpress/interactivity på kärnans fil, och WordPress bygger importkartan av de beroenden som är registrerade för de moduler som laddas. Med wp-scripts hamnar beroendet i en genererad view.asset.php. Utan byggsteg skriver du den själv.
<?php
// No build step: tell WordPress that view.js imports @wordpress/interactivity,
// so the import map on the page can resolve it.
return array(
'dependencies' => array( '@wordpress/interactivity' ),
'version' => '0.1.0',
);
Filen ska heta exakt som modulen med .asset.php i stället för .js, och ligga i samma mapp. Kärnan letar efter den i register_block_script_module_id() och använder version för cache-busting i modulens URL.
Steg 5 – redigeraren utan byggsteg
I redigeraren behöver blocket bara två fält. Jag använder TextControl för rubriken och RichText för texten, så att fetstil och länkar följer med. Utan JSX skrivs komponenterna med wp.element.createElement, som jag kortar till el. save returnerar null eftersom servern sköter renderingen.
( function ( wp ) {
var el = wp.element.createElement;
var useBlockProps = wp.blockEditor.useBlockProps;
var RichText = wp.blockEditor.RichText;
var TextControl = wp.components.TextControl;
wp.blocks.registerBlockType( 'wpu/visa-mer', {
edit: function ( props ) {
var a = props.attributes;
var bp = useBlockProps( { className: 'wpu-visa-mer' } );
return el(
'div',
bp,
el( TextControl, {
label: 'Rubrik (syns alltid)',
value: a.rubrik,
onChange: function ( v ) { props.setAttributes( { rubrik: v } ); },
} ),
el( RichText, {
tagName: 'p',
className: 'wpu-visa-mer__text',
placeholder: 'Texten som är hopfälld tills besökaren klickar …',
value: a.text,
onChange: function ( v ) { props.setAttributes( { text: v } ); },
} )
);
},
// Dynamic block: the front end is rendered by render.php.
save: function () { return null; },
} );
} )( window.wp );
Redigerarskriptet är ett klassiskt skript, inte en modul, och det behöver sina beroenden på samma sätt som view.js. Också här ersätter en handskriven fil det som wp-scripts annars genererar.
<?php
// No build step: declare the editor dependencies by hand.
return array(
'dependencies' => array( 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components' ),
'version' => '0.1.0',
);
Blocket saknar CSS med flit. Knappen och texten ärver temats stilar, och vill du styla dem hör det hemma i temat, med klasserna wpu-visa-mer__knapp, ar-oppen och wpu-visa-mer__text.
Alla sju filerna finns färdiga i en zip:
Testa blocket
Jag testar interaktiva block i två steg, först utan webbläsare. Aktivera pluginet, skapa ett publicerat inlägg med blocket från kommandoraden och hämta sidan med curl.
wp plugin activate wpu-visa-mer
wp post create --post_status=publish --post_title='Test' --porcelain
--post_content='<!-- wp:wpu/visa-mer {"rubrik":"Så räknar vi ut fraktkostnaden","text":"Frakten baseras på <strong>vikt</strong> och postnummer."} /-->'
curl -skL https://din-sajt.test/?p=ID | grep -o 'data-wp-[a-z-]*="[^"]*"' | sort -u
I HTML:en ska fyra saker finnas. Omslaget med data-wp-interactive="wpu/visa-mer" och data-wp-context='{"isOpen":false}'. Serverns utvärdering av direktiven: aria-expanded="false" på knappen, hidden på det andra spannet och på textrutan, och etiketterna ifyllda. Ett <script type="module"> som pekar på view.js, och en <script type="importmap"> som pekar @wordpress/interactivity på kärnans modul. Och sist en <script type="application/json"> med id wp-script-module-data-@wordpress/interactivity, där staten med visaText och doljText ligger serialiserad.
Steg två är ett klick i webbläsaren. Jag öppnar sidan, klickar och kontrollerar att aria-expanded blir true, att klassen ar-oppen läggs på knappen och att texten syns. Ett klick till ska vända allt tillbaka. Konsolen ska vara tom. Det är det enda stället i hela bygget där jag behöver en riktig webbläsare, och det räcker att göra det en gång per block. Testinlägget raderar jag efteråt med wp post delete ID --force, så att det inte ligger kvar publicerat.
Fallgropar
Den fil som oftast saknas är view.asset.php. Dokumentationen nämner den inte, eftersom wp-scripts skapar den åt dig, men utan den vet WordPress inte att modulen beror på @wordpress/interactivity. Modulen laddas ändå, men importkartan saknar posten, och importen misslyckas i webbläsaren med ett fel om att modulspecifikationen inte går att lösa upp. Blocket ser korrekt ut, eftersom servern renderat starttillståndet, men inget händer vid klick.
Nästa är supports.interactivity. Glömmer du den körs direktiven inte på servern, och HTML:en skickas ut utan hidden och utan aria-expanded. Blocket fungerar när skriptet laddats, men besökaren ser hela texten utfälld ett ögonblick och får en omritning. Lägg in stödet från början.
Direktiv kan inte innehålla uttryck. data-wp-bind--hidden="!context.isOpen" fungerar eftersom negation med utropstecken är den enda operatorn som finns, men context.antal > 3 gör det inte. Behöver du en beräkning lägger du den som härledd state i storen och pekar direktivet dit.
Versionen i view.asset.php hamnar som ?ver=0.1.0 i modulens URL, och det är den som avgör om besökarens webbläsare hämtar en ny fil. Ändrar du view.js utan att räkna upp versionen ligger den gamla modulen kvar i cachen hos återkommande besökare, och felsökningen blir förvirrande eftersom allt fungerar i ditt eget inkognitofönster. Jag räknar upp versionen i samma commit som ändrar modulen.
Sist en sak om RichText i ett dynamiskt block. Värdet sparas som en HTML-sträng i blockkommentaren, och det är därför render.php kör det genom wp_kses_post(). Hoppar du över det kan en redaktör med lägre behörighet få in skript i sidan.
Nästa steg
Två saker brukar jag lägga till när det här blocket är på plats.
Den första är härledd state, så att knappens etikett räknas ut i stället för att växla mellan två spann. En getter i JavaScript och en closure i PHP med samma namn ger samma resultat på servern och i webbläsaren. PHP-delen kräver WordPress 6.6, där wp_interactivity_get_context() kom och där namnrymden får utelämnas inuti en getter.
<?php
// Requires WordPress 6.6: derived state in PHP and wp_interactivity_get_context().
// The closure runs while the server processes data-wp-text="state.knappText".
wp_interactivity_state(
'wpu/visa-mer',
array(
'visaText' => __( 'Visa mer', 'wpu-visa-mer' ),
'doljText' => __( 'Visa mindre', 'wpu-visa-mer' ),
'knappText' => function () {
$state = wp_interactivity_state();
$context = wp_interactivity_get_context();
return ! empty( $context['isOpen'] ) ? $state['doljText'] : $state['visaText'];
},
)
);
import { store, getContext } from '@wordpress/interactivity';
const { state } = store( 'wpu/visa-mer', {
state: {
get knappText() {
return getContext().isOpen ? state.doljText : state.visaText;
},
},
actions: {
toggle() {
const context = getContext();
context.isOpen = ! context.isOpen;
},
},
} );
Knappen blir då ett enda spann med data-wp-text="state.knappText". Closures serialiseras inte till webbläsaren, så gettern i JavaScript måste finnas, annars blir etiketten tom efter första klicket. Closuren körs bara medan servern bearbetar ett direktiv; anropar du wp_interactivity_get_context() någon annanstans loggar kärnan ett _doing_it_wrong och returnerar en tom array.
Den andra är flera block som delar en store. Alla anrop till store( 'wpu/visa-mer', … ) slås ihop, oavsett vilken fil de ligger i, och global state är gemensam för hela sidan. Vill du ha en knapp som fäller ihop alla ”Visa mer”-block på en gång lägger du isOpen i state i stället för i context, nycklat på ett id per block som render.php skriver ut, och låter en action i ett annat block gå igenom nycklarna. Context är rätt så länge blocken är oberoende av varandra, state är rätt så fort de ska påverka varandra.
Vanliga frågor
Vilken WordPress-version krävs?
6.5 eller senare. Där kom både Interactivity API och stödet för viewScriptModule i block.json. Den härledda statusen i PHP under ”Nästa steg” kräver 6.6.
Måste jag använda npm och wp-scripts?
Nej. Dokumentationen utgår från wp-scripts, men allt den bygger går att skriva för hand: redigerarens index.js med wp.element.createElement, en handskriven index.asset.php och en handskriven view.asset.php som anger @wordpress/interactivity som beroende.
Varför får jag ”Failed to resolve module specifier @wordpress/interactivity” i konsolen?
Filen view.asset.php saknas eller anger inte beroendet. Utan den vet WordPress inte att view.js importerar modulen, och då hamnar den inte i sidans importkarta. Kontrollera att filen ligger bredvid view.js och returnerar dependencies med ’@wordpress/interactivity’.
Kan jag använda samma namnrymd i flera block?
Ja. Alla anrop till store() med samma namnrymd slås ihop till en gemensam store, och global state delas mellan blocken. Det som ska vara eget per block lägger du i context.
Fungerar blocket utan JavaScript?
Delvis. Servern renderar starttillståndet, så rubriken syns och texten är dold. Själva utfällningen kräver att view.js laddas. Vill du ha en utfällning som fungerar helt utan JavaScript är HTML-elementet details rätt verktyg i stället.
Källor
- Interactivity API, Block Editor Handbook
- API Reference: direktiv och store, Block Editor Handbook
- Metadata in block.json (viewScriptModule, render, supports), Block Editor Handbook
- wp_interactivity_state(), Code Reference
- wp_interactivity_data_wp_context(), Code Reference
- register_block_type(), Code Reference
- wp_register_script_module(), Code Reference
- get_block_wrapper_attributes(), Code Reference
