カスタム Gutenberg ブロックの作り方
React、block.json、PHPで独自のGutenbergブロックを作成。属性、静的・動的レンダリング、旧バージョン対応で検証エラーを防ぐ方法を解説します。
Gutenberg ブロックとは、WordPress が名前空間付きの名前で登録する React コンポーネントです。エディター内では edit 関数で描画され、フロントエンドでは save 関数または PHP のレンダーテンプレートで出力されます。
React コンポーネントが書ければ、ブロックも書けます。難しいのは、コンポーネントを取り巻く WordPress 固有の部分です。具体的には、JSON マニフェスト、PHP の登録呼び出し、そしてマークアップを変更した途端に「このブロックには、想定されていないか無効なコンテンツが含まれています」(This block contains unexpected or invalid content)と警告するバリデーション処理です。
ブロックが登場する以前は、wp_enqueue_script を使ってWordPress テーマにカスタム JavaScript を追加することで独自の挙動を実装していました。コンテンツについては、ブロックがその役割を引き継ぎます。編集者は、静的な HTML に対して実行されるスクリプトの代わりに、視覚的に編集できるコンポーネントを使えるようになります。本ガイドでは、編集可能な RichText の本文とサイドバーの URL フィールドを備えたコールアウトブロックを作成します。スキャフォールドで生成される各ファイルを解説し、バリデーションエラーが発生する理由と修正方法を示します。
重要ポイント
npx @wordpress/create-block@latest callout --namespace=openreplayで完全なブロックプラグインが生成されます。ビルドには@wordpress/scriptsを使い、npm startとnpm run buildを実行します。- block.json はブロックを宣言するファイルです。PHP と JavaScript の登録呼び出しは、どちらもこの 1 つのファイルから名前、属性、supports、アセットのパスを読み込みます。
sourceを持たない属性は、ブロックの HTML コメントデリミターに JSON として保存されます。sourceとselectorを持つ属性は、保存されたマークアップから解析して取り出されます。- 投稿を読み込むとき、エディターは
save()を再実行し、その結果を保存済みのマークアップと比較します。差異があると無効なコンテンツのエラーが発生し、deprecatedエントリーで古い出力を再現するまで解消されません。 - ダイナミックブロックは
saveからnullを返し、リクエストのたびに PHP で描画されます。投稿を再保存しなくても変化させる必要がある出力に使います。
ブロックは React コンポーネントである
Gutenberg ブロックは React コンポーネントです。その WordPress 固有の API は、React 開発者がすでに知っているパターンに対応しています。
| React の概念 | ブロックでの対応 |
|---|---|
| コンポーネントの JSX | edit 関数 |
| Props | attributes |
setState | setAttributes |
| サーバーでレンダリングされた出力 | save 関数または render.php |
Gutenberg ブロックは、通常の React コンポーネントと 2 つの点で異なります。1 つ目は、save の出力が HTML として投稿コンテンツに書き込まれることです。2 つ目は、属性のスキーマを PropTypes や TypeScript ではなく JSON で定義することです。
create-block でブロックをスキャフォールドする
公式の @wordpress/create-block パッケージを使うと、ビルドツールが設定済みの動作するブロックプラグインを生成できます。wp-content/plugins 内で実行してください。
npx @wordpress/create-block@latest callout --namespace=openreplay
cd callout
npm start # watch mode during development
npm run build # production build
--namespace を省略すると、ブロック名は create-block/callout になります。npm start を実行したら、管理画面でプラグインを有効化してください。
| ファイル | 役割 |
|---|---|
callout.php | プラグインヘッダーと、init 時のブロック登録 |
package.json | @wordpress/scripts をラップする npm スクリプト |
src/callout/block.json | ブロックの宣言(名前、属性、supports、アセット) |
src/callout/index.js | edit と save を指定して registerBlockType を呼び出す |
src/callout/edit.js | エディター UI コンポーネント |
src/callout/save.js | 投稿コンテンツに保存されるマークアップ |
src/callout/style.scss | フロントエンドとエディターの両方に適用されるスタイル |
src/callout/editor.scss | エディターのみに適用されるスタイル |
build/ | WordPress が実際に読み込むコンパイル済みの出力 |
build/blocks-manifest.php | ビルド時に生成される全ブロックのメタデータ |
PHP ファイルは、通常のプラグインに登録呼び出しを 1 つ追加しただけのものです(WordPress プラグインをゼロから作成するを参照)。基盤となるのは register_block_type() で、この関数は block.json を含むフォルダーを受け取ります。スキャフォールドはこれを一括登録の呼び出しでラップしています。この呼び出しには WordPress 6.8 以降が必要なため、生成されるプラグインヘッダーにも Requires at least: 6.8 が設定されています。この呼び出しを簡略化すると次のようになります。
add_action( 'init', function () {
wp_register_block_types_from_metadata_collection(
__DIR__ . '/build',
__DIR__ . '/build/blocks-manifest.php'
);
} );
両方の形式については、ブロック登録ガイドで解説されています。
block.json:ブロックを宣言する場所
block.json ファイルは、ブロックを宣言する唯一の場所です。PHP と JavaScript の登録呼び出しは、どちらもここから名前、属性、supports、アセットのパスを読み込みます。そのため、1 つのファイルでエディターとサーバーの両方にブロックを定義できます。
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "openreplay/callout",
"version": "0.1.0",
"title": "Callout",
"category": "text",
"icon": "info",
"attributes": {
"content": { "type": "string", "source": "html", "selector": "p" },
"url": { "type": "string", "default": "" }
},
"supports": { "html": false },
"textdomain": "callout",
"editorScript": "file:./index.js",
"editorStyle": "file:./index.css",
"style": "file:./style-index.css"
}
name は namespace/slug の形式です。このブロックを使うすべての投稿のマークアップに書き込まれるため、後から変更しないものとして扱ってください。
apiVersion: 3 は WordPress 6.3 で導入されました。ブロックが iframe 化されたエディター内で動作することを宣言します。WordPress 6.9 以降、スキーマはバージョン 3 のみを受け付けます。古いバージョンで登録したブロックは、SCRIPT_DEBUG が有効な場合にコンソールへ警告を出力します。WordPress 7.1 からは、ブロックが宣言する apiVersion に関係なく、投稿エディターが常に iframe 内で動作します。そのため、すべてのブロックが iframe 内で動作する必要があります。
attributes はブロックのデータを定義します。type と default は想像どおりに機能します。source は値をどこに保存するかを制御します。属性リファレンスによると、source を持たない値はコメントデリミター内に格納されます。一方、source: "html" はマッチした要素の内部 HTML を読み取ります。RichText フィールドでは通常こちらを使います。
| 属性の定義 | 値の保存場所 |
|---|---|
source なし(url) | <!-- wp:... --> コメント内の JSON |
source + selector(content) | 読み込みのたびに保存済み HTML から解析 |
保存された投稿コンテンツは次のようになります(例)。
<!-- wp:openreplay/callout {"url":"https://example.com"} -->
<div class="wp-block-openreplay-callout"><p>Deploys freeze Friday at noon.</p></div>
<!-- /wp:openreplay/callout -->
supports はエディターの機能を有効にします。html: false を指定すると、「HTML として編集」(Edit as HTML)オプションが非表示になります。align や color などのキーを指定すると、追加のコードなしでツールバーやサイドバーにコントロールが追加されます。詳細は supports リファレンスを参照してください。
file: パスは、登録される block.json からの相対パスとして解決されます。この block.json は、コンパイル済みの index.js、index.css、style-index.css と同じ build/callout/ にあるコピーです。PHP が src ではなく build を参照するのはこのためです。すべてのキーはブロックメタデータリファレンスに記載されています。
edit.js:RichText、InspectorControls、useBlockProps
edit コンポーネントは、エディター内でのみ描画されます。props として attributes と setAttributes を受け取ります。setAttributes は state のセッターと同じ役割を果たし、渡された差分をマージして再レンダリングをトリガーします。
import { __ } from '@wordpress/i18n';
import { useBlockProps, RichText, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, TextControl } from '@wordpress/components';
export default function Edit( { attributes, setAttributes } ) {
const { content, url } = attributes;
return (
<>
<InspectorControls>
<PanelBody title={ __( 'Link', 'callout' ) }>
<TextControl
label={ __( 'URL', 'callout' ) }
value={ url }
onChange={ ( value ) => setAttributes( { url: value } ) }
/>
</PanelBody>
</InspectorControls>
<div { ...useBlockProps() }>
<RichText
tagName="p"
value={ content }
onChange={ ( value ) => setAttributes( { content: value } ) }
placeholder={ __( 'Write the callout…', 'callout' ) }
/>
</div>
</>
);
}
useBlockProps() は、エディターが選択やスタイリングに使うクラス名やデータ属性を追加します。最も外側の要素にスプレッドしてください。RichText を使うと、ユーザーはブロック内で直接テキストを編集できます。InspectorControls は、URL のようにインラインで編集しない設定をサイドバーに配置します。PanelBody は、それらのコントロールを折りたたみ可能なセクションにまとめます。
save.js とブロックバリデーションエラー
save 関数は、投稿コンテンツに保存されるマークアップを生成します。この関数は属性だけに依存する純粋関数でなければなりません。state や副作用は使えず、useBlockProps.save() 以外のフックも使えません。
import { useBlockProps, RichText } from '@wordpress/block-editor';
export default function save( { attributes } ) {
return (
<div { ...useBlockProps.save() }>
<RichText.Content tagName="p" value={ attributes.content } />
</div>
);
}
RichText.Content は保存された値をプレーンなマークアップとして出力し、useBlockProps.save() はフロントエンドでラッパーにクラスを追加します。
ブロックバリデーションが失敗するのはなぜか
投稿をエディターで開くと、WordPress は解析した属性を使ってブロックの現在の save 関数を実行します。そして、その結果を投稿に保存されているマークアップと比較します。Edit と Save のガイドによると、新しく生成した出力と保存済み HTML の間に差異があれば、そのブロックは無効と判定されます。このときエディターは「このブロックには、想定されていないか無効なコンテンツが含まれています」と表示し、ブラウザーのコンソールに差分を出力します。
save 関数を変更しても、すでにブロックを含んでいる投稿は書き換えられません。たとえば、v2 でクラスとリンクを追加したとします。
export default function save( { attributes } ) {
const { content, url } = attributes;
return (
<div { ...useBlockProps.save() }>
<RichText.Content tagName="p" className="callout__body" value={ content } />
{ url && <a href={ url }>Learn more</a> }
</div>
);
}
この変更により、既存のコールアウトはすべてバリデーションに失敗します。新しい save の出力が、以前に保存されたマークアップと一致しなくなるためです。
解決策:deprecated
deprecated エントリーには、以前の属性と以前の save 関数を保持します。これでブロックバリデーションエラーを解消できます。古い属性を新しい形式に変換する migrate 関数を含めることもできます(任意)。このエントリーがあれば、エディターは古いマークアップを認識し、次に投稿を保存するときにアップグレードします。
import { registerBlockType } from '@wordpress/blocks';
import { useBlockProps, RichText } from '@wordpress/block-editor';
import Edit from './edit';
import save from './save';
import metadata from './block.json';
const v1 = {
attributes: {
content: { type: 'string', source: 'html', selector: 'p' },
url: { type: 'string', default: '' },
},
save( { attributes } ) {
return (
<div { ...useBlockProps.save() }>
<RichText.Content tagName="p" value={ attributes.content } />
</div>
);
},
};
registerBlockType( metadata.name, { edit: Edit, save, deprecated: [ v1 ] } );
解析は属性の定義に依存するため、各エントリーは独自の attributes を持ちます。仮に v2 でセレクターを p から .callout__body に変更していたとしても、古い投稿を正しく読み込むには v1 に selector: 'p' を残しておく必要があります。
非推奨化(deprecation)リファレンスでは、次のルールが定められています。
- エントリーは新しいものから順に並べます。
- エディターは、保存時のマークアップに対して各エントリーを個別にテストします。あるエントリーの結果が次のエントリーに引き継がれることはありません。
- エントリーの
save出力が保存済み HTML と一致しない場合、エディターはそのエントリーを無視します。そのmigrateも実行されません。 isEligible関数(任意)を使うと、すでにバリデーションを通過しているブロックに対してもエントリーを適用できます。
次の 3 つの習慣を守れば、ほとんどのバリデーションエラーを防げます。
- 日付やランダムな ID など、非決定的な値を
saveに含めない。 save内で state やコンテキストを読み取らない。saveの出力はデータベーススキーマと同じように扱い、変更には必ずマイグレーションを用意する。
ダイナミックブロックとは
ダイナミックブロックは save から null を返し、投稿コンテンツには属性だけを保存します。描画はリクエストのたびに PHP で行われます。最新記事の一覧のように、投稿を再保存しなくても出力を変化させる必要がある場合に使います。
| 静的ブロック | ダイナミックブロック | |
|---|---|---|
| マークアップの保存場所 | 投稿コンテンツ | PHP テンプレート |
| 更新タイミング | 投稿の再保存時 | リクエストごと |
save の戻り値 | JSX | null |
| サーバーコスト | コンテンツの配信以外は不要 | 描画ごとに PHP を実行 |
npx @wordpress/create-block@latest callout --namespace=openreplay --variant dynamic でスキャフォールドするか、block.json に "render": "file:./render.php" を手動で追加します。次のテンプレートは、カスタム投稿タイプのエントリーを一覧表示します。number 型の count 属性を追加済みであることを前提としています。
<?php
$query = new WP_Query( array(
'post_type' => 'project',
'posts_per_page' => isset( $attributes['count'] ) ? (int) $attributes['count'] : 3,
) );
?>
<ul <?php echo get_block_wrapper_attributes(); ?>>
<?php while ( $query->have_posts() ) : $query->the_post(); ?>
<li><a href="<?php echo esc_url( get_permalink() ); ?>"><?php echo esc_html( get_the_title() ); ?></a></li>
<?php endwhile; wp_reset_postdata(); ?>
</ul>
$attributes には、ブロックの解析済み属性が格納されています。get_block_wrapper_attributes() は、useBlockProps に相当する PHP 側の関数です。テンプレートファイルではなく関数を使いたい場合は、register_block_type() に render_callback を渡します。ダイナミックブロックでは、エディターが比較する保存済みマークアップが存在しないため、バリデーションエラーは発生しません。
まとめ
ブロックは、React コンポーネントに JSON による宣言と保存済みマークアップの契約を加えたものです。React 開発者にとって、コンポーネントは簡単な部分です。ブロックが壊れるのは契約の部分です。実際の投稿にブロックが使われるようになったら、save を変更するたびに対応する deprecated エントリーが必要になります。まずはコールアウトをスキャフォールドし、save の出力にクラスを追加して、コンソールにエラーが表示される様子を確認してみてください。次に、そのエラーを解消する deprecation を書いてみましょう。このサイクルを一度経験すれば、無効なコンテンツのエラーに戸惑うことはなくなります。
よくある質問
カスタム Gutenberg ブロックはプラグインとテーマのどちらに含めるべきですか?
プラグインに含めてください。ブロックのマークアップは投稿コンテンツに保存されるため、投稿がそのブロックを使っている限り、ブロックを登録したままにしておく必要があります。テーマの切り替えなどで登録コードが無効になると、エディターは、サイトがそのブロックに対応していない旨のメッセージを表示し、そのまま残すか削除するかを選ぶよう求めます。プラグインにしておけば、テーマを変更してもブロックを使い続けられます。
「ブロックのリカバリーを試行」(Attempt Block Recovery)は、無効なブロックに対して何を行いますか?
「ブロックのリカバリーを試行」は、解析済みの属性と現在の save 関数を使ってブロックを再構築し、保存済みマークアップを置き換えます。修正されるのは 1 つの投稿内の 1 つのブロックだけで、属性で捕捉されていないコンテンツは失われます。これはエディター上で問題を回避する手段であり、コードを修正するものではありません。deprecated エントリーを用意すれば、手作業でブロックを復旧しなくても、既存のすべての投稿をアップグレードできます。
同じプラグインに 2 つ目のブロックを追加するにはどうすればよいですか?
src の下に新しいフォルダーを作成し、専用の block.json、index.js、edit.js、save.js を配置します。@wordpress/scripts の start コマンドと build コマンドは、src とそのサブフォルダーから block.json ファイルを探します。見つかったブロックはそれぞれ個別のエントリーポイントとして build 配下の対応するフォルダーにビルドされ、プラグインが登録するブロックマニフェストにすべて書き込まれます。フォルダーを追加したら、npm start を再起動してください。
ダイナミックブロックに、ネストしたブロックを含めることはできますか?
はい、できます。ただし、save から null を返すことはできません。edit では InnerBlocks または useInnerBlocksProps で子ブロックの領域を描画し、save からは InnerBlocks.Content を返して、子ブロックのマークアップを投稿コンテンツに保存します。PHP テンプレートは、描画された子ブロックのマークアップを $attributes とともに $content 変数で受け取ります。1 つのブロックに配置できる InnerBlocks 領域は 1 つだけです。allowedBlocks を使うと、編集者が挿入できる子ブロックを制限できます。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k