Zsens Admin 插件短代码嵌套解析:我因递归闭包捕获了过期全局变量,导致内层短代码"继承"了外层全部参数污染
上周接了个需求,要在 Zsens Admin 里实现短代码嵌套——外层 [zsens_container type="grid"] 包着内层 [zsens_item id="7"]。看似 trivial,结果内层短代码死活读不到自己的 id,输出的全是外层 type="grid"。排查路径极其阴间,记录一下。
错误的写法:闭包里的"幽灵引用"
我最开始图省事,在短代码注册时用了闭包捕获 $atts,想着每个短代码独立执行,各管各的:
add_shortcode('zsens_container', function($atts, $content = null) {
$atts = shortcode_atts(['type' => 'list'], $atts);
// 错误:把 $atts 塞进全局或静态,方便"子组件"读取
global $zsens_current_atts;
$zsens_current_atts = $atts; // 直接覆盖,无栈无隔离
$parsed = do_shortcode($content); // 内层 [zsens_item] 在这里执行
return '<div class="zsens-' . esc_attr($atts['type']) . '">' . $parsed . '</div>';
});
add_shortcode('zsens_item', function($atts, $content = null) {
global $zsens_current_atts; // 读到的永远是"最后一次写入"的那个
// 灾难:这里想读自己的 id,却被外层的 type 污染
// 更惨的是,如果多层嵌套,读到的是最外层还是最内层?取决于执行时机
$my_atts = shortcode_atts(['id' => 0], $atts);
// 我原本想做个"继承"机制,结果变成全局污染
$merged = array_merge($zsens_current_atts, $my_atts);
return '<span data-id="' . esc_attr($merged['id']) . '">' . do_shortcode($content) . '</span>';
});
问题爆发场景:三层嵌套时,最内层的 id 被第二层覆盖,第二层又被第一层覆盖。WordPress 的 do_shortcode 是递归下降解析,但我的全局变量没有任何栈结构,后写覆盖先写,执行顺序和 DOM 嵌套层级完全脱钩。
正确的写法:显式栈 + 作用域隔离
核心思路:用静态数组模拟调用栈,push 进外层上下文,pop 恢复,内层只读自己需要的、显式透传的参数。
class Zsens_Shortcode_Stack {
private static array $stack = [];
public static function push(array $context): void {
self::$stack[] = $context;
}
public static function pop(): ?array {
return array_pop(self::$stack);
}
public static function current(): ?array {
return empty(self::$stack) ? null : end(self::$stack);
}
// 关键:只暴露"允许继承"的键,不是全盘 dump
public static function inherited(string $key, $default = null) {
$current = self::current();
return $current[$key] ?? $default;
}
}
add_shortcode('zsens_container', function($atts, $content = null) {
$atts = shortcode_atts(['type' => 'list', 'cols' => 3], $atts);
Zsens_Shortcode_Stack::push([
'type' => $atts['type'], // 子元素可能需要的
'cols' => $atts['cols'], // 网格列数,透传
// 故意不把 id 放进来,子元素必须自己声明
]);
$parsed = do_shortcode($content);
Zsens_Shortcode_Stack::pop(); // 必须配对,哪怕中间抛异常
return sprintf(
'<div class="zsens-%s" style="--cols:%d">%s</div>',
esc_attr($atts['type']),
(int)$atts['cols'],
$parsed
);
});
add_shortcode('zsens_item', function($atts, $content = null) {
$atts = shortcode_atts([
'id' => 0,
'span' => 1, // 跨列数,默认继承容器 cols 但可覆盖
], $atts);
// 显式读取"允许继承"的上下文,而非全局脏数据
$span = $atts['span'] ?? Zsens_Shortcode_Stack::inherited('cols', 1);
// 自己的 id 必须显式声明,没有默认值可继承
$id = (int)$atts['id'];
if (!$id) {
// 调试期直接炸,上线后改 warning_log
return '<!-- zsens_item: missing required attr "id" -->';
}
return sprintf(
'<div class="zsens-item" data-id="%d" style="grid-column:span %d">%s</div>',
$id,
(int)$span,
do_shortcode($content)
);
});
两个关键差异
1. 覆盖 vs 透传的语义区分
错误代码用 array_merge 把全局变量和本地变量无脑合并,语义是"继承一切"。正确代码用显式白名单 push(['type', 'cols']),子元素用 inherited() 按需读取,未声明的键即使同名也不会串味。
2. 异常安全
错误代码如果 do_shortcode 里某个短代码抛异常,全局变量永远脏在那。正确代码的 push/pop 必须配对,建议再包一层 try/finally 或者注册 shutdown 钩子做栈深度校验——我实际项目里加了断言:
register_shutdown_function(function() {
if (!empty(Zsens_Shortcode_Stack::current())) {
error_log('Zsens: shortcode stack leak detected, depth=' . count(Zsens_Shortcode_Stack::$stack));
}
});
踩坑后的自检清单
短代码嵌套出问题,先问自己三个问题:
- 我的"上下文共享"用的是全局/静态,还是显式栈?
do_shortcode($content)前后有没有对称的 push/pop?- 子元素读取的变量,是"必须声明"还是"静默继承"?(前者防手滑,后者防污染)
另外提一嘴,如果嵌套层级深或者短代码被缓存插件截胡,栈状态可能在不同请求间串——我后面把栈实现改成了 WP_Object_Cache 的 non-persistent group,用 wp_cache_add 带请求级 TTL,避免跨进程污染。这个展开另开帖说。

