搜索全站
插件 16 分钟阅读

插件开发:uninstall.php 清理数据的规范做法

用户删了插件,数据库里却留着一堆表和 options。本文讲清 uninstall.php 的执行条件、WP_UNINSTALL_PLUGIN 常量、多站点循环清理写法,以及和停用钩子的区别。

U
UFO_PF 2017-01-25 · 阅读 16 分钟
插件 插件
阅读提示
本文约 6,329 字,预计阅读 16 分钟。文中命令与配置均经过实测,可直接参考执行。

引言:卸载了,但垃圾还在

插件开发的最后一公里经常被忽略:用户删除插件后,wp_options 里还躺着几十条配置、数据库里还有两张自定义表、定时任务还在每分钟尝试执行一个已经不存在的函数、上传目录里还有插件生成的缓存文件。

这些残留有三个实际影响。第一,数据库越来越臃肿,wp_options 的 autoload 数据直接影响每次请求的加载速度(这一点在 插件数量与性能 里专门讲过)。第二,残留的 cron 任务会持续报错,把错误日志刷满。第三,也是最影响口碑的——用户如果之后重新安装这个插件,会读到一堆陈旧的旧配置,行为诡异且难以排查。

WordPress 为此提供了两个专门的机制:uninstall.php 文件和 register_uninstall_hook() 函数。它们只在插件被「删除」时执行,而不是被「停用」时。这个区别是整套机制里最容易搞混的地方,本文会讲清楚。

原理分析:停用、删除、卸载三件事的区别

动作 触发方式 应该做什么 不应该做什么
停用(Deactivate) 后台点「停用」 清理临时缓存、取消定时任务、刷新重写规则 不能删除用户数据
删除(Delete) 停用在后台点「删除」 执行 uninstall.php,彻底清理 —
卸载(Uninstall) 即删除动作的一部分 删选项、删表、删文件 不能影响其他插件

关键规则:停用不删数据。因为用户可能只是暂时停用排查问题,或者换主题测试,删了数据等于毁掉他的配置。只有明确的「删除」动作才应该清数据。

uninstall.php 的执行条件是:

  1. 插件已处于停用状态;
  2. 用户在后台插件列表点击「删除」,或者通过 WP-CLI 的 wp plugin delete;
  3. WordPress 加载 wp-content/plugins/{plugin}/uninstall.php。

一个重要的技术细节:执行 uninstall.php 时,插件本身的代码是没有被加载的。WordPress 只 include 这一个文件,插件主文件里的函数、类都不可用。所以 uninstall.php 必须是自包含的——需要用到的逻辑要么直接写在里面,要么通过 require 显式引入。

安全上还有一条硬要求:uninstall.php 可以被直接通过 URL 访问(它就是一个公开目录下的 PHP 文件),所以必须检查 WP_UNINSTALL_PLUGIN 常量。这个常量由 WordPress 在加载 uninstall.php 之前定义,直接访问时它不存在,脚本应当立刻退出。

两个机制的取舍:

机制 优点 缺点 适用
uninstall.php 不加载插件代码,内存占用低;不依赖插件主文件 无法使用插件里的类和函数 推荐,绝大多数场景
register_uninstall_hook() 可以使用插件内已定义的函数 会先加载整个插件,内存开销大 清理逻辑依赖插件类的情况

保姆级实操

第一步:创建 uninstall.php 并做常量检查

<?php
/**
 * 插件卸载清理脚本。
 */

// 防止被直接访问,必须是第一行有效代码
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
    exit;
}

第二步:删除选项(单站点与多站点都覆盖)

// 单站点选项
delete_option( 'mmf_options' );
delete_option( 'mmf_version' );
delete_option( 'mmf_last_sync' );

// 多站点网络级选项
delete_site_option( 'mmf_network_options' );
delete_site_option( 'mmf_network_version' );

delete_site_option() 操作的是 wp_sitemeta 表,用于网络级配置;delete_option() 操作当前站点的 wp_options。

第三步:清理 transient

// 删除已知的 transient
delete_transient( 'mmf_api_cache' );
delete_transient( 'mmf_daily_report' );

// 批量清理带前缀的 transient(transient 在 options 表里有两条记录)
global $wpdb;
$wpdb->query(
    "DELETE FROM {$wpdb->options}
     WHERE option_name LIKE '\_transient\_mmf\_%'
        OR option_name LIKE '\_transient\_timeout\_mmf\_%'
        OR option_name LIKE '\_site\_transient\_mmf\_%'
        OR option_name LIKE '\_site\_transient\_timeout\_mmf\_%'"
);

注意 \_ 是对下划线做转义,因为 SQL 里 _ 是单字符通配符。另外由于我们是在 uninstall.php 里,前面已经 global $wpdb,可以直接用。

第四步:删除自定义数据表

global $wpdb;

$tables = array(
    $wpdb->prefix . 'mmf_logs',
    $wpdb->prefix . 'mmf_stats',
);

foreach ( $tables as $table ) {
    // $wpdb->prefix 由 WordPress 生成,不需要额外转义
    $wpdb->query( "DROP TABLE IF EXISTS {$table}" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
}

生产环境更安全稳妥的做法是先用 $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $table ) ) 确认表存在,再 DROP。

第五步:清理文章与用户的 meta

// 删除插件写入的所有 post meta
delete_post_meta_by_key( '_mmf_score' );
delete_post_meta_by_key( '_mmf_synced' );

