跳到正文
阿里雲開發者社區 AI·· 4 小時前AI 評分50

頁面建構器製作的頁面如何翻譯:短碼、區塊與動態內容的常見問題及處理方法

页面构建器做的页面怎么翻译:短代码、区块、动态内容各自的坑和绕法

AI 導讀

頁面建構器網站的多語翻譯應先處理渲染前的短碼與區塊資料,再於渲染後補譯動態 DOM。短碼可用佔位符保護不可翻譯片段,區塊屬性按白名單翻譯;動態內容則用 MutationObserver 分批處理,避免寫回 DOM 後重複觸發。

正文

2026-10-30 1

版权

版权声明:

本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《 阿里云开发者社区用户服务协议》和 《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写 侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

简介: 切到中文之后,按钮上显示的是 [cta text="Get a quote" /],页脚多出一段没解析的 JSON,几个本该是「加入购物车」的地方只剩空白。用页面构建器搭出来的站点,做多语言时经常出现这类情况,共同点是:文案并没有老老实实待在 HTML 文本节点里。 构建器把一句话拆成好几份:一部分在文本节点,一部分在标签属性里,还有一部分存在自己的数据结构里(区块属性、构建器的 JSON 配置)。翻译工具在哪个环节动手,决定了它会不会把页面翻坏。

切到中文之后,按钮上显示的是 [cta text="Get a quote" /],页脚多出一段没解析的 JSON,几个本该是「加入购物车」的地方只剩空白。用页面构建器搭出来的站点,做多语言时经常出现这类情况,共同点是:文案并没有老老实实待在 HTML 文本节点里。

构建器把一句话拆成好几份:一部分在文本节点,一部分在标签属性里,还有一部分存在自己的数据结构里(区块属性、构建器的 JSON 配置)。翻译工具在哪个环节动手,决定了它会不会把页面翻坏。

渲染前改,还是渲染后改

渲染前:拿到的是 post_content 里的原文,短代码还是 [cta text="Get a quote" /] 这个样子,区块还是属性数组。在这个环节翻译,短代码是完整的一整段,不容易被拆坏;代价是要自己判断哪些字段是给人看的。

渲染后:拿到的是浏览器里的 DOM,短代码已经被解析成 <a class="btn">Get a quote</a>。在这个环节翻译只能看到文本节点和属性,好处是能覆盖构建器动态生成的内容,坏处是留下的线索变少了,而且很容易把类名、字段名一起翻掉。

渲染前和渲染后都得做,顺序是先渲染前、再渲染后补漏。下面按内容存放的位置分开说。

短代码:先挖走,再翻译,最后填回

短代码被翻坏的典型方式,不是整段被翻,而是被拆开。[cta text="Get a quote" /] 里的 Get a quote 是可翻译文本,翻译接口只返回这一小段,回填时括号和属性名对不上,页面就渲染成纯文本了。

做法是把不该动的片段先替换成占位符,翻译完再原样换回来。

/**
 * 翻译前:把不可翻译片段替换为占位符。
 * @return array{0:string,1:array<string,string>} [处理后的文本, 占位符 => 原文]
 */
function i18n_extract_protected( string $html ): array {
   
    $store = [];
    $i     = 0;

    $rules = [
        '/<(pre|code)\b[^>]*>.*?<\/\1>/is',                 // 代码块:里面的东西一律不动
        '/\[([a-zA-Z0-9_-]+)(?:\s[^\]]*)?\].*?\[\/\1\]/s', // 成对短代码
        '/\[[a-zA-Z0-9_-]+(?:\s[^\]]*)?\/?\]/s',           // 自闭合短代码
        '/https?:\/\/[^\s"\'<>]+/i',                        // 裸链接
        '/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i',           // 邮箱
    ];

    foreach ( $rules as $re ) {
   
        $html = preg_replace_callback( $re, function ( $m ) use ( &$store, &$i ) {
   
            $token = '{
   {PROT' . $i++ . '}}';
            $store[ $token ] = $m[0];
            return $token;
        }, $html );
    }

    return [ $html, $store ];
}

