如何构建自定义 Gutenberg 区块
使用 React、block.json 和 PHP 构建自定义 Gutenberg 区块,了解属性、静态与动态渲染及旧版本迁移,避免验证错误。
Gutenberg 区块是一个 React 组件,WordPress 会以带命名空间的名称注册它。它在编辑器中通过 edit 函数渲染,在前端则通过 save 函数或 PHP 渲染模板输出。
只要会写 React 组件,就能写区块。大部分难点在于组件周边那些 WordPress 特有的部分:一份 JSON 清单(manifest)、一次 PHP 注册调用,以及一个校验步骤。只要你修改了标记(markup),这个校验步骤就会报出 “This block contains unexpected or invalid content”(此区块包含意外或无效的内容)。
在区块出现之前,要实现自定义行为,通常需要借助 wp_enqueue_script 向 WordPress 主题添加自定义 JavaScript。在内容层面,区块取代了这种做法:编辑人员面对的是一个可视化、可编辑的组件,而不再是一段针对静态 HTML 运行的脚本。本指南将构建一个 callout(提示框)区块,包含一个可编辑的 RichText 正文和侧边栏中的一个 URL 字段。文中会逐一讲解脚手架生成的每个文件,并说明校验错误的成因及修复方法。
核心要点
npx @wordpress/create-block@latest callout --namespace=openreplay会生成一个完整的区块插件,@wordpress/scripts则通过npm start和npm run build完成构建。- block.json 负责声明区块。PHP 和 JavaScript 的注册调用都从这一个文件中读取区块的 name、attributes、supports 和资源路径。
- 未设置
source的属性会以 JSON 形式存储在区块的 HTML 注释分隔符中。设置了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 组件有两点不同。第一,save 的输出会以 HTML 形式写入文章内容。第二,属性 schema 定义在 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 | 封装 @wordpress/scripts 的 npm 脚本 |
src/callout/block.json | 区块声明:name、attributes、supports、资源 |
src/callout/index.js | 调用 registerBlockType,传入 edit 和 save |
src/callout/edit.js | 编辑器 UI 组件 |
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 的注册调用都从中读取 name、attributes、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 起,schema 仅接受版本 3,使用旧版本注册的区块会在启用 SCRIPT_DEBUG 时在控制台输出警告。从 WordPress 7.1 开始,无论区块声明的 apiVersion 是多少,文章编辑器都将始终运行在 iframe 中,因此每个区块都必须能在 iframe 中正常工作。
attributes 定义区块的数据。type 和 default 的作用与你预想的一致。source 决定值的存放位置。根据 Attributes 参考文档,未设置 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 解析,也就是 build/callout/ 中的副本,它与编译后的 index.js、index.css 和 style-index.css 位于同一目录。这就是 PHP 指向 build 而非 src 的原因。区块元数据参考文档列出了所有可用的键。
edit.js:RichText、InspectorControls 与 useBlockProps
edit 组件只在编辑器内渲染。它通过 props 接收 attributes 和 setAttributes,其中 setAttributes 的作用与 state setter 相同:合并传入的部分更新并触发重新渲染。
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 属性,因此要将其展开到最外层元素上。RichText 让用户可以直接在区块内部编辑文本。InspectorControls 会把不适合行内编辑的设置项(例如 URL)放到侧边栏中。PanelBody 则将这些控件归入一个可折叠的分组。
save.js 与区块校验错误
save 函数生成存储在文章内容中的标记。它必须是只依赖 attributes 的纯函数:不能有 state,不能有副作用,除 useBlockProps.save() 外也不能使用任何 hook。
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>
);
}
此时所有已有的 callout 区块都会校验失败,因为新的 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中使用非确定性的值,例如日期或随机 ID。 - 不要在
save中读取 state 或 context。 - 像对待数据库 schema 一样对待
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 条目。不妨先搭建这个 callout 区块,给它的 save 输出加一个类名,观察控制台中出现的错误,再编写能消除该错误的 deprecation。完整走过一遍这个流程后,无效内容错误就不再神秘了。
常见问题
自定义 Gutenberg 区块应该放在插件中还是主题中?
放在插件中。区块标记会保存到文章内容中,因此只要还有文章在使用该区块,它就必须保持注册状态。如果负责注册的代码被停用(例如切换了主题),编辑器会提示站点不支持该区块,并让用户选择保留原样或将其删除。放在插件中,区块在更换主题后依然可用。
“尝试恢复区块”(Attempt Block Recovery)会对无效区块做什么?
“尝试恢复区块”会使用当前的 save 函数,根据解析出的属性重建区块,并替换已存储的标记。它每次只能修复一篇文章中的一个区块,而且会丢弃属性未能捕获的内容。它只是在编辑器中绕开了问题,并没有修复代码。deprecated 条目则能让所有现有文章自动升级,无需手动逐个恢复区块。
如何在同一个插件中添加第二个区块?
在 src 下新建一个文件夹,放入独立的 block.json、index.js、edit.js 和 save.js。@wordpress/scripts 的 start 和 build 命令会扫描 src 及其子文件夹中的 block.json 文件,将每个区块作为独立入口构建到 build 下对应的文件夹中,并把所有区块写入插件所注册的区块清单(blocks manifest)。添加文件夹后需要重启 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