// 删除插件写入的用户 meta
$wpdb->delete( $wpdb->usermeta, array( 'meta_key' => 'mmf_preference' ) );
$wpdb->delete( $wpdb->usermeta, array( 'meta_key' => 'mmf_last_login_ip' ) );

delete_post_meta_by_key() 会删除所有文章上的该键,不需要循环。

第六步:取消定时任务

// 清理插件注册的定时任务
wp_clear_scheduled_hook( 'mmf_daily_sync' );
wp_clear_scheduled_hook( 'mmf_hourly_check', array( 'full' ) );

第二个参数必须与 wp_schedule_event() 注册时传入的参数完全一致,否则清不掉。

第七步:多站点逐个站点清理

如果插件在子站上也有数据,必须遍历所有站点:

if ( is_multisite() ) {
    $site_ids = get_sites( array(
        'fields'                 => 'ids',
        'number'                 => 0, // 不限制数量,全部站点
        'update_site_meta_cache' => false,
    ) );

    foreach ( $site_ids as $site_id ) {
        switch_to_blog( $site_id );

        delete_option( 'mmf_options' );
        delete_option( 'mmf_version' );

        // 站点级的表也要删
        $GLOBALS['wpdb']->query(
            "DROP TABLE IF EXISTS " . $GLOBALS['wpdb']->prefix . "mmf_logs"
        );

        restore_current_blog();
    }
}

switch_to_blog() 与 restore_current_blog() 必须成对出现,$wpdb->prefix 在切换后会自动变成对应子站的前缀。number => 0 表示返回全部站点,不加这个参数默认只返回 100 个——这是个很容易漏掉的坑。

第八步:完整的 uninstall.php 结构

<?php
/**
 * 插件卸载脚本。
 */

if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
    exit;
}

global $wpdb;

// 1) 选项
delete_option( 'mmf_options' );
delete_option( 'mmf_version' );
delete_site_option( 'mmf_network_version' );

// 2) transient
delete_transient( 'mmf_api_cache' );
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '\_transient\_mmf\_%' OR option_name LIKE '\_transient\_timeout\_mmf\_%'" );

// 3) 数据表
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}mmf_logs" );

// 4) meta
delete_post_meta_by_key( '_mmf_score' );

// 5) 定时任务
wp_clear_scheduled_hook( 'mmf_daily_sync' );

// 6) 多站点遍历
if ( is_multisite() ) {
    foreach ( get_sites( array( 'fields' => 'ids', 'number' => 0 ) ) as $site_id ) {
        switch_to_blog( $site_id );
        delete_option( 'mmf_options' );
        $GLOBALS['wpdb']->query( "DROP TABLE IF EXISTS " . $GLOBALS['wpdb']->prefix . "mmf_logs" );
        restore_current_blog();
    }
}

// 7) 刷新缓存
wp_cache_flush();

第九步:验证卸载是否干净

# 删除插件(会触发 uninstall.php)
wp plugin deactivate mmf-demo
wp plugin delete mmf-demo

# 检查残留
wp db query "SELECT option_name FROM wp_options WHERE option_name LIKE '%mmf%'"
wp db query "SHOW TABLES LIKE 'wp_mmf%'"
wp cron event list

三条查询都应返回空。

常见踩坑

  1. 把清理逻辑写在停用钩子里:用户停用插件后数据被清空,再启用时配置全没了,是严重的用户体验事故。停用只做 wp_clear_scheduled_hook() 和 flush_rewrite_rules()。

  2. 忘记 WP_UNINSTALL_PLUGIN 检查:uninstall.php 可被直接 URL 访问,等于给了攻击者一个「一键清空插件数据」的接口。

  3. 在 uninstall.php 里调用插件主文件的函数:此时插件代码没被加载,会触发 Call to undefined function。要么自包含,要么显式 require。

  4. 多站点只清理了当前站:get_sites() 默认 number 是 100,站点多时会漏。

  5. 清理了不属于自己的数据:选项名、表名必须带自己的前缀,避免误删其他插件的数据。

  6. 案例: 某统计插件的开发者把清理逻辑写在了 register_deactivation_hook() 里。用户在排查一次前台报错时停用了该插件,几个月的历史统计表被 DROP 掉,重新启用后数据全空且无法恢复。用户投诉后开发者才改成 uninstall.php。教训是:停用钩子只做可逆操作,删除数据只能放在卸载流程里。

  7. 清完不刷新对象缓存:如果站点启用了 Redis 之类的持久化对象缓存(见 drop-ins 机制说明),delete_option() 之后不 wp_cache_flush(),缓存里可能还有旧值。

总结

规范可以压缩成四条:只有删除才清数据,停用不清;uninstall.php 第一行检查 WP_UNINSTALL_PLUGIN;清理范围要覆盖选项、transient、自定义表、meta、cron;多站点用 get_sites( array('number' => 0) ) 遍历并 switch_to_blog()。

一个容易忘的细节:wp_clear_scheduled_hook() 的第二个参数必须和注册时完全一致,否则定时任务清理不掉,会持续报错。

写完卸载脚本后,务必用 wp plugin delete 实测一次并查残留。插件生命周期的另一端是安装——如果你还没写过第一个插件,可以从 从零写一个 WordPress 插件 开始;而插件头部字段的写法在 插件目录结构与头部注释 里。

U
UFO_PF作者

专注 WordPress 生态的实战作者,内容以「可复制、可验证」为第一原则。

留下你的想法

邮箱地址不会被公开。带 * 的为必填项。