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