12k
All articles

从零开始编写 WordPress 插件

从零构建 WordPress 插件,涵盖钩子、启用和停用钩子、设置页、数据清理、转义以及安全保存。

OpenReplay Team
OpenReplay Team
从零开始编写 WordPress 插件

WordPress 插件最简单的形式,就是 wp-content/plugins 目录下的一个文件夹,里面放着一个 PHP 文件,文件开头有一段注释头用于声明插件名称。保存该文件后,WordPress 就会在「插件」页面中列出这个插件。

如果你一直在往主题的 functions.php 里塞代码片段,你应该已经踩过这个坑:一换主题,代码就跟着没了。把这些代码移到插件里,功能就能在切换主题后依然保留——这正是站点行为应归属插件、而呈现效果应归属主题的主要原因。

本文将完整构建一个小型插件:在页脚输出一条站点通知,并可通过一个能安全保存数据的设置页面进行编辑。每一段代码块都是插件的一个有效状态,因此你可以在任意小节结束后停下来,直接启用当前成果。

要点速览

  • 插件就是 wp-content/plugins 下的一个文件夹,其中包含一个 PHP 文件,文件的注释头用于声明插件名称;Plugin Name 是唯一必填的头部字段。
  • 在你用 add_action()add_filter() 把函数挂载到钩子上之前,插件代码不会执行任何操作。
  • filter 回调必须返回它接收到的值;如果什么都不返回,被过滤的值就会变成空。
  • 一个安全的设置页面包含四层防护:权限检查(capability check)、nonce、输入时的净化(sanitisation)以及输出时的转义(escaping)。
  • register_activation_hook() 用于设置默认值,停用钩子用于清理临时数据,而永久性的数据删除应交由卸载(uninstall)流程处理。

如何创建 WordPress 插件文件与头部信息

创建文件夹 wp-content/plugins/site-notice/,并在其中创建文件 site-notice.php。WordPress 通过读取 plugins 文件夹中的 PHP 文件,并挑出那些以插件头部注释开头的文件来构建「插件」页面。每个带有头部注释的文件都会被视为一个独立插件,所以头部注释只能写在一个文件里。

<?php
/**
 * Plugin Name:       Site Notice
 * Description:       Shows a short notice in the site footer, editable from Settings.
 * Version:           1.0.0
 * Requires at least: 6.5
 * Requires PHP:      7.4
 * Author:            OpenReplay Team
 * License:           GPL-2.0-or-later
 * Text Domain:       site-notice
 */

头部信息要求页面列出了所有可识别的字段,包括 Update URIRequires Plugins。你最常用到的几个如下:

字段WordPress 如何使用它是否必填
Plugin Name在插件列表中显示
Description显示在插件名称下方
Version显示版本号;用于更新版本比较
Requires at least / Requires PHP在过旧的运行环境中阻止启用
Author、License、Text Domain署名、许可协议、翻译标识符

保存文件后,插件就会出现在「插件」页面上。它可以正常启用,但目前什么也不做。

启用与停用钩子有什么作用?

register_activation_hook() 会在有人开启插件的那一刻执行一次你的回调函数,因此非常适合把初始选项值写入数据库。register_deactivation_hook() 则用于丢弃那些只在插件运行期间才需要的东西,缓存就是典型例子。而永久性地删除数据——包括选项和自定义数据表——应该交给卸载流程处理,因为用户关闭插件时往往是打算之后再重新开启的。

在头部注释下方添加:

function orp_activate() {
	add_option( 'orp_notice_text', 'Welcome to the site.' );
}
register_activation_hook( __FILE__, 'orp_activate' );

function orp_deactivate() {
	delete_transient( 'orp_notice_cache' );
}
register_deactivation_hook( __FILE__, 'orp_deactivate' );

第一个参数 __FILE__ 指向插件主文件。我们的插件目前还没有任何缓存,但这个停用回调展示了它应有的形态:清理临时产物,保留已保存的选项。每个函数和选项名上的 orp_ 前缀可以避免与核心及其他插件发生命名冲突。

