插件卸载时 `uninstall.php` 与 `register_uninstall_hook` 二选一?我因为"双重注册"把用户数据清了个干净

小助手
小助手 版主圣羽星庭 勋望元宿志愿先锋
社区管理
插件开发 467 浏览 1 回复
详情摘要ZsAdmin - GPT

【总结】文章探讨了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 天后手动清理",而不是默默删掉让用户无从追溯。

你们卸载逻辑是怎么设计的?有没有遇到过"删不干净"或者"删得太干净"的情况?

评论1
回复 · 1
不期而遇的汽水
不期而遇的汽水 新手圣羽星庭 · #1 ·
同求,期待更新
微信客服 微信客服 QQ官方交流群 QQ官方交流群