Elementor 编辑器内存耗尽排查:一次 trace 内存分析实战

Elementor 编辑器内存耗尽排查:一次 trace 内存分析实战

现象

用户站点上,一个 Theme Builder 模板(Single Application Design,post_id=1054)在 Elementor 编辑器中完全打不开,直接抛出 Fatal Error:


Fatal error: Allowed memory size of 536870912 bytes exhausted 
(tried to allocate 1986560 bytes) 
in /wp-includes/class-wp-scripts.php on line 673

512MB 内存全部耗尽。奇怪的是,站上其他页面和 Theme Builder 模板都能正常进入编辑器,唯独这一个模板不行。

第一轮排查:常规怀疑对象

UE 组件?

该模板大量使用了 Unlimited Elements(UE)的自定义 widget。我首先怀疑 UE 的 29 个 widget 在编辑器中注册时产生了巨大的配置数据。在编辑器中临时注销所有 UE widget:


[TEST] Unregistered 29 UE widgets from editor
[MEM] FATAL: ... mem=510MB

即使去掉 UE,问题依旧。

显示条件?

模板中有 35 个元素挂了 Premium Addons 的 pp_display_conditions,每个条件对象有 40+ 字段。Elenmontor 在编辑器中为每个控件的可见性评估这些条件,可能产生大量计算。清空全部 52 个条件字段(数据从 71KB 减到 56KB),仍然不行。

短代码?

模板中有 4 个 shortcode widget,分别调用了 [app_related_products][application_faq][iks_menu][elementor-template]。这些 shortcode 在编辑器渲染预览时执行 PHP 代码并嵌入其他模板。全部清空后测试 —— 还是不行。

元数据?

接下来我清空了 _elementor_controls_usage_elementor_page_settings_elementor_conditions 等所有非必要的 post meta,只留下 _elementor_data 等 6 个核心字段。依然无济于事。

关键的二分测试

此时我已经排除了几乎所有”内容层面”的可能,决定做一个终极实验:_elementor_data 替换为最简数据(1 个 Container + 1 个 Heading),看看编辑器能否打开。

结果:仍然不行!

这意味着问题根本不在模板的内容数据里 —— 连最简结构都打不开,说明问题出在 post 1054 本身,而不是它的 widget 数据。

对比测试锁定方向

我创建了一个全新的 elementor_library 类型 post(1136),设置相同的模板类型 single-post 和条件 singular/application,写入相同的最简数据。

结果:1136 的编辑器正常打开!

1054 和 1136 的区别只有一个:1054 有 16 个修订版本(revisions),1136 为 0

真相大白:循环引用的修订版本

检查 1054 的修订版本时发现,Revision 1096 的 _elementor_data 包含一个短代码,指向 revision 1096 自身


{
  "widgetType": "shortcode",
  "settings": {
    "shortcode": "[elementor-template id=\"1096\"]"
  }
}

Elementor 编辑器在加载时会读取所有修订版本,用于构建”历史”面板(History)。当它处理 revision 1096 时,发现了一个指向 revision 1096 自身的短代码引用,试图递归解析 —— 就像这个伪代码:


load_revision(1096)
  -> process_element(shortcode)
    -> resolve_shortcode("[elementor-template id=\"1096\"]")
      -> load_revision(1096)
        -> process_element(shortcode)
          -> resolve_shortcode("[elementor-template id=\"1096\"]")
            -> ...

这个无限递归在每次迭代中分配新的字符串和数组,直到 PHP 的 512MB 内存限制被彻底耗尽。

class-wp-scripts.php:673 的报错位置(wp_localize_script 中的 JSON 序列化)只是一个受害者 —— 它是递归过程中最后撑不住的那一行代码,而不是错误的根源。

修复

删除 1054 的全部 16 个修订版本:


DELETE FROM wp_posts WHERE post_parent = 1054 AND post_type = 'revision';

同时从正常的 Revision 数据中恢复 _elementor_data,确保没有循环引用。清除后,编辑器秒开。

附:排查过程中使用的工具代码

以下是本次排查中使用的关键代码,可以直接作为 mu-plugin 部署到 WordPress 站点,供类似问题参考。

1. 内存追踪 mu-plugin

这个插件会在每个关键钩子点记录当前内存用量,精确定位内存暴涨发生在哪个阶段:

 $v) {
        $new->$k = $v;
    }
    $GLOBALS['wp_scripts'] = $new;
}, 1);

// 在各个关键钩子点打点
add_action('admin_init', function() { _mt('admin_init'); }, 1);
add_action('elementor/init', function() { _mt('elementor/init'); }, 1);
add_action('elementor/controls/register', function() { _mt('controls/register'); }, 1);
add_action('elementor/editor/before_enqueue_scripts', function() { _mt('before_enqueue_scripts'); }, 1);
add_action('elementor/widgets/register', function() { _mt('widgets/register_START'); }, 1);
add_action('elementor/widgets/register', function() { _mt('widgets/register_END'); }, 999);
add_action('elementor/editor/after_enqueue_scripts', function() { _mt('after_enqueue_scripts'); }, 1);

