插件卸载时 `uninstall.php` 与 `register_uninstall_hook` 二选一?我因为"双重注册"把用户数据清了个干净
【总结】文章探讨了WordPress插件卸载逻辑设计问题,指出uninstall.php与register_uninstall_hook双重注册会导致用户数据被误删,建议二选一实现;同时提出建表时应避免硬编码charset,
上周帮客户做插件交接,对方技术负责人盯着代码问了一句:"你们卸载逻辑怎么写了两份?"我当时一愣,回去翻仓库才发现——uninstall.php 里删了一遍表,register_uninstall_hook 的回调里又删了一遍。更绝的是,两份逻辑还不完全一致,一个清选项、一个清表,用户点卸载时到底执行哪份,全看 WordPress 当天心情。
这篇帖子不是讲"怎么写卸载",是讲"怎么写不互相打架"。
一、WordPress 的卸载执行优先级,很多人记反了
官方文档说得很含糊,实测行为是这样的:
// 情况A:只有 uninstall.php,且文件头部注释合规
// → 直接走文件,不触发 register_uninstall_hook
// 情况B:没有 uninstall.php,或有但注释不对
// → 才会去查 register_uninstall_hook 注册的回调
// 情况C:两者都有,且 uninstall.php 注释正确
// → 只执行 uninstall.php,hook 回调被完全忽略
但"情况C"有个致命盲区:如果 uninstall.php 存在但语法报错,或者权限问题导致 is_uninstallable_plugin() 返回 false,系统会静默 fallback 到 hook 回调。两份逻辑都会被执行——只是不在同一次请求里,而是"这次失败,下次补刀"。
我踩的坑更隐蔽:uninstall.php 开头忘了写标准的插件信息注释,导致 WordPress 不认它是"合规的卸载文件",于是每次都走 hook。后来补上了注释,但 hook 没删,测试环境因为缓存问题没触发双重执行,线上环境换了服务器配置后突然"生效"了。
二、建表时的"隐形契约":charset 与 collation 别硬编码
这是另一个血案。早期版本为了"兼容",我直接把建表语句写死了:
$sql = "CREATE TABLE {$wpdb->prefix}my_plugin_logs (
id bigint(20) NOT NULL AUTO_INCREMENT,
content longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
...
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;";
客户数据库是 utf8mb4_general_ci(MySQL 8.0 默认),插件激活时 dbDelta 对比结构,发现 collation 不一致,每次页面加载都尝试"修正"表结构。后台慢得像蜗牛,SHOW PROCESSLIST 里全是 ALTER TABLE。
现在我的模板里固定这么写:
global $wpdb;
$charset_collate = $wpdb->get_charset_collate(); // 让 WordPress 去猜宿主环境
$sql = "CREATE TABLE {$wpdb->prefix}my_plugin_logs (
id bigint(20) NOT NULL AUTO_INCREMENT,
content longtext,
...
) $charset_collate;";
注意 content longtext 后面不再跟 CHARACTER SET ... COLLATE ...,让 $charset_collate 统一收口。如果某个字段确实需要特殊编码(比如存 emoji 的字段要 utf8mb4 而宿主是 utf8),单独声明并加注释说明原因。
三、升级脚本的"时间旅行"问题:版本号比较别用字符串
插件从 1.9 升级到 1.10,我的升级脚本没执行。调试半天发现:
// 错误示范
if ( $current_version '1.10' 的第二个字符 '1'
现在全部改用 version_compare(),并且版本号入库时存的是"最后成功执行的升级版本",不是"当前插件版本":
$db_version = get_option( 'my_plugin_db_version', '0.0.0' );
$upgrades = [
'1.2.0' => 'upgrade_120_add_index',
'1.5.0' => 'upgrade_150_migrate_options',
'1.10.0' => 'upgrade_1100_split_tables',
];
foreach ( $upgrades as $ver => $callback ) {
if ( version_compare( $db_version, $ver, '
关键细节:如果 upgrade_150_migrate_options 里抛异常,1.10.0 的拆分表逻辑不会执行,但 1.5.0 已经标记为完成。下次重试时从 1.10.0 开始,不会重复迁移选项。这比"全成功才写版本号"更安全——虽然可能留下半成品数据,但至少不会把旧数据再迁移一遍导致重复。
四、卸载时的"温柔降级":给开发者留条后路
最后分享个现在养成的习惯:卸载时不直接 DROP TABLE,而是改表名:
function my_plugin_uninstall() {
global $wpdb;
$tables = [ 'my_plugin_logs', 'my_plugin_stats', 'my_plugin_relations' ];
$backup_suffix = '_uninstalled_' . date( 'YmdHis' );
foreach ( $tables as $table ) {
$full_name = $wpdb->prefix . $table;
$backup_name = $full_name . $backup_suffix;
// 先检查是否存在,避免重复卸载时报错
if ( $wpdb->get_var( "SHOW TABLES LIKE '{$full_name}'" ) === $full_name ) {
$wpdb->query( "RENAME TABLE `{$full_name}` TO `{$backup_name}`" );
}
}
// 选项数据同理,移到备份键而不是 delete_option
$options = $wpdb->get_col( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE 'my_plugin_%'" );
foreach ( $options as $opt ) {
update_option( '_backup_' . $opt, get_option( $opt ) );
delete_option( $opt );
}
}
表还在库里,只是"隐身"了。用户如果误卸载或者想迁移数据,能找回来。我会在插件文档里写清楚"备份表保留 30 天后手动清理",而不是默默删掉让用户无从追溯。
你们卸载逻辑是怎么设计的?有没有遇到过"删不干净"或者"删得太干净"的情况?


微信客服
QQ官方交流群