作者复盘7月向WordPress提交24个PR的经历,包括WooCommerce日志批量删除的性能修复、REST接口新增等实际代码改动及审查过程。
七月从一开始就显得不同寻常。我在这个月一开始就遇到了一个 WooCommerce 日志 bug,最终关闭了 24 个 pull request,分布在 9 个不同的 WordPress 仓库中。有些修复只花了十分钟,有一个却花了一整周与维护者反复沟通,只为了一行代码。
这篇文章是我 2026 年 7 月的开源 WordPress 贡献回顾。我分享的是每个 pull request 的实际代码,而不仅仅是链接列表,这样你就能看到具体改了什么以及为什么重要。
逐个修复 WooCommerce
WooCommerce 占据了我这个月的大部分时间。我向核心插件合并了 10 个 pull request,从一行代码的性能调优到全新的 REST 端点都有涉及。下面逐一讲解。
日志清理悄悄遗落了文件
这个月以 PR #66073 开头。LogHandlerFileV2::delete_logs_before_timestamp() 获取过期日志文件时没有设置 per_page 值,因此继承了管理后台 UI 的默认值 20。在有超过 20 个过期日志来源的站点上,每日清理 cron 任务只删除前 20 个,其余的永远留在 wp-content/uploads/wc-logs/ 中。
修复将删除改为批量处理,不再依赖单次查询:
// BEFORE:
$files = $this->file_controller->get_files( [ 'before' => $timestamp ] );
foreach ( $files as $file ) {
$this->delete_log_file( $file );
}
// AFTER (batched with a no-progress guard):
do {
$files = $this->file_controller->get_files( [
'before' => $timestamp,
'per_page' => 100,
] );
$deleted_this_pass = 0;
foreach ( $files as $file ) {
if ( $this->delete_log_file( $file ) ) {
$deleted_this_pass++;
}
}
} while ( $files && $deleted_this_pass > 0 );
现在一个有 101 个遗留日志文件的站点会全部删除,而不是仅仅删除 20 个。我添加了一个"无进展"守卫,以便当某个批次无法删除时循环停止,防止在卡住的文件上产生无限循环。
订单列表表格缓存忽略了自定义过滤器
PR #66207 修复了一个更隐蔽的 bug。ListTable::prepare_items() 在任何过滤器运行之前就检查查询参数,来决定是否跳过 SQL_CALC_FOUND_ROWS。如果开发者钩上了 woocommerce_order_list_table_prepare_items_query_args 来添加 meta_query,缓存快速路径会对它视而不见,返回过时的总数。
// BEFORE (checked pre-filter args):
if ( empty( array_diff( array_keys( $this->order_query_args ), $safe_keys ) ) ) {
$args['no_found_rows'] = true;
}
// AFTER (checks post-filter args):
if ( empty( array_diff( array_keys( $order_query_args ), $safe_keys ) ) ) {
$args['no_found_rows'] = true;
}
将检查移至过滤器运行之后,意味着任何插件添加的键都能正确地禁用缓存快捷方式。这是一个小改动,但它阻止了商家在自定义查询过滤器激活时看到错误的订单数量。
六次请求合并为一次
这个月最复杂的 WooCommerce PR 是 PR #66276。每次 wp-admin 页面加载都会触发 6 个独立的 wc-analytics REST 请求,仅仅是为了填充 Activity Panel 铃铛图标和"接下来要做的事"首页小部件。三个端点,每个被两个不同组件各调用一次,它们之间没有共享缓存。
我改为添加了一个单一的组合端点:
class ActivityPanelCounts extends \WC_REST_Data_Controller {
protected $namespace = 'wc-analytics';
protected $rest_base = 'activity-panel/counts';
public function get_counts( $request ) {
return rest_ensure_response( [
'orders_to_fulfill_count' => $this->get_count_via( '/wc-analytics/orders', [
'page' => 1,
'per_page' => 1,
'status' => $request->get_param( 'order_statuses' ),
'_fields' => [ 'id' ],
] ),
'reviews_to_moderate_count' => $this->get_count_via( '/wc-analytics/products/reviews', [
'page' => 1,
'per_page' => 1,
'status' => $request->get_param( 'review_status' ),
] ),
] );
}
}
@woocommerce/data 中匹配的 activityPanelStore 选择器意味着三个 Activity Panel 组件现在都从一个地方读取。解析缓存将六次网络请求压缩为每次页面加载一次,并且计数逻辑没有任何变化,因为新端点只是在内部委托给现有端点。
如果你想更深入地了解我如何研究 WooCommerce 内部原理,我的 2026 年 6 月开源回顾涵盖了一个类似的 REST 端点整合案例。
访客订单终于显示姓名了
PR #66279 修复了困扰店主已久的问题。WooCommerce 首页订单面板为可操作订单显示"订单 #123 客户姓名",但访客结账没有 customer_id 可以查询,因此姓名总是返回空白。
// BEFORE: only checked the registered customer record
const customerName = order.customer ? order.customer.name : '';
// AFTER: falls back to billing details for guest orders
const customerName = order.customer
? order.customer.name
: [ order.billing?.first_name, order.billing?.last_name ]
.filter( Boolean )
.join( ' ' );
订单响应已经包含了账单地址。我只是把它添加到请求字段中并作为后备。注册客户仍然获得与其档案关联的姓名,访客则像该行其他内容一样显示为纯文本。
产品分类框默认被隐藏
PR #65990 解决了一个首次访问的烦恼。在用户首次访问"外观 → 菜单"时,WordPress 除了页面、文章、自定义链接和分类之外隐藏了所有元盒子。这个默认列表也吞掉了产品分类、产品标签和品牌盒子,所以新用户必须深入"显示选项"才能将这些添加到菜单中。
public function filter_default_nav_menu_hidden_meta_boxes( $result, $option, $user ) {
global $wp_meta_boxes;
if ( false !== $result || ! $user || ! isset( $wp_meta_boxes['nav-menus'] ) ) {
return $result;
}
$visible = [
'add-post-type-page', 'add-post-type-post', 'add-custom-links',
'add-category', 'add-product_cat', 'add-product_tag',
'woocommerce_endpoints_nav_link',
];
if ( taxonomy_exists( 'product_brand' ) ) {
$visible[] = 'add-product_brand';
}
$hidden = [];
foreach ( $wp_meta_boxes['nav-menus'] as $priorities ) {
foreach ( (array) $priorities as $boxes ) {
foreach ( (array) $boxes as $box ) {
if ( isset( $box['id'] ) && ! in_array( $box['id'], $visible, true ) ) {
$hidden[] = $box['id'];
}
}
}
}
return $hidden;
}
这会钩上 get_user_option_metaboxhidden_nav-menus,使其仅在用户尚无保存的偏好时触发。已保存"显示选项"选择的现有用户不受影响。
品牌导航小部件中的缓存中毒 bug
PR #65947 是这个月最难追踪的 bug。品牌导航小部件的 filter_out_cats() 方法钩上了 woocommerce_product_subcategories_args,当 URL 中有品牌过滤器激活时返回空 taxonomy。该空 taxonomy 查询返回零行,而 WooCommerce 将这些零行缓存到与常规非过滤请求相同的键下。之后每个访客,无论是否有品牌过滤器,都会读取被污染的缓存,看到完全没有子分类。
// BEFORE: always cached the result, even an empty taxonomy query
wp_cache_set( $cache_key, $result, 'product_cat' );
// AFTER: only cache when the query actually resolved to a taxonomy
if ( ! empty( $args['taxonomy'] ) ) {
wp_cache_set( $cache_key, $result, 'product_cat' );
}
一个空 taxonomy 查询永远不会产生有意义的结果,所以存储它只是在浪费地污染共享缓存。当 taxonomy 为空时跳过缓存写入,修复了所有访客的问题,而不仅仅是触发品牌过滤器的那一个。
可视化属性的九个默认颜色
PR #65923 添加了一个小的体验改进功能。当商家创建一个新的"颜色/图片"属性时,术语列表初始为空,他们必须手动逐个添加每种颜色。这会 自动填入 9 种常见颜色:
private static function get_default_color_terms(): array {
return [
'black' => [ 'label' => __( 'Black', 'woocommerce' ), 'color' => '#121212' ],
'white' => [ 'label' => __( 'White', 'woocommerce' ), 'color' => '#FFFFFF' ],
'red' => [ 'label' => __( 'Red', 'woocommerce' ), 'color' => '#D32F2F' ],
'blue' => [ 'label' => __( 'Blue', 'woocommerce' ), 'color' => '#1976D2' ],
'green' => [ 'label' => __( 'Green', 'woocommerce' ), 'color' => '#388E3C' ],
// gray, yellow, pink, and brown follow the same pattern
];
}
播种器仅从 WC_Admin_Attributes::process_add_attribute() 运行,因此它只在商家通过 UI 创建属性时触发,而不是通过编程式 wc_create_attribute() 调用或 CSV 导入触发。这个区别在审查时很重要,因为没有人希望批量导入在静默状态下注入没人需要的术语。
三个较小的 WooCommerce 修复完成了这批工作:
PR #66280 修复了 product_brand_thumbnails_description shortcode 上缺失的布局样式。其样式表从未定义 list-style: none 或 clearfix,导致 shortcode 渲染为裸露的项目符号列表而非网格。我还使用 max( 1, absint( $args['columns'] ) ) 对 columns 参数进行了限幅,这样像 columns="abc" 这样的无效值不会抛出 DivisionByZeroError。
PR #66188 修复了 WooCommerce 状态页上虚假的"Not scheduled"消息。每日 Cron 检查查找的是旧的 wp_next_scheduled('wc_admin_daily') 钩子,但该钩子早已迁移到 Action Scheduler 的 wc_admin_daily_wrapper。将检查改为 as_next_scheduled_action('wc_admin_daily_wrapper') 修复了新安装上的虚假警报。
PR #66273 在六个遗留 JS 文件中将弃用的 jQuery .focus() 简写调用替换为 .trigger( focus ),清除了经典结账页上出现的 JQMIGRATE: jQuery.fn.focus() event shorthand is deprecated 警告。
如果你想尝试自己提交这样的修复,WooCommerce 贡献指南详细说明了核心团队期望的编码标准和 PR 流程。
LifterLMS 在 7 月合并了 7 个 pull requests,其中大部分来自对插件运行 WordPress 插件检查工具并处理其标记的问题。
PR #3201 和 PR #3209 都在模板加载器和课程进度控制器中将 wp_redirect() 替换为 wp_safe_redirect()。我通过直接补丁和扫描驱动两种方式提交了修复,两次都落地了相同的更改:
// 修复前:
if ( $redirect ) {
nocache_headers();
wp_redirect( $redirect );
exit;
}
// 修复后:
if ( $redirect ) {
nocache_headers();
wp_safe_redirect( $redirect );
exit;
}
$redirect 值可以来自过滤器,这意味着第三方插件可能传入不受信任的 URL。wp_safe_redirect() 默认阻止外部主机,封闭了这条开放重定向路径。课程完成重定向得到了同样的处理,因为它也是在使用前通过过滤器传递的。
PR #3198 为 15 个 PHP 文件添加了标准的 defined( 'ABSPATH' ) || exit; 守卫,这些文件大多是被插件检查标记的构建器视图模板:
// 修复前:
<?php
/**
* Builder lesson model view
*/
?>
<script type="text/html" id="tmpl-llms-lesson-template">
// 修复后:
<?php
/**
* Builder lesson model view
*/
defined( 'ABSPATH' ) || exit;
?>
<script type="text/html" id="tmpl-llms-lesson-template">
插件检查标记的两个文件已经有等效的守卫,所以我没有动它们。修复是机械性的,但它阻止了对这些从未打算在 WordPress 引导之外运行的文件的直接 HTTP 访问。
PR #3194 修复了一个仅在启用持久对象缓存(如 Object Cache Pro)时才会出现的 bug。保护文件的授权结果通过 wp_cache_add() 进行缓存,该方法仅在键不存在时才写入。如果有人先查看了一个未受保护的文件,"not protected"的'null'哨兵会永远留在缓存中,即使该文件后来被保护了。
// 修复前:
wp_cache_add( $cache_key, 'null', 'llms_media_authorization', $cache_expiration );
// 修复后:
wp_cache_set( $cache_key, 'null', 'llms_media_authorization', $cache_expiration );
切换到 wp_cache_set() 意味着缓存始终反映当前状态。我还添加了 invalidate_authorization_cache() 方法,并将其挂接到保护保存流程中,这样文件保护状态改变的瞬间缓存就会被清除。
PR #3181 修复了一个标签错误。当文件通过文件块的锁定图标被保护时,媒体库附件页面显示的是插件的标签("protected as an assignment submission")而非核心标签,因为插件的过滤器对每个受保护文件都运行,而不管是谁保护了它。
$auth_filter = $protector->get_authorization_filter_name( $post->ID );
$is_addon_protected = $auth_filter && 'llms_attachment_is_access_allowed' !== $auth_filter;
$is_core_protected = $protector->is_media_protected( $post->ID ) && ! $is_addon_protected;
if ( $is_core_protected ) {
return $form_fields;
}
核心保护的文件现在在插件过滤器运行之前就使用正确的标签提前返回了。插件仍然可以给自己的文件打标签,因为检查比较的是实际的授权钩子名称,而不只是检查一个布尔值。
PR #3236 将凭证编辑页面上的"Uses"输入框宽度从 50px 加宽到 80px,因为像 1000 这样的 4 位数兑换计数被截断了。PR #3237 为访问计划按钮添加了 wp-element-button CSS 类,使其在块主题上正确继承 theme.json 中的主题按钮样式,同时不影响经典主题。
开放重定向也是 WordPress 之外有详细记录的的攻击模式。OWASP 未验证重定向和转发速查表解释了为什么让用户输入控制重定向目标是危险的,适用于任何 Web 应用程序,而不仅仅是 WordPress 插件。
我对官方 WordPress 插件检查工具(也是 WordPress.org 上插件检查插件背后的同一个扫描器)提交了一个 pull request,修复了它在验证 Requires Plugins 头时的一个可用性差距。在此更改之前,声明了多个依赖项的插件只会收到一条通用的"header is not valid"错误,没有任何关于哪个 slug 是实际问题的提示。
private function check_requires_plugins_header( $result, $requires_plugins, $label, $main_file ) {
$slugs = array_map( 'trim', explode( ',', $requires_plugins ) );
foreach ( $slugs as $slug ) {
if ( '' === $slug ) {
continue;
}
if ( ! preg_match( '/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug ) ) {
// 报告一个命名这个特定 slug 的错误
continue;
}
$this->check_requires_plugins_slug_status( $result, $slug, $main_file );
}
}
每个格式错误的 slug 现在都会得到自己的一条错误,命名那个确切的 slug。有效的 slug 通过 transient 缓存的 API 调用对照 WordPress.org 插件目录进行检查,如果声明的依赖项在那里找不到,就会出现警告。在审查期间,维护者指出检查插件的本地安装状态不是这个检查的工作。plugin_repo 类别检查应该验证插件是否准备好进入目录,所以我完全放弃了最初的本地状态方法,转而使用目录查找。
Yoast SEO PR #23466 修复了一个评分 bug,这个 bug 一直在静默地拖累本来完美的 SEO 分析。functionWordsInKeyphrase 评估正确地在焦点关键词包含真实内容词时返回一个空结果,因此它会从 UI 中隐藏。但主要的 SEO 和分类评估器仍然在总分母中计算那行空结果,这将一个完全绿色的分析从大约 99 分拉低到大约 94 分。
// 修复前:
this.assessor = new SEOScoreAggregator();
// 修复后:
this.assessor = new ValidOnlyResultsScoreAggregator();
相关的关键词和集合页面评估器已经使用了 ValidOnlyResultsScoreAggregator,它会跳过没有真实分数的结果。将主要的 SEO 和分类评估器切换到同一个聚合器使它们保持一致,每个建立在其之上的子类(基石内容、产品页面、商店博客文章)都自动继承了这个修复。
官方 Yoast 开发者文档有更多关于 SEO 分析管道如何对单个评估打分的细节,如果你想进一步了解聚合器模式。
Ultimate Member PR #1833 修复了一个 bug,该 bug 在每个帖子上都将已注销的访客重定向到首页,但仅在运行 Spectra 块主题的网站上出现。根本原因需要一些挖掘。Ultimate Member 的 filter_protected_posts() 挂接了 the_posts,它会在每个 WP_Query 上触发,而不仅仅是主查询。在块模板解析期间,Spectrum 运行一个针对 UM"用户"页面的辅助查询,而该页面的访问设置会强制执行重定向。重定向逻辑没有检查它是否在主查询上运行,因此在一个仅为模板解析而运行的后台查询上退出了整个请求。
Secondary queries 仍然通过标题替换和文章隐藏来应用内容限制,只是不再调用 exit() 来终止整个页面。对于真正被限制的页面的直接访问仍然会正确重定向,因为主查询检查保留了这一行为。
Gutenberg: 恢复手机形状的预览
Gutenberg PR #80271 恢复了一项编辑器之前失去的功能。帖子编辑器中的移动端和平板预览曾经会显示手机或平板形状的边框。早前的一次修改弃用了旧的 useResizeCanvas hook,该 hook 注入了固定的设备高度,后来被只考虑宽度的模型取代。修改后宽度是正确的,但高度在编辑器中坍缩为自适应。
// New private selector deriving device-shaped preview height
function getCanvasHeight( state ) {
const width = getCanvasWidth( state );
const ratios = { mobile: 8 / 5, tablet: 4 / 3 };
const device = getDeviceForWidth( width );
if ( ! device || ! ratios[ device ] ) {
return undefined;
}
return Math.round( width * ratios[ device ] );
}
移动端采用 8:5 的竖屏比例,平板采用 4:3,都与编辑器回归问题之前的外观一致。高度仅在预览下拉菜单设置的精确预设宽度下生效,因此将边框拖动到自定义宽度后,高度会再次自适应填满编辑器。桌面端预览和 Site Editor 独立的可调整边框不受影响。
ElasticPress: 清理翻译者注释
ElasticPress PR #4323 修复了运行 wp i18n make-pot 时出现的两个警告。一个字符串有两个相互冲突的翻译者注释,还有一个占位符完全没有注释。
// BEFORE: two different comments for the same string literal
/* translators: %1$s: first feature name, %2$s: second feature name */
__( '%1$s and %2$s', 'elasticpress' );
// ... elsewhere in the file:
/* translators: %1$s: comma-separated list of feature names, %2$s: last feature name */
__( '%1$s and %2$s', 'elasticpress' );
// AFTER: one consistent comment for both call sites
/* translators: %1$s: feature name(s), %2$s: last feature name */
__( '%1$s and %2$s', 'elasticpress' );
我还在一个分页字符串上方添加了缺失的 /* translators: %d: Page number. */ 注释。翻译者注释不影响运行时行为,但一份干净的 POT 文件对插件的本地化者非常重要,因为缺失或重复的注释会让翻译者不得不猜测占位符的实际含义。
Pods Framework: 提前适配 PHP 8.5
Pods PR #7550 是本月按文件数计最大的单一目的变更。PHP 8.5 弃用了 (boolean) 强制类型转换,在 PHP 9.0 中将成为致命错误。我在 25 个文件中将所有相关用法替换为规范的 (bool) 形式,共计 139 处。
// BEFORE:
$params->single = (boolean) $params->single;
// AFTER:
$params->single = (bool) $params->single;
(bool) 从 PHP 最早版本起就是有效的写法,所以这是一对一的纯粹替换,零行为变更。不需要新的测试,因为运行时结果完全相同。之后我在整个仓库中用 grep 确认了零残留的 (boolean) 用法。
PHP 即将淘汰的完整强制类型转换和函数列表存在于官方 PHP 迁移指南中,如果你维护的插件已经很久没有更新,值得一看。
WP Rocket: 一行代码的 Schema 修复,却有真实影响
WP Rocket PR #8583 是本月最小的 diff,只有一行,但它修复了一个真实的 WP-CLI 兼容性问题。wp-rocket/get-recommendations ability 将其输入 schema 声明为 [ 'type' => 'null' ],导致 WP-CLI 的 ability 命令直接拒绝它,并报错 "input must be an object, null given"。
// BEFORE:
'input_schema' => [
'type' => 'null',
],
// AFTER:
'input_schema' => [],
空数组在 JSON Schema 术语中表示"无输入属性",这也是 WP-CLI 对不接受输入的 ability 的期望格式。一行代码,但彻底解除了 WP-CLI 运行该 ability 的阻塞。
本月我学到的
九款插件的二十四个 pull request 教会了我一些值得记录的东西。
安全修复一旦开始关注就会聚集出现。本月有三款不同的插件存在开放重定向或直接访问问题,被 Plugin Check 标记出来。一旦你在一个插件上运行过扫描,就会开始在其他地方发现同样的模式。这不是巧合,而是整个生态系统在十年间有机发展的结果。
缓存 bug 是最难发现、最容易在 review 中被漏掉的。Brand Nav 缓存污染问题和 LifterLMS 媒体授权缓存 bug 都需要激活持久化对象缓存才能复现。一款插件在测试中可能看起来完全正确,但仍然会在运行 Redis 或 Object Cache Pro 的生产站点上污染所有访客共享的缓存键。
重复工作会发生,这很正常。我通过两个独立的 pull request 提交了完全相同的 LifterLMS 重定向修复,两次都被合并了,代码完全一致。开源并不总是一条完美协调的流水线。小规模的重复会发生,维护者处理起来也不会大惊小怪。
小的 PR 仍然需要真实的测试。WP Rocket 的一行修复和 ElasticPress 翻译者注释修复看起来都很 trivial。两个都仍然需要一个清晰的复现步骤、清晰的修复方案和清晰的测试步骤,因为 review 小 PR 的维护者仍然需要相信它真的有效。
七月将我推过了更多 rep