`add_submenu_page` 的 `$parent_slug` 到底该写 slug 还是 file 路径?我被 WordPress 的"双轨制"绕进死胡同两小时

小助手
小助手 版主圣羽星庭 勋望元宿志愿先锋
社区管理
插件开发 84 浏览 0 回复

昨天给插件加二级菜单,本地一切正常,打包到测试环境后菜单直接消失。排查到最后发现是 $parent_slug 的传值问题——WordPress 在这里搞了套"看脸识别"机制,传不对就当你没传。

先上结论,两种写法都能被识别,但底层走的不是同一条路:

// 写法 A:用顶级菜单的 "menu slug"
add_submenu_page(
    'my-plugin-top',           // ← 这是 add_menu_page 的 $menu_slug
    '子页面标题',
    '子页面',
    'manage_options',
    'my-plugin-sub'
);

// 写法 B:用顶级菜单的 "file 路径"(仅限原生菜单)
add_submenu_page(
    'edit.php?post_type=book', // ← 自定义文章类型的"伪路径"
    '子页面标题',
    '子页面',
    'manage_options',
    'my-plugin-sub'
);

坑在哪?add_menu_page 自己注册的顶级菜单,必须用 返回的 slug 字符串;而 WordPress 内置菜单(比如 edit.phpupload.php)和部分扩展注册的菜单,反而要用 file 路径或带 query string 的 URL。更恶心的是,传错了不会抛错,就是静默不显示,连 _doing_it_wrong 都不触发。

我踩坑的具体场景:插件 A 用 add_menu_page('My Plugin', ...) 注册顶级菜单,返回 slug 是 my-plugin-top。插件 B(扩展模块)想挂个子菜单进去,我图省事直接写了 plugin_dir_path(__FILE__) . 'admin/main.php'——因为看到有些教程里 $parent_slug 写的是文件路径。结果子菜单死活不出来,global $submenu 里也没有。

wp-admin/includes/plugin.phpadd_submenu_page 的实现,关键逻辑在这里:

// 简化版核心逻辑
global $_registered_pages;
if ( isset( $submenu[$parent_slug] ) ) {
    // 走"已注册菜单"分支,直接挂到 $submenu 数组
    $submenu[$parent_slug][] = [ $menu_title, $capability, $menu_slug, $page_title ];
} else {
    // 走"hookname 推导"分支,靠 $parent_slug 拼出 hook
    $hookname = get_plugin_page_hookname( $menu_slug, $parent_slug );
    $_registered_pages[$hookname] = true;
}

第一种情况要求 $parent_slug 必须是 $menu$submenu 全局数组里已有的键;第二种情况 WordPress 会把你传的字符串和 $menu_slug 拼接成 action hook。如果两边都对不上,菜单就"魂飞魄散"了——$_registered_pages 里有记录,但 $submenu 里没挂靠,最终渲染时过滤掉了。

我现在养成了个习惯:注册完顶级菜单立刻把 slug 写进常量,子菜单强制用这个常量,绝不手写字符串:

class My_Plugin_Admin {
    const TOP_SLUG = 'my-plugin-top'; // 唯一真相源
    
    public function init() {
        add_action('admin_menu', [$this, 'register_menus']);
    }
    
    public function register_menus() {
        add_menu_page(
            'My Plugin',
            'My Plugin',
            'manage_options',
            self::TOP_SLUG,  // ← 这里
            [$this, 'render_top'],
            'dashicons-admin-generic',
            30
        );
        
        add_submenu_page(
            self::TOP_SLUG,  // ← 子菜单也必须用这个
            '设置',
            '设置',
            'manage_options',
            'my-plugin-settings',
            [$this, 'render_settings']
        );
    }
}

还有个权限节点的细节:子菜单的 $capability 如果比顶级菜单宽松,WordPress 不会拦你注册,但用户点进去会 403。比如顶级菜单要 manage_options,子菜单写 edit_posts,有 edit_postsmanage_options 的用户能看到菜单入口,进去就是"权限不足"。

我的做法是子菜单能力值默认继承顶级菜单,需要单独控制时显式覆盖,而不是写死字符串:

public function add_submenu($slug, $title, $capability = null) {
    $cap = $capability ?? $this->top_capability; // 默认继承
    add_submenu_page(self::TOP_SLUG, $title, $title, $cap, $slug, [$this, 'render']);
}

你们有没有被 $parent_slug 的"双轨制"坑过?或者遇到过更隐蔽的菜单注册问题?

评论0
回复 · 0
还没有回复
微信客服 微信客服