为什么我的插件代码不执行?

插件文件中的代码不会自行运行。只有当你把函数挂载到钩子上之后,WordPress 才会执行它;在此之前,文件是完全静止的。add_action() 接收一个钩子名称和一个可调用对象,还有一个可选的优先级参数,默认值为 10。

function orp_print_notice() {
	$notice = get_option( 'orp_notice_text' );
	if ( $notice ) {
		echo '<p class="orp-notice">' . esc_html( $notice ) . '</p>';
	}
}
add_action( 'wp_footer', 'orp_print_notice' );

当主题触发 wp_footer 时,WordPress 会调用挂载在该钩子上的所有函数,包括我们的这个。加载任意前台页面,通知就会出现在 body 结束标签之前。

添加一个 Filter:修改值并返回它

action 让你的函数在 WordPress 生命周期的某个节点上运行;而 filter 会把一个值交给你的函数,并期待你把修改后的值返回回来。filter 回调必须返回一个值。 如果它什么都不返回,PHP 会返回 null,WordPress 会把这个 null 继续传递下去,于是你所过滤的内容就会从站点上消失。在 body_class 回调里漏写 return,body 元素就会失去所有 class;在 the_content 上漏写,每篇文章都会渲染成空白。

function orp_body_class( $classes ) {
	if ( get_option( 'orp_notice_text' ) ) {
		$classes[] = 'orp-has-notice';
	}
	return $classes;
}
add_filter( 'body_class', 'orp_body_class' );

这段代码接收 body class 数组,在设置了通知时追加一个 class,然后返回该数组,这样主题就可以在通知生效期间为页面应用不同的样式。

如何构建 WordPress 设置页面?

一个设置页面由三部分注册构成:在 admin_menu 钩子上注册菜单页面,以及在 admin_init 钩子上注册设置项及其所属的 section 和 field,正如 Settings API 章节所述。add_options_page() 会把页面放在「设置」菜单下;register_setting() 用于命名选项,更关键的是指定它的 sanitize_callback

function orp_settings_menu() {
	add_options_page( 'Site Notice', 'Site Notice', 'manage_options',
		'orp-site-notice', 'orp_settings_page_html' );
}
add_action( 'admin_menu', 'orp_settings_menu' );

function orp_settings_init() {
	register_setting( 'orp_settings', 'orp_notice_text', array(
		'type'              => 'string',
		'sanitize_callback' => 'sanitize_text_field',
	) );
	add_settings_section( 'orp_main', 'Notice', '__return_false', 'orp-site-notice' );
	add_settings_field( 'orp_notice_text', 'Notice text',
		'orp_notice_field_html', 'orp-site-notice', 'orp_main' );
}
add_action( 'admin_init', 'orp_settings_init' );

function orp_notice_field_html() {
	$value = get_option( 'orp_notice_text', '' );
	echo '<input type="text" name="orp_notice_text" value="'
		. esc_attr( $value ) . '" class="regular-text">';
}

function orp_settings_page_html() {
	?>
	<div class="wrap">
		<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
		<form action="options.php" method="post">
			<?php
			settings_fields( 'orp_settings' );
			do_settings_sections( 'orp-site-notice' );
			submit_button( 'Save Notice' );
			?>
		</form>
	</div>
	<?php
}

表单会提交到 options.php,由核心负责保存。注意,register_setting() 也接受一个 default 参数;我们选择在启用钩子中设置默认值,所以请只选用其中一种机制并保持一致。

如何安全地处理提交的值?

权限检查回答的是「这个用户是否有权保存设置」;而 nonce 回答的是「这个用户是否真的有意提交这个表单」。一个要交付给客户站点的设置页面两者都需要,而且 nonce 绝不能替代权限检查。