function i18n_restore_protected( string $translated, array $store ): string {
   
    return strtr( $translated, $store );
}

占位符的形式要挑翻译接口会原样返回的那种。{ {PROT0}} 在多数接口下能活下来,但有的接口会改空格、加分词,所以上线前一定要断言数量一致,不一致就整段回退原文,不要发出去半截。另外成对短代码的正则要放在自闭合之前,否则 [cta ...]...[/cta] 会先被自闭合规则咬掉开头。

function i18n_tokens_intact( string $before, string $after ): bool {
   
    preg_match_all( '/\{\{PROT\d+\}\}/', $before, $a );
    preg_match_all( '/\{\{PROT\d+\}\}/', $after, $b );
    return $a[0] === $b[0];
}

区块与构建器 JSON:文案藏在属性里

只遍历文本节点,按钮文字、输入框提示、图片 alt、aria-label 全会漏。这些值存在区块属性或构建器自己的配置里,翻译必须发生在它们被渲染成 HTML 之前。

用区块的站点可以在渲染前的数据层拦一道,只翻白名单里的字段:

add_filter( 'render_block_data', function ( array $block ): array {
   
    // 只翻明确是给人看的字段,其余一概不碰
    $whitelist = [
        'core/image'  => [ 'alt' ],
        'core/search' => [ 'label', 'placeholder', 'buttonText' ],
    ];

    $name = $block['blockName'] ?? '';
    if ( ! isset( $whitelist[ $name ], $block['attrs'] ) ) {
   
        return $block;
    }

    foreach ( $whitelist[ $name ] as $key ) {
   
        if ( isset( $block['attrs'][ $key ] ) && is_string( $block['attrs'][ $key ] ) ) {
   
            $block['attrs'][ $key ] = translate_text( $block['attrs'][ $key ] );
        }
    }
    return $block;
}, 10, 1 );

render_block_data 在区块渲染之前触发,这时改属性不会影响结构。表里这两个区块只是举例,实际字段以你站点上保存的属性为准——把导出的一份区块数据打开看一眼,比照抄别人的清单靠谱。

构建器不用区块、文案存在自己的表里,思路一样:找到它读取配置的那个入口,在读取之后、拼 HTML 之前翻译。在输出后的 HTML 上用正则替换属性值,遇到转义和单双引号混用就开始出问题,而且每次构建器改版都要跟着改。

表单控件还要单独处理:value、placeholder、<option> 的文本、aria-label,这几项在切换语言时要跟着变,不能只在首次渲染时翻一次。

动态内容:补翻要防自己触发自己

构建器常在滚动、点击、切 tab 之后才把内容插进 DOM。用 MutationObserver 补翻是对的,但直接把翻译结果写回 DOM,会再次触发监听,运气不好就是无限循环加重复请求。

写回时先断开监听、给已翻节点打标记、把变更合并成批次,少一样就会绕回自己。

