做 WooCommerce 项目最折磨人的往往不是商品逻辑,而是结账页面翻译。你辛辛苦苦把主题、商品分类、文章全部翻译完了,一到结算页,各种硬编码的英文文案就这么直挺挺地怼在顾客脸上。那一刻你就明白了:结账页面的翻译不是一个“语言包”能解决的事,它是一项实打实的自定义工程。
这篇文章我会把自己在多个 WooCommerce 项目中积累的翻译实战经验完整拆开讲,包括结账页面翻译难在哪、工具链怎么选、如何从主题和插件两个层面做自定义翻译、踩过的坑怎么排,以及如何让整个翻译体系跑得更稳。无论你是刚开始接触 WooCommerce 本地化,还是已经被结账页的翻译问题折磨过几轮,这篇内容应该都能给你一条清晰的路。
1. 结账页面翻译为什么是个“硬骨头”
1.1 问题从哪来:主题、插件与动态内容的三重叠加
先说一个很扎心的现实:WooCommerce 本身是支持多语言的,但它支持的是“它自己的字符串”。真正让结账页面翻译变成难题的,是系统里其他参与渲染的元素。
第一层是主题。很多商业主题为了追求灵活性,会把结账页的按钮、提示文案直接写在主题代码里。比如“Proceed to Checkout”“Place Order”这些,如果主题作者没有走 WordPress 的翻译函数,而是直接写死了字符串,那无论你怎么切语言,它都原样输出英文。
第二层是插件。结账页永远不止 WooCommerce 一个插件在跑。运费计算插件、支付网关、地址校验、优惠券模块……每个插件都可能往结账页塞自己的文案。有些插件用了标准的翻译机制,翻译文件齐全,有些则是“能用就行”的态度,字符串全部硬编码。最典型的就是某些第三方支付网关,错误提示直接 from the gateway API 英文原样返回。
第三层才是真正头疼的动态内容。结账页有很多文案不是在 PHP 里渲染的,而是由 JavaScript 根据用户的输入状态实时生成。比如“Please enter a valid phone number”“This field is required”,这些文本存在 JS 文件里,甚至直接来自 Ajax 请求的返回值。你就算把主题的 .po 文件翻译得再完美,这些动态文案也纹丝不动。
所以翻译结账页面,本质上是在跟这三层内容同时作战。你要先搞清楚一个字符串是从哪一层来的,才能决定用哪种方式去处理它。
1.2 需求拆解:用户看到的“翻译完整”到底是什么
如果你把“翻译完整”定义为“顾客在结账页看不到英文”,那实际要覆盖的点比想象中多得多。我通常会在项目启动时列一个结账页文案清单,至少包含这么几类:
- 页面标题和面包屑,比如“Checkout”
- 表单字段的 label,比如“First Name”“Billing Address”“Phone”
- 表单占位符 placeholder
- 按钮文字,比如“Place Order”
- 校验提示,包括 PHP 侧和 JS 侧的
- 支付方式名称和描述
- 运费方式名称
- 优惠券输入框和提示文案
- 订单摘要里的“Subtotal”“Shipping”“Total”
- 底部条款、隐私提示等法律文案
- 第三方服务返回的错误或通知信息
这个清单看起来简单,但每一类背后都可能对应着不同的技术实现。你只有把“用户看到的完整翻译”拆解成这些具体条目,才知道自己到底缺了哪一块。很多项目翻译不彻底,不是因为工具不行,而是因为需求层面就没有把“完整”定义清楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 翻译工具选型:从现成插件到自定义方案的路线
2.1 主流翻译方案对比
做 WooCommerce 结账页翻译,市面上的路线大概有三条。
第一条是直接用多语言插件。WPML、Polylang、TranslatePress 这些工具都能覆盖 WooCommerce 的大部分前台字符串。它们通过扫描主题和插件的翻译函数来生成可翻译条目,你可以在后台的可视化编辑器里直接点选翻译。这条路最大的优点是快,适合纯内容站点,不需要懂代码。但缺点也很明显:遇到硬编码字符串和 JS 动态文案时,它一样无能为力,需要额外写代码做补充。
第二条是手工编译语言包。下载 Loki 或 Poedit,手动编辑 .po/.mo 文件,然后扔到主题或插件的 languages 目录。这是最“正统”的 WordPress 翻译流程,稳定性高,对源码无侵入。缺点是加载时机有讲究,子主题和插件文件优先级容易混乱,而且无法处理动态文本。
第三条是我个人最推荐的自定义翻译层。用 gettext filter 钩子、子主题 functions.php 里的文本替换、配合对 JS 文件的处理,把翻译逻辑集中到一个可控的自定义模块里。这条路前期投入高一些,但是最可控,能覆盖前两种方案覆盖不了的内容,而且后续维护成本非常低。
2.2 为什么我最终选择自定义方案
我前几个项目用的是 WPML 加主题语言包的组合,一开始感觉挺顺利,但到了结账页就频频出问题。经常是某个字段标签在英文下显示正常,切到中文就回退成英文,排查半天发现是主题作者在模板里用了 esc_html_e( 'Address', 'theme-slug' ),而语言包里的翻译条目用的 text domain 对不上。
后来我痛定思痛,决定把翻译逻辑从前台的“标签匹配”转变为“输出拦截”。也就是说不依赖插件去扫描翻译条目,而是直接在输出层面对字符串做自定义映射。用一个 translation override 的机制,把所有需要翻译的字符串集中维护,再根据当前语言环境去加载对应的映射表。这个方案从此成了我做 WooCommerce 本地化项目的标配。
自定义方案的核心优势有三个:
- 覆盖面广:只要字符串最终输出到了页面,无论它是来自主题、插件还是核心文件,都可以在输出层拦截。
- 可审计:所有自定义翻译集中在一个文件里,方便随时查看哪条文案被覆盖了,哪条遗漏了。
- 不依赖插件生命周期:多语言插件更新、主题升级,都不会影响你的自定义翻译层。
3. 核心实现:自定义翻译的完整实操流程
3.1 搭建子主题并注册语言文件
第一步永远是把子主题准备好。直接在父主题上做翻译的后果,就是主题一更新,所有翻译全部打回原形。子主题不仅是为了改样式,也是为了给你自己的代码一个“常驻空间”。
我在子主题的 functions.php 里挂一个加载自定义翻译的函数:
php复制add_action( 'after_setup_theme', 'custom_checkout_translation_setup' );
function custom_checkout_translation_setup() {
load_child_theme_textdomain( 'custom-checkout', get_stylesheet_directory() . '/languages' );
}
这个操作让子主题拥有了自己的语言文件加载通道。做好这一步之后,你在子主题 languages 目录下放 zh_CN.po 和 zh_CN.mo 文件,再定义需要覆盖的字符串。
如果你完全不想碰 .po 文件,也可以在 functions.php 里直接用 gettext 过滤器做字符串映射。这个方法更适合字符串数量不多、不想引入额外文件的情况。
php复制add_filter( 'gettext', 'custom_checkout_gettext', 20, 3 );
add_filter( 'ngettext', 'custom_checkout_ngettext', 20, 4 );
function custom_checkout_gettext( $translated, $original, $domain ) {
$strings = array(
'Billing details' => '账单详情',
'Your order' => '你的订单',
'Place order' => '提交订单',
'Proceed to checkout' => '前往结算',
);
if ( isset( $strings[$original] ) ) {
return $strings[$original];
}
return $translated;
}
gettext 过滤器会拦截所有经过 __()、_e() 等函数输出的字符串。只要结账页的文案走了 WordPress 的翻译函数,这个映射就能生效。它比 .po 翻译更直接,也更好调试,适合翻译覆盖量在几十条以内的场景。
3.2 用 gettext 过滤器完成按钮与提示文案覆盖
3.2.1 覆盖默认文案
有些字符串虽然 WooCommerce 核心已经做了语言包支持,但默认语言包翻译得不够好,或者文案不符合你的业务表达习惯。这时候你不需要去改 WooCommerce 的语言包,直接通过自定义 gettext 映射来覆盖就行。
举个例子,订单详情里的“Subtotal”被翻译成“小计”,但你的业务里想叫“商品合计”。直接映射:
php复制$strings['Subtotal'] = '商品合计';
3.2.2 覆盖主题里的硬编码文本
主题里的硬编码文本,是最容易出现 text domain 混乱的地方。比如某个主题把“Customize your order”直接写死在结账页模板里,而且用的是 esc_html_e( 'Customize your order', 'custom-theme' )。如果你的自定义映射只覆盖了 WooCommerce 的 text domain,这一条就漏掉了。
好在我们用 gettext 过滤器的时候,$domain 参数是可以判断来源的。你可以决定是全域名统一映射,还是精确到某个 domain 再映射。
我一般习惯在映射表里记录每个字符串来自哪个 domain:
php复制$strings = array(
'custom-theme' => array(
'Customize your order' => '定制你的订单',
'Add note' => '添加备注',
),
'woocommerce' => array(
'Place order' => '提交订单',
),
);
这样做的优势是排查问题的时候,你能快速知道某条文案是被哪个映射“接住”了。翻译不生效时,第一个要查的就是 domain 是否匹配。
3.3 解决 JavaScript 动态文案的翻译问题
PHP 侧的字符串拦截解决完了,接下来是结账页翻译真正的分水岭:JS 动态文案。
WooCommerce 结账页的大量校验提示都是由 JavaScript 渲染的,比如“The phone field is required.”“Invalid billing postcode.”。这类文案存在 woocommerce_params 对象里,或者直接写在插件的前端 JS 文件中。语言包对它几乎无效。
处理动态文案的思路有三种,我按实际项目中优先级排列:
3.3.1 直接覆盖 localized script 参数
WooCommerce 结账页会把很多文案通过 wp_localize_script 输出到前端的 JS 变量里。你可以在 WordPress 输出这些脚本之前,用钩子修改参数数组。
php复制add_filter( 'woocommerce_get_country_locale', function( $locale ) {
// 修改结账页国家/地区相关文案
return $locale;
} );
add_filter( 'woocommerce_get_base_location', function( $location ) {
return $location;
} );
更通用的一种做法是在 wp_enqueue_scripts 阶段直接重新注册 localized data。但插件的脚本队列很复杂,这一步往往要针对具体插件写单独的逻辑,不够通用。
3.3.2 加载自定义 JS 做字符串替换
更通用、更暴力的方案,是在页面里加载一段自定义 JS,在前端把带有特定文本节点的内容替换掉。我在项目里经常用 document.body.innerHTML 的定向替换,但要注意替换的精确度,不要误伤其他文案。
js复制jQuery( document ).ready( function( $ ) {
var translations = {
'Phone' : '手机号',
'Postcode' : '邮政编码',
'This field is required.' : '此项为必填项。',
};
$.each( translations, function( original, translated ) {
$( 'body' ).html( $( 'body' ).html().split( original ).join( translated ) );
} );
} );
这个方案的问题在于性能。如果字符串很多,频繁操作 DOM 会拖慢渲染。所以我会建议只在结账页加载这段脚本,并且优先使用更细粒度的节点替换,而不是直接操作整个 body。
3.3.3 从源头修改插件的脚本文件
如果动态文案来自某个特定插件,最干净的方式是反编译它的 JS 文件,找到字符串定义的位置,然后用子主题的 script override 机制把自定义的 JS 文件在插件之后加载,覆盖它的变量。
这个做法只适合维护能力强的团队,因为插件一升级,你的覆盖脚本可能就失效了。但它真的是治本的办法。
3.4 修改结账字段的 label 与 placeholder
WooCommerce 结账页表单字段,严格来说是允许通过代码自定义的。但很多开发者忽视了这一点,直接去改模板文件,导致升级时模板被覆盖。正确做法是用 woocommerce_checkout_fields 过滤器。
php复制add_filter( 'woocommerce_checkout_fields', 'custom_checkout_fields_labels' );
function custom_checkout_fields_labels( $fields ) {
$fields['billing']['billing_first_name']['label'] = '名字';
$fields['billing']['billing_first_name']['placeholder'] = '请输入名字';
$fields['billing']['billing_phone']['label'] = '手机号码';
$fields['billing']['billing_email']['label'] = '电子邮箱';
return $fields;
}
这个过滤器允许你直接覆盖 label、placeholder、description,甚至调整字段顺序、添加新字段。这是修改结账页表单最正统、最推荐的方式。
需要注意的是,这个过滤器只是修改了字段定义,前端实际渲染时还是会经过 woocommerce_form_field 函数,里面的默认文案依然可能从语言包来。所以 label 覆盖之后,你要确认前端输出的是不是自定义值。如果显示的是语言包里的值,可能是主题自定义了渲染逻辑,要顺藤摸瓜去查主题的模板函数。
3.5 结账页区块页面模板的处理
新版 WooCommerce 推出了基于区块的结账页,也就是 Checkout Block。这东西相比传统短代码结账页,灵活性更高,但翻译处理也变得更复杂。
区块页面的文案很多是通过区块本身的国际化机制渲染的,字符串可能存在 @wordpress/i18n 的翻译文件中。这时候传统 gettext 过滤器依然有效,但覆盖时机更微妙。如果你发现区块结账页的某个文案翻译不生效,先检查是不是把筛选器挂错了钩子。
区块渲染时会走 render_block 过滤器,你可以针对 woocommerce/checkout 这个区块做专门的字符串处理:
php复制add_filter( 'render_block', 'custom_checkout_block_translation', 10, 2 );
function custom_checkout_block_translation( $block_content, $block ) {
if ( $block['blockName'] === 'woocommerce/checkout' ) {
$block_content = str_replace( 'Billing address', '账单地址', $block_content );
}
return $block_content;
}
如果不幸用了 str_replace,记住这只是临时方案。真正要做完整翻译,最好还是维护一份针对区块结构的字符串映射表,然后用 DOMDocument 来解析和替换,避免误替换。
4. 踩坑实录:常见问题与排查技巧
4.1 翻译不生效的三大原因
做自定义翻译时,遇到最多的问题就是“我明明写了映射,为什么不生效”。我把它归结为三个主要原因。
第一个是 gettext 过滤器执行顺序。其他插件或者主题可能也挂了 gettext 过滤器,并且优先级比你高。如果你在 gettext 过滤器里返回了字符串,但后面有一个优先级更低的过滤器又把它改了回去,那最终输出就不符合你的预期。排查方式是在过滤器里加 error_log 输出,确认你的映射是否被执行了。
第二个是字符串本身不是通过翻译函数输出的。比如主题直接在模板里写了 <span><?php echo 'Order Details'; ?></span>,这种字符串根本不经过 gettext 系统,你的过滤器再强也没用。这种只能通过输出缓冲或者 str_replace 来兜底。
第三个是大小写和空格问题。我遇到过最离谱的一次,是目标字符串里有个不可见的 UTF-8 空格字符,肉眼根本看不出来。排查办法是把字符串复制到编辑器里,切换成 hex 模式查看每个字符的码点。
4.2 编码问题与特殊字符
结账页翻译最容易翻车的地方,就是编码。如果你的 .po 文件或者 functions.php 文件不是 UTF-8 编码,中文字符就会变成乱码。而很多代码编辑器的默认编码可能不是 UTF-8,保存的时候没有注意就出问题了。
更隐蔽的是 HTML 实体字符。比如 & 在 HTML 里要写成 &,在翻译里如果直接写 &,页面渲染时可能会被转义。我在做支付方式翻译时,经常遇到“PayPal Checkout”里包含特殊字符的情况,这时你就需要判断该用原始字符串还是转义后的字符串去匹配。
经验是:优先用页面源码里显示的真实字符做匹配。如果拿不准,打开浏览器开发者工具,复制出文本节点里的内容,再放到 gettext 过滤器中。
4.3 多语言插件与自定义翻译同时工作时的冲突
如果你项目里既有 WPML,又有自定义翻译层,一定要想清楚它们的执行顺序。WPML 会在 gettext 过滤器上做一套自己的逻辑,而且优先级通常设置在 10 左右。如果你的自定义 filter 也挂在 10 以下,就可能被 WPML 的优先级覆盖。
我的做法是,自定义翻译的优先级设置在 20 或 30,确保在 WPML 之后执行,让自定义翻译成为最终裁定者。
php复制add_filter( 'gettext', 'custom_checkout_gettext', 30, 3 );
但要注意,这个顺序只是默认的。WPML 的某个功能更新后可能会调整优先级,所以最稳妥的方式还是写一个单元测试,对关键字符串的翻译结果做断言。
4.4 前端缓存与动态字符串的对抗
缓存是结账页翻译的隐形杀手。页面缓存、对象缓存、CDN 缓存,任何一个环节都可能让你刚改好的翻译无法即时生效。
动态 JS 替换的方案尤其容易踩这个坑。因为页面缓存不会重新执行 JS,所以只要缓存还在,用户拿到的就是旧文案。解决方案是在自定义 JS 文件加载的 URL 上加上文件版本号,每次修改翻译时递增版本号,强制浏览器加载新文件。
php复制wp_enqueue_script(
'checkout-custom-translations',
get_stylesheet_directory_uri() . '/js/checkout-translations.js',
array( 'jquery' ),
'2.1.0',
true
);
版本号从静态的 1.0.0 改成动态获取文件修改时间的写法,能减少很多“为什么我没看到变化”的尴尬。
5. 性能优化与多语言架构思考
5.1 翻译数据的存储与加载优化
自定义翻译层如果做得太重,字符串映射表动辄几百条,每次请求都要加载和匹配,势必会影响页面性能。我的建议是分清主次:数量少、高频使用的字符串用 PHP 数组直接映射;数量大、低频使用的字符串放到独立 JSON 文件或者自定义数据库表里,按需加载。
结账页属于高频页面,所以尽量把数组映射写在内存里,不要每次查数据库。如果你有几十条以上的字符串,可以考虑用 WordPress 的对象缓存包装一层:
php复制$translations = wp_cache_get( 'custom_checkout_strings', 'translations' );
if ( false === $translations ) {
$translations = include get_stylesheet_directory() . '/inc/checkout-translations.php';
wp_cache_set( 'custom_checkout_strings', $translations, 'translations', 3600 );
}
这个做法在生产环境配合 Redis 或者 Memcached 会有明显效果。
5.2 多语言切换的体验设计
翻译只是本地化的第一步。真正做得好,还要考虑用户在结账页切换语言时的体验。很多站点的语言切换器放在页脚,用户到结账页想切换语言还得滚到底部,体验很差。
我一般会在结账页的顶部加一个轻量级语言切换提示。如果检测到用户当前语言和站点默认语言不一致,就显示一行“您正在使用中文结账”的提示,并且提供切换入口。这个功能不复杂,但能显著降低因语言不一致导致的误操作和弃单率。
5.3 从“翻译”到“本地化”的思维升级
翻译工作做到最后,你会发现真正重要的事情是本地化,而不仅仅是换一种语言。所谓本地化,还包括数字格式、货币符号、地址格式、电话格式、日期格式的适配。
举个例子,结账页的邮编字段,不同国家的格式完全不同。美国邮编是 5 位数字,英国是字母加数字混合,中国是 6 位数字。WooCommerce 通过 country locale 机制默认处理了一部分,但翻译之后你还是要检查一遍字段校验规则是否匹配目标市场的预期。
再比如电话号码的输入框,默认校验可能只允许数字和 + - ( ) 字符。但在某些国家和地区,电话号码里可能出现扩展名,校验逻辑就得相应调整。这些细节不是翻译问题,却是在做翻译时一定会碰到的问题。
结账页的地址字段也一样。国内习惯先写省再写市,国外习惯街道、城市、州、邮编分开。如果你翻译了 label,但字段顺序还是老外的顺序,用户填起来就会很别扭。这时候就要用到 woocommerce_default_address_fields 过滤器重新排列字段顺序。
php复制add_filter( 'woocommerce_default_address_fields', 'custom_address_fields_order' );
function custom_address_fields_order( $fields ) {
$fields['country']['priority'] = 10;
$fields['state']['priority'] = 20;
$fields['city']['priority'] = 30;
$fields['address_1']['priority'] = 40;
$fields['address_2']['priority'] = 50;
$fields['postcode']['priority'] = 60;
return $fields;
}
做完这一步,再加上合适的 label 翻译,顾客填写地址时的体验才算真正过关。
6. 一个可复用的自定义翻译模块
6.1 模块结构
把前面讲到的方案整合起来,我建议你用一个独立模块来管理结账页自定义翻译,而不是散落在 functions.php 里。目录结构大概是这样的:
code复制child-theme/
├── custom-checkout/
│ ├── custom-checkout.php
│ ├── includes/
│ │ ├── class-translation-manager.php
│ │ ├── class-checkout-field-editor.php
│ │ └── class-js-override.php
│ ├── languages/
│ │ ├── zh_CN.po
│ │ └── zh_CN.mo
│ └── assets/
│ └── checkout-translations.js
主文件 custom-checkout.php 负责加载所有子模块,按照 WordPress 的标准插件风格来写。这样即使哪天你不想用子主题承载它了,也可以直接通过 mu-plugins 加载同一个模块。
6.2 翻译管理器实现思路
翻译管理器的核心职责是:接收原始字符串,根据当前语言返回翻译结果。我习惯把它设计成支持三种数据源的形式:硬编码数组、外部语言包文件、自定义数据库表。
php复制class Checkout_Translation_Manager {
private static $instance = null;
private $translations = array();
private $loaded_sources = array();
public static function instance() {
if ( null === self::$instance ) {
self::$instance = new self();
self::$instance->init();
}
return self::$instance;
}
private function init() {
$this->load_inline_translations();
$this->register_gettext_filters();
$this->load_js_translations_for_browser();
}
private function load_inline_translations() {
$this->translations = include get_stylesheet_directory() . '/custom-checkout/includes/translations-map.php';
}
public function translate( $string, $domain = 'woocommerce' ) {
if ( isset( $this->translations[ $domain ][ $string ] ) ) {
return $this->translations[ $domain ][ $string ];
}
if ( isset( $this->translations['*'][ $string ] ) ) {
return $this->translations['*'][ $string ];
}
return $string;
}
}
这样设计的好处是,所有翻译逻辑收敛在一个类里,后续要接第三方的翻译管理平台也很容易,只需要在这个类里增加一个数据源接口就行。
7. 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 按钮文案翻译不生效 | 主题硬编码字符串 | 用 gettext filter 无法覆盖时,改用 render_block 或输出缓冲替换 |
| 翻译后出现乱码 | 文件编码不是 UTF-8 | 统一保存为 UTF-8 无 BOM 格式 |
| 只有部分页面翻译生效 | 缓存问题 | 清理页面缓存并递增 JS/CSS 版本号 |
| WPML 切换语言后自定义翻译失效 | 过滤器优先级冲突 | 将自定义过滤器的优先级调至 20 以上 |
| JS 动态提示仍然是英文 | localized script 参数未覆盖 | 重写脚本参数或使用 DOM 替换脚本 |
| 结账字段 label 改了但显示没变 | 主题覆盖了 render 逻辑 | 检查主题模板中的 woocommerce_form_field 调用 |
| 支付网关错误信息是英文 | 错误来自远程 API | 在前端做错误信息映射,或联系网关支持 |
这个表里的场景,都是我实际做项目时碰到过的。你可以把它当成一个排查起点,遇到问题照着对应方向去查,能省掉不少时间。
最后再分享一个小技巧。结账页面翻译做完之后,不要只在后台断点调试,一定要用无痕窗口走完全流程,从加入购物车到支付成功,每一个步骤都亲眼确认文案显示正常。支付成功回调之后的一些文案,往往是翻译最容易遗漏的角落。等顾客在下单之后收到一封英文确认信再回来投诉,那就真的晚了。