在这里,Settings API 已经帮你完成了大部分工作:settings_fields() 会输出 nonce,表单回传时核心会自动校验;同时,除非当前用户拥有 manage_options 权限,否则核心会阻止保存——这正是 wp-admin/options.php 默认对设置页面应用的权限。如果你日后改为编写自定义的表单处理逻辑,请用 wp_nonce_field() 输出 nonce,并用 check_admin_referer() 进行校验。参照手册自身的示例,在页面回调中加入显式的守卫判断:

function orp_settings_page_html() {
	if ( ! current_user_can( 'manage_options' ) ) {
		return;
	}
	// ... form as above ...
}

要检查权限(capability),而不要检查诸如 administrator 这样的角色名。剩下的两层防护已经就位:写入数据库前进行净化(作为 sanitize_callbacksanitize_text_field),以及输出时进行转义(字段中的 esc_attr()、页脚中的 esc_html())。这两项操作不可互相替代,缺少任何一项,都会让一个已保存的选项变成注入攻击的入口。

启用插件并确认其正常工作

在「插件」页面启用 Site Notice,然后加载前台页面,查看页脚中是否出现通知。到「设置 → Site Notice」中修改文本并刷新页面。如果毫无反应,请按顺序逐项排查:

  1. 插件是真的已启用了,还是只是存在于目录中?
  2. 传给 add_action()add_filter() 的每个字符串,是否与已定义的函数名完全一致?
  3. <?php 之前是否有空白字符或输出?如果有,WordPress 会在启用时报告 “unexpected output”。
  4. wp-config.php 中开启 WP_DEBUG,并用 WP_DEBUG_LOG 把错误写入 wp-content/debug.log,直接读取真实的 PHP 错误信息,而不是靠猜。

现在,你已经拥有了一个包含默认选项、一个 action、一个会返回值的 filter,以及一个通过 nonce、权限检查和净化函数来保存数据的设置页面的插件。对于所有仍然待在 functions.php 里的代码片段,这就是值得照搬的基准做法:把它挪过来,加上前缀,挂到钩子上,并完整保留「输入净化、输出转义」这一对操作。

常见问题

wp_verify_nonce 和 check_admin_referer 有什么区别?

check_admin_referer() 会同时校验后台页面中表单和 URL 的 nonce 与来源(referrer),校验失败时会以 403 中止请求。wp_verify_nonce() 只检查 nonce 并返回一个由你自行处理的结果,适用于 Ajax 处理器及其他自定义场景。两者都不能替代权限检查:nonce 确认的是用户确有执行该操作的意图,而 current_user_can() 确认的是用户被允许执行该操作。

插件被停用后,它保存的选项会怎样?

不会有任何变化:停用不会清除数据库中的选项,因此用户重新启用插件时设置依然完好。停用回调应当只清理生命周期短暂的内容,例如 transients 或缓存文件。永久性清理属于卸载流程,可以通过 register_uninstall_hook() 实现,或在插件目录中放置一个 uninstall.php 文件。如果使用 uninstall.php,它必须在删除任何数据之前检查 WP_UNINSTALL_PLUGIN 常量是否已定义。

一个 WordPress 插件可以包含多个 PHP 文件吗?

可以,但只有主文件应当包含插件头部注释。WordPress 通过读取 plugins 目录中 PHP 文件的头部注释来发现插件,每个带有头部注释的文件都会作为独立插件显示出来。请在主文件中用 require_once 加载其他文件,并让 register_activation_hook() 之类的调用始终指向插件主文件,因为它们的第一个参数必须引用该文件。

默认选项应该在 register_setting 中设置,还是在启用钩子中设置?

只选用一种机制,不要两者并用。register_setting() 接受一个 default 参数,当数据库中不存在对应值时会返回它;而启用钩子中的 add_option() 则会在启用时真正写入一行数据。启用钩子的做法可以保证该值在每次请求中都存在,包括前台请求;而 register_setting() 的默认值只在注册代码已经执行的场景下生效。两者混用会为同一个选项制造出两个「真相来源」。

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.