(function () {
   
  const done = new WeakSet();
  const queue = new Map();          // 文本节点 -> 原文
  const MAX_BATCH = 50;
  let timer = null;
  let observer;

  const opts = {
    childList: true, subtree: true, characterData: true };

  function collect(root) {
   
    const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
   
      acceptNode(node) {
   
        const p = node.parentElement;
        if (!p) return NodeFilter.FILTER_REJECT;
        if (/^(SCRIPT|STYLE|CODE|PRE|TEXTAREA)$/.test(p.tagName)) return NodeFilter.FILTER_REJECT;
        if (done.has(p)) return NodeFilter.FILTER_REJECT;
        if (!node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
        return NodeFilter.FILTER_ACCEPT;
      }
    });
    let n;
    while ((n = walker.nextNode()) && queue.size < MAX_BATCH) {
   
      queue.set(n, n.nodeValue);
    }
  }

  function flush() {
   
    timer = null;
    if (!queue.size || document.documentElement.lang === 'en') return;

    const nodes = [...queue.keys()];
    const items = nodes.map(n => queue.get(n));
    queue.clear();

    fetch('/api/i18n/translate', {
   
      method: 'POST',
      headers: {
    'Content-Type': 'application/json' },
      body: JSON.stringify({
    locale: document.documentElement.lang, items })
    })
      .then(r => r.ok ? r.json() : Promise.reject(r.status))
      .then(({
    items: out }) => {
   
        observer.disconnect();                 // 关键:写回期间不监听
        nodes.forEach((node, i) => {
   
          const t = out[i];
          if (typeof t === 'string' && t.trim() !== '') {
   
            node.nodeValue = t;
            done.add(node.parentElement);
          }
        });
        observer.observe(document.body, opts);
      })
      .catch(() => observer.observe(document.body, opts));
  }

  observer = new MutationObserver(muts => {
   
    for (const m of muts) {
   
      if (m.type === 'characterData') {
   
        if (m.target.parentElement && done.has(m.target.parentElement)) continue;
        if (queue.size < MAX_BATCH) queue.set(m.target, m.target.nodeValue);
      } else {
   
        m.addedNodes.forEach(n => {
    if (n.nodeType === 1) collect(n); });
      }
    }
    if (queue.size && !timer) timer = setTimeout(flush, 300);
  });

  collect(document.body);
  observer.observe(document.body, opts);
})();

批次上限和 300ms 合并的意义不只在于性能:不设上限的一次性补翻,会把整页几百个文本节点塞进一个请求,接口超时后你拿不到结果,节点也没打标记,下一轮又重来。

译文本身也要缓存:以 sha1(原文 . 目标语言 . 词典版本) 做键,命中就不再请求。

语言切换:URL、cookie、html lang、hreflang 要对得上

这几处只要有一个对不上,切换后就会出现「页面是中文、日期格式是英文」,或者「刷新又跳回英文」。

解析顺序建议固定成:URL 前缀 → cookie → Accept-Language → 站点默认。

function resolve_locale(): string {
   
    $supported = [ 'en', 'zh-CN', 'ja' ];
    $default   = 'en';

    if ( preg_match( '#^/([a-z]{2}(?:-[A-Z]{2})?)(/|$)#', $_SERVER['REQUEST_URI'] ?? '', $m )
         && in_array( $m[1], $supported, true ) ) {
   
        return $m[1];
    }

    if ( isset( $_COOKIE['site_locale'] ) && in_array( $_COOKIE['site_locale'], $supported, true ) ) {
   
        return $_COOKIE['site_locale'];
    }

    foreach ( explode( ',', $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '' ) as $part ) {
   
        $tag = trim( explode( ';', $part )[0] );
        if ( in_array( $tag, $supported, true ) ) return $tag;
    }

    return $default;
}

内容页用 URL 前缀,因为它要被搜索引擎收录;界面字符串、表单提示、按钮这些不需要被索引的,cookie 就够了,不必为此改 URL。写 cookie 的时候带上 Path=/; Max-Age=31536000; SameSite=Lax; Secure。

上线前跑一遍这几项

  • 短代码数量:译文里 [ 和 ] 的配对数量与原文一致
  • 占位符 token 序列完全一致,不一致回退原文
  • 用 DOMDocument 加载译文,libxml_get_errors() 不为空就说明标签被翻断了
  • 保护名单(短代码名、字段名、类名、型号、品牌名)在译文里原样存在
  • 抽 10 个页面,人工核对按钮、placeholder、下拉选项这三处

这套「挖走—翻译—填回—校验」的流程自己写一遍不算难,难的是把白名单维护住。AumLang 处理的是 AI 翻译和 hreflang 这一段,短代码保护和字段白名单仍然要按上面的方式落在你的主题里:aumcreate.com/plugins/aumlang。

來源:阿里雲開發者社區 AI · developer.aliyun.com