插件目录"第一块砖"怎么摆:我拆解了五种常见入口文件写法,发现 `plugins_loaded` 里藏了条"暗河"
刚开始写插件那会儿,我把所有代码往一个文件里塞,require 满天飞,调试时改一行刷新整个页面,报错信息指向第 800 多行,眼睛都看花。后来拆目录、拆文件,却发现入口文件怎么写直接影响后续能不能愉快地本地调试。这里把我踩过的几种结构摊开聊。
结构一:单文件"裸奔"型
最省事,也是官方示例默认给的:
/*
Plugin Name: 我的插件
*/
// 直接在这里写逻辑
add_action('init', function() {
// ...
});
问题很明显:没有命名空间、没有自动加载,稍微写多点就变成"意大利面条"。本地调试时想加个断点,Xdebug 停在一堆全局函数里,变量作用域乱成一锅粥。这种结构只适合 50 行以内的功能验证。
结构二:入口文件只做"接线员"
我现在常用的最小可运行骨架:
my-plugin/
├── my-plugin.php // 仅保留插件头信息 + 常量定义 + 启动器调用
├── bootstrap.php // 实际做自动加载注册、容器初始化
├── src/
│ ├── Core/
│ │ └── Plugin.php // 主类,负责绑定钩子
│ └── Admin/
│ └── Menu.php
└── vendor/ // Composer 依赖
入口文件瘦成这样:
<?php
/**
* Plugin Name: My Plugin
* Version: 1.0.0
*/
if (!defined('ABSPATH')) exit;
define('MY_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('MY_PLUGIN_URL', plugin_dir_url(__FILE__));
require_once MY_PLUGIN_DIR . 'bootstrap.php';
// 延迟到 plugins_loaded,避免过早执行
add_action('plugins_loaded', [\MyPlugin\Core\Plugin::class, 'instance']);
关键点在这里:把 new Plugin() 或者静态工厂方法挂到 plugins_loaded,而不是在入口文件直接实例化。WordPress 加载插件是按目录字母顺序扫的,如果你的插件依赖另一个插件的函数,直接实例化可能对方还没加载完。
结构三:bootstrap 里的"陷阱门"
bootstrap.php 我写过两版,一版用 Composer autoload,一版手写 SPL 自动加载。本地调试时 Composer 那版有个坑:如果 vendor/autoload.php 存在但依赖没装全,require 不会报错,跑到一半才炸。
现在加了道检查:
// bootstrap.php
$autoload = MY_PLUGIN_DIR . 'vendor/autoload.php';
if (file_exists($autoload)) {
require_once $autoload;
} else {
// 本地开发时走手写加载,避免没跑 composer install 就白屏
spl_autoload_register(function ($class) {
$prefix = 'MyPlugin\\';
if (strpos($class, $prefix) !== 0) return;
$relative = substr($class, strlen($prefix));
$file = MY_PLUGIN_DIR . 'src/' . str_replace('\\', '/', $relative) . '.php';
if (file_exists($file)) require $file;
});
}
这样同事 clone 下来没装 Composer 也能先跑起来看效果,CI 环境里再强制走 composer install --no-dev。
结构四:本地热调试的"偷鸡"技巧
不用每次改代码都手动刷新后台,我在 Plugin.php 里埋了个开发模式开关:
class Plugin {
private function __construct() {
$this->define_hooks();
if (defined('WP_DEBUG') && WP_DEBUG) {
$this->enable_dev_tools();
}
}
private function enable_dev_tools() {
// 开发时强制不缓存脚本版本号
add_filter('script_loader_src', [$this, 'bust_cache'], 10, 2);
// 暴露一个 REST 端点给本地 Vite/Webpack dev server 做热重载 ping
add_action('rest_api_init', function() {
register_rest_route('my-plugin/v1', '/ping', [
'methods' => 'GET',
'callback' => function() {
return ['timestamp' => time(), 'memory' => memory_get_usage()];
},
'permission_callback' => '__return_true' // 本地 nginx 限了 IP
]);
});
}
public function bust_cache($src, $handle) {
if (strpos($handle, 'my-plugin-') === 0) {
return add_query_arg('ver', time(), $src);
}
return $src;
}
}
配合 Valet 的 valet link,本地目录直接映射到 .test 域名,Xdebug 的 idekey 设成 PHPStorm 自动识别。最爽的是 bust_cache 这个 filter,改完 JS/CSS 不用清浏览器缓存,版本号自动带时间戳。
结构五:多站点/多环境时的入口"分流"
有次给客户部署,发现他们用了 Bedrock 结构,wp-content 位置变了,plugin_dir_path 返回的路径里多了层 app/。我在入口文件加了环境探测:
// 兼容 Bedrock / 标准 WP / 自定义内容目录
if (!defined('MY_PLUGIN_DIR')) {
$candidate = dirname(__FILE__);
// Bedrock 里插件实际在 web/app/plugins,但 WP 常量已经帮我们算好了
define('MY_PLUGIN_DIR', plugin_dir_path(__FILE__));
}
// 本地 .env 里配,线上走 wp-config.php 的常量
$env_file = MY_PLUGIN_DIR . '.env';
if (file_exists($env_file) && class_exists('Dotenv\Dotenv')) {
$dotenv = \Dotenv\Dotenv::createImmutable(MY_PLUGIN_DIR);
$dotenv->safeLoad();
}
这样同一套代码,git clone 下来配个 .env 就能跑,不用改入口文件里的硬编码路径。
一个关于 plugins_loaded 的冷知识
我之前以为所有插件的 plugins_loaded 回调是"平级"执行的,直到有次两个插件都挂了这个钩子,A 插件在回调里 do_action('my_custom_event'),B 插件也监了这个事件,结果执行顺序完全不是我想的那样。
查了源码才发现:plugins_loaded 本身是个钩子,但插件文件是 include 进来的,include 过程中如果某个插件直接调了 do_action('plugins_loaded')(对,有人真这么干),后面的插件文件还没被加载。所以永远别在插件文件顶层触发 plugins_loaded,入口文件只注册、不执行,把执行权交还给 WordPress 的加载流程。
你们入口文件是怎么组织的?有没有遇到过更诡异的加载顺序问题?

