12k
All articles

Как создать собственный блок Gutenberg

Создайте собственный блок Gutenberg с React, block.json и PHP. Разберитесь в атрибутах, статическом и динамическом выводе и устаревших версиях для устранения ошибок.

OpenReplay Team
OpenReplay Team
Как создать собственный блок Gutenberg

Блок 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
Propsattributes
setStatesetAttributes
Вывод, отрендеренный на сервереФункция 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.jsonnpm-скрипты — обёртки над @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 позволяет применить запись к блоку, который уже проходит валидацию.

Большинство ошибок валидации предотвращают три правила:

  1. Никогда не используйте в save недетерминированные значения, например даты или случайные идентификаторы.
  2. Никогда не читайте в save состояние или контекст.
  3. Относитесь к выводу save как к схеме базы данных: любое изменение требует миграции.

Что такое динамический блок?

Динамический блок возвращает null из save, хранит в содержимом записи только свои атрибуты и рендерится на PHP при каждом запросе. Выбирайте его, когда вывод должен меняться без повторного сохранения записи, например для списка последних публикаций.

Статический блокДинамический блок
Где хранится разметкаВ содержимом записиВ PHP-шаблоне
Когда обновляетсяПри повторном сохранении записиПри каждом запросе
Что возвращает saveJSXnull
Нагрузка на серверТолько отдача контентаВыполнение 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 ограничивает набор дочерних блоков, которые могут вставлять редакторы.

DevTools for the frontend

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

We use cookies to improve your experience. By using our site, you accept cookies.