// Shutdown 时 dump 脚本数据总量
register_shutdown_function(function() {
    global $wp_scripts;
    $total = 0;
    if ($wp_scripts && !empty($wp_scripts->registered)) {
        foreach ($wp_scripts->registered as $h => $s) {
            if (!empty($s->extra['data'])) {
                $total += strlen($s->extra['data']);
            }
        }
    }
    $mem = round(memory_get_usage(true) / 1024 / 1024, 1);
    file_put_contents(
        WP_CONTENT_DIR . '/editor_mem_trace.log',
        sprintf("[SHUTDOWN] total script data = %.1fMB mem=%sMB\n", $total/1048576, $mem),
        FILE_APPEND
    );
});

2. 编辑器中临时注销 Unlimited Elements Widget

快速验证 UE widget 是否是内存问题的根源:

// 部署为 mu-plugin,只在编辑器上下文中注销 UE 组件
add_action('elementor/widgets/register', function($widgets_manager) {
    if (!is_admin()) return;

    $ue_widgets = [];
    foreach ($widgets_manager->get_widget_types() as $name => $widget) {
        if (strpos($name, 'ucaddon_') === 0) {
            $ue_widgets[] = $name;
        }
    }
    foreach ($ue_widgets as $name) {
        $widgets_manager->unregister($name);
    }

    file_put_contents(
        WP_CONTENT_DIR . '/editor_mem_trace.log',
        '[TEST] Unregistered ' . count($ue_widgets) . ' UE widgets' . "\n",
        FILE_APPEND
    );
}, 999);

3. 清除显示条件(pp_display_conditions)

批量删除 Elementor 数据中所有元素的显示条件:

$post_id = 1054;
$data = json_decode(get_post_meta($post_id, '_elementor_data', true), true);

$stripped = 0;
function strip_conditions(&$elements, &$stripped) {
    foreach ($elements as &$element) {
        $settings = &$element['settings'];
        if (isset($settings['pp_display_conditions'])) {
            unset($settings['pp_display_conditions']);
            $stripped++;
        }
        if (isset($settings['display_condition_list'])) {
            unset($settings['display_condition_list']);
            $stripped++;
        }
        if (isset($element['elements']) && is_array($element['elements'])) {
            strip_conditions($element['elements'], $stripped);
        }
    }
}
strip_conditions($data, $stripped);

$new = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
update_post_meta($post_id, '_elementor_data', wp_slash($new));
echo "Stripped {$stripped} condition fields";

4. 清空所有 shortcode widget

$post_id = 1054;
$data = json_decode(get_post_meta($post_id, '_elementor_data', true), true);

$cleared = 0;
function clear_shortcodes(&$elements, &$cleared) {
    foreach ($elements as &$element) {
        if (($element['widgetType'] ?? '') === 'shortcode') {
            $shortcode = $element['settings']['shortcode'] ?? '';
            if (trim($shortcode) !== '') {
                $element['settings']['shortcode'] = '';
                $cleared++;
            }
        }
        if (isset($element['elements']) && is_array($element['elements'])) {
            clear_shortcodes($element['elements'], $cleared);
        }
    }
}
clear_shortcodes($data, $cleared);

$new = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
update_post_meta($post_id, '_elementor_data', wp_slash($new));
delete_post_meta($post_id, '_elementor_css');
delete_post_meta($post_id, '_elementor_element_cache');
echo "Cleared {$cleared} shortcode widgets";

5. 清除非必需元数据

$post_id = 1054;
$to_delete = [
    '_elementor_controls_usage',
    '_elementor_page_assets',
    '_elementor_page_settings',
    '_elementor_conditions',
    '_elementor_css',
    '_elementor_element_cache',
];
foreach ($to_delete as $key) {
    delete_post_meta($post_id, $key);
}

6. 替换为最简数据进行二分定位

当所有常规怀疑都被排除后,用最简数据验证问题是否在 post 本身:

$post_id = 1054;

// 备份当前数据
$backup = get_post_meta($post_id, '_elementor_data', true);
update_post_meta($post_id, '_elementor_data_backup', $backup);

// 写入最简数据:1 个 Container + 1 个 Heading
$minimal = json_encode([[
    'id' => 'test001',
    'elType' => 'container',
    'settings' => [],
    'elements' => [[
        'id' => 'test002',
        'elType' => 'widget',
        'widgetType' => 'heading',
        'settings' => ['title' => 'Test'],
        'elements' => [],
    ]],
    'isInner' => false,
]], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

update_post_meta($post_id, '_elementor_data', wp_slash($minimal));
delete_post_meta($post_id, '_elementor_css');

总结

这个问题的排查过程教会了我几件事:

  1. trace 内存要用排除法 + 对比法。光看内存绝对值意义不大(28MB → 512MB 只知道在哪爆了),但通过”其他模板能开 → 这个不能”的对比,能快速定位问题域。
  2. 报错行号是受害者,不是凶手class-wp-scripts.php:673 是 WordPress 脚本本地化时的最后一步,任何导致内存耗尽的代码都会在这里倒下。如果只看这一行去排查,方向就偏了。
  3. 修订版本是容易忽略的陷阱。Elementor 会自动保存修订,这些修订可能包含已经被修复的 bug 数据。当编辑器读取它们构建历史时,这些”僵尸数据”被重新激活。特别是循环自引用的短代码,在正常的 post 内容中可能已经被发现和修复,但藏在某个旧修订里,等着把你绊倒。
  4. 修复后别忘了心理上复盘 —— 回到最初,UE、显示条件、短代码这些怀疑都合情合理,但关键证据来自于”用最简数据依然崩”这个反直觉的发现。调试时要有勇气推翻自己的直觉假设。
发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注