`plugins_url()` 返回的 URL 在本地正常、上线后 404:我挖到 WordPress 多站点与符号链接的"路径双标"现场
上周把本地跑得好好的积分插件往客户多站点环境一丢,前端 CSS 全挂。F12 一看,资源 URL 变成 https://example.com/wp-content/plugins/my-plugin/assets/style.css,实际文件却在 /wp-content/uploads/sites/2/wp-content/plugins/... 的符号链接后面。plugins_url() 没撒谎,撒谎的是我对"插件目录"的朴素认知。
先贴我当时偷懒的代码,估计不少人写过同款:
// 错的不是这一行,是用这一行时脑子里的假设
wp_enqueue_style(
'my-plugin-frontend',
plugins_url( 'assets/style.css', __FILE__ ),
[],
'1.0.0'
);
本地单站点、无符号链接,plugins_url() 基于 WP_PLUGIN_URL 常量拼接,天衣无缝。多站点 + 运维把插件目录符号链接到共享盘时,WP_PLUGIN_URL 和真实文件系统的映射关系就裂开了。更坑的是:有的主机商把 wp-content/plugins 整个外链,有的只链单个插件文件夹,行为像开盲盒。
我现在的做法分两层:先让路径"自描述",再让回退"有兜底"。
第一层:用 plugin_dir_url() 替代 plugins_url() 的相对玩法
这俩函数不是简单的别名。plugin_dir_url( __FILE__ ) 以当前文件为锚点,不受调用文件层级干扰;而 plugins_url( 'assets/style.css', __FILE__ ) 要求你算准相对路径。更关键的是,某些符号链接场景下 plugins_url() 会尝试"规范化"路径,可能跳回到物理目录而非链接目录:
// 现在入口文件里只定义一次 BASE
if ( ! defined( 'MY_PLUGIN_URL' ) ) {
define( 'MY_PLUGIN_URL', plugin_dir_url( __DIR__ . '/my-plugin.php' ) );
}
// 所有资源统一走这个常量
wp_enqueue_style(
'my-plugin-frontend',
MY_PLUGIN_URL . 'assets/style.css',
[],
filemtime( plugin_dir_path( __DIR__ . '/my-plugin.php' ) . 'assets/style.css' )
);
注意 plugin_dir_url() 的参数我传的是 __DIR__ . '/my-plugin.php' 而不是直接 __FILE__。因为实际 enqueue 代码往往埋在 includes/class-assets.php 里,__FILE__ 在那个上下文里指向的是深层文件,拼接会多出一截 includes/。
第二层:模板继承时的"双向路径"问题
我的插件在前端输出积分商城页面,允许主题覆盖模板。之前的逻辑是"先找主题,再找插件":
// 旧逻辑:只考虑了 .php 文件,没考虑模板里的 <img>、<link> 的 src
$template = locate_template( 'my-plugin/single-reward.php' );
if ( ! $template ) {
$template = plugin_dir_path( __FILE__ ) . 'templates/single-reward.php';
}
load_template( $template );
问题来了:主题覆盖的模板里如果写 <img src="./assets/badge.png">,路径解析权交到浏览器手里,主题目录和插件目录深度不同,相对路径立刻爆炸。插件自带的模板里写 plugins_url( 'assets/badge.png' ),被主题覆盖后又失效——因为主题作者不会知道你插件的资源 URL。
现在的解法是在加载模板前,往模板作用域注入一个"资源定位函数":
// 加载前塞一个闭包进去
set_query_var( 'my_plugin_asset', function ( $relative_path ) {
// 优先看主题有没有同名覆盖资源
$theme_override = get_stylesheet_directory() . '/my-plugin-assets/' . $relative_path;
if ( file_exists( $theme_override ) ) {
return get_stylesheet_directory_uri() . '/my-plugin-assets/' . $relative_path;
}
// 回退到插件默认
return MY_PLUGIN_URL . 'assets/' . $relative_path;
});
// 模板里统一这样写
// <img src="<?php echo esc_url( $my_plugin_asset( 'badge.png' ) ); ?>">
主题作者想换图,往自己目录丢同名文件即可;不想折腾的,插件资源自动兜底。这个闭包通过 set_query_var 传,比全局函数干净,也不污染 wp_enqueue_* 的队列。
第三层:符号链接的物理路径陷阱
最阴的 bug 是 filemtime() 和 file_exists() 在符号链接上的行为差异。file_exists() 跟符号链接走,filemtime() 默认也走,但如果运维开了 open_basedir 限制,或者 PHP 的 realpath_cache 把链接目标缓存成过期路径,版本号参数就会拿到 false,变成空字符串或时间戳 0,CDN 缓存直接炸。
我现在强制清一次 realpath 缓存再取版本号:
function my_plugin_asset_version( $relative_path ) {
$full_path = plugin_dir_path( __DIR__ . '/my-plugin.php' ) . 'assets/' . $relative_path;
// 符号链接场景下,realpath_cache 可能是脏的
clearstatcache( true, $full_path );
return file_exists( $full_path ) ? filemtime( $full_path ) : '1.0.0';
}
clearstatcache 第一个参数传 true 是清真实文件缓存,不是符号链接缓存。这个细节我踩了两次才记住——第一次传了 false,本地复现不了,上线又 404。
一个还没完美解决的点
多站点的 switch_to_blog() 切站后,plugin_dir_url() 返回的 URL 不会自动跟到子站的 uploads 映射。如果插件资源存在站点级别的覆盖需求(比如子站 A 要用春节皮肤、子站 B 用默认),现在的 MY_PLUGIN_URL 常量方案不够。我临时用 get_site_option 存了个"资源 CDN 前缀"做拼接,但感觉这不是正道。有人做过类似的多站点资源隔离方案吗?