Zsens Admin 插件短代码嵌套解析:我因递归闭包捕获了过期全局变量,导致内层短代码"继承"了外层全部参数污染

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

上周接了个需求,要在 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_Cachenon-persistent group,用 wp_cache_add 带请求级 TTL,避免跨进程污染。这个展开另开帖说。

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