Как создать собственный блок Gutenberg
Создайте собственный блок Gutenberg с React, block.json и PHP. Разберитесь в атрибутах, статическом и динамическом выводе и устаревших версиях для устранения ошибок.
Блок Gutenberg — это React-компонент, который WordPress регистрирует под именем с пространством имён (namespace). В редакторе он отображается через функцию edit, а на фронтенде выводится либо функцией save, либо PHP-шаблоном рендеринга.
Если вы умеете писать React-компоненты, вы сможете написать и блок. Основная сложность — в специфичных для WordPress частях вокруг компонента: JSON-манифесте, вызове регистрации в PHP и этапе валидации. Стоит изменить разметку, и валидация выдаёт ошибку «This block contains unexpected or invalid content» (в русской локализации — «Этот блок содержит неожиданное или недопустимое содержимое»).
До появления блоков собственное поведение добавляли, подключая пользовательский JavaScript к темам WordPress через wp_enqueue_script. Для контента блоки заменяют этот подход: редакторы получают визуальный редактируемый компонент вместо скрипта, работающего поверх статического HTML. В этом руководстве мы создадим блок-выноску (callout) с редактируемым текстом на основе RichText и полем URL на боковой панели. Мы разберём каждый сгенерированный файл, а также объясним, почему возникают ошибки валидации и как их исправить.
Ключевые выводы
- Команда
npx @wordpress/create-block@latest callout --namespace=openreplayгенерирует готовый плагин с блоком, а@wordpress/scriptsсобирает его командамиnpm startиnpm run build. - Файл block.json описывает блок. Вызовы регистрации в PHP и JavaScript берут из этого единственного файла имя блока, атрибуты, поддерживаемые возможности (supports) и пути к ресурсам.
- Атрибуты без
sourceхранятся в виде JSON в HTML-комментарии-разделителе блока. Атрибуты сsourceиselectorизвлекаются обратно из сохранённой разметки. - При загрузке записи редактор повторно выполняет
save()и сравнивает результат с сохранённой разметкой. Любое расхождение вызывает ошибку недопустимого содержимого, пока запись вdeprecatedне воспроизведёт прежний вывод. - Динамический блок возвращает
nullизsaveи рендерится на PHP при каждом запросе. Используйте его для вывода, который должен меняться без повторного сохранения записи.
Блок — это React-компонент
Блок Gutenberg — это React-компонент, API которого, специфичный для WordPress, соответствует уже знакомым React-разработчикам паттернам:
| Понятие React | Аналог в блоке |
|---|---|
| JSX компонента | Функция edit |
| Props | attributes |
setState | setAttributes |
| Вывод, отрендеренный на сервере | Функция save или render.php |
От обычного React-компонента блок Gutenberg отличается двумя вещами. Во-первых, вывод save записывается в содержимое записи в виде HTML. Во-вторых, схема атрибутов описывается в JSON, а не через PropTypes или TypeScript.
Генерация каркаса блока с помощью 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 | npm-скрипты — обёртки над @wordpress/scripts |
src/callout/block.json | Описание блока: имя, атрибуты, supports, ресурсы |
src/callout/index.js | Вызывает registerBlockType с edit и save |
src/callout/edit.js | Компонент интерфейса в редакторе |
src/callout/save.js | Разметка, сохраняемая в содержимом записи |
src/callout/style.scss | Стили для фронтенда и редактора |
src/callout/editor.scss | Стили только для редактора |
build/ | Скомпилированные файлы, которые фактически загружает WordPress |
build/blocks-manifest.php | Метаданные всех блоков, генерируемые при сборке |
PHP-файл — это обычный плагин (см. статью о создании плагина 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 и пути к ресурсам, поэтому один файл описывает блок и для редактора, и для сервера.
{
"$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 редактор записей всегда работает в iframe независимо от того, какую apiVersion объявляет блок, поэтому любой блок должен корректно в нём работать.
attributes определяют данные блока. type и default работают так, как и ожидается. source определяет, где хранится значение. Согласно справочнику по атрибутам, значение без source попадает в комментарий-разделитель, а source: "html" считывает внутренний HTML найденного элемента — именно на это обычно опираются поля RichText.
| Определение атрибута | Где хранится значение |
|---|---|
Без source (url) | JSON внутри комментария <!-- wp:... --> |
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. Регистрируется копия из build/callout/, которая лежит рядом со скомпилированными index.js, index.css и style-index.css. Именно поэтому PHP указывает на build, а не на src. Все ключи описаны в справочнике по метаданным блока.
edit.js: RichText, InspectorControls и useBlockProps
Компонент edit рендерится только в редакторе. Он получает в props attributes и setAttributes. setAttributes работает как сеттер состояния: объединяет переданные изменения с текущими атрибутами и запускает повторный рендер.
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() добавляет классы и data-атрибуты, необходимые редактору для выделения и стилизации блока, поэтому их нужно развернуть (spread) на самом внешнем элементе. RichText позволяет редактировать текст прямо внутри блока. InspectorControls переносит на боковую панель настройки, которые не редактируются inline, например URL. PanelBody группирует эти элементы управления в сворачиваемую секцию.
save.js и ошибка валидации блока
Функция save формирует разметку, сохраняемую в содержимом записи. Она должна быть чистой функцией от атрибутов: никакого состояния, никаких эффектов и никаких хуков, кроме 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 делает блок невалидным. Редактор показывает сообщение «This block contains unexpected or invalid content» и выводит diff в консоль браузера.
Изменение функции 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позволяет применить запись к блоку, который уже проходит валидацию.
Большинство ошибок валидации предотвращают три правила:
- Никогда не используйте в
saveнедетерминированные значения, например даты или случайные идентификаторы. - Никогда не читайте в
saveсостояние или контекст. - Относитесь к выводу
saveкак к схеме базы данных: любое изменение требует миграции.
Что такое динамический блок?
Динамический блок возвращает null из save, хранит в содержимом записи только свои атрибуты и рендерится на PHP при каждом запросе. Выбирайте его, когда вывод должен меняться без повторного сохранения записи, например для списка последних публикаций.
| Статический блок | Динамический блок | |
|---|---|---|
| Где хранится разметка | В содержимом записи | В PHP-шаблоне |
| Когда обновляется | При повторном сохранении записи | При каждом запросе |
Что возвращает save | JSX | null |
| Нагрузка на сервер | Только отдача контента | Выполнение PHP при каждом рендере |
Сгенерируйте его командой npx @wordpress/create-block@latest callout --namespace=openreplay --variant dynamic или самостоятельно добавьте "render": "file:./render.php" в block.json. Приведённый ниже шаблон выводит записи пользовательского типа записей. Предполагается, что вы добавили атрибут count типа number:
<?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() — PHP-аналог useBlockProps. Если вместо файла шаблона вы предпочитаете функцию, передайте render_callback в register_block_type(). Динамические блоки никогда не сталкиваются с ошибками валидации, поскольку у редактора нет сохранённой разметки для сравнения.
Заключение
Блок — это React-компонент плюс JSON-описание и контракт на сохранённую разметку. Компонент — самая простая часть для React-разработчика. Ломается блок именно на контракте: как только ваш блок появляется в реальных записях, каждое изменение save требует соответствующей записи в deprecated. Сгенерируйте блок-выноску, добавьте класс в вывод save и посмотрите, как в консоли появится ошибка. Затем напишите запись deprecation, которая её устранит. Пройдя этот цикл один раз, вы перестанете воспринимать ошибку недопустимого содержимого как загадку.
Часто задаваемые вопросы
Где размещать собственный блок Gutenberg — в плагине или в теме?
В плагине. Разметка блока сохраняется в содержимом записи, поэтому блок должен оставаться зарегистрированным, пока его используют записи. Если код регистрации отключается, например при смене темы, редактор сообщает, что сайт не поддерживает этот блок, и предлагает оставить его как есть или удалить. Плагин сохраняет доступность блока при смене темы.
Что делает с невалидным блоком функция «Попытаться восстановить блок» (Attempt Block Recovery)?
Эта функция пересобирает блок из извлечённых атрибутов с помощью текущей функции save и заменяет сохранённую разметку. Она исправляет один блок в одной записи и отбрасывает всё содержимое, которое не отражено в атрибутах. Это обходной путь в редакторе, а не исправление кода. Запись в deprecated позволяет обновить все существующие записи без ручного восстановления блоков.
Как добавить второй блок в тот же плагин?
Создайте в src новую папку с собственными файлами block.json, index.js, edit.js и save.js. Команды start и build из @wordpress/scripts ищут файлы block.json в src и её подпапках, собирают каждый блок как отдельную точку входа в соответствующую папку внутри build и записывают все блоки в манифест, который регистрирует плагин. После добавления папки перезапустите npm start.
Может ли динамический блок содержать вложенные блоки?
Да, но тогда save не может возвращать null. Отрендерите область дочерних блоков в edit с помощью InnerBlocks или useInnerBlocksProps и возвращайте из save InnerBlocks.Content, чтобы разметка дочерних блоков сохранялась в содержимом записи. PHP-шаблон получает эту отрендеренную разметку в переменной $content вместе с $attributes. Каждый блок поддерживает только одну область InnerBlocks, а 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