点一下就知道文案从哪来:国际化可视化实践

You,30 min read

点一下就知道文案从哪来:国际化可视化实践

这篇文章整理自一次前端国际化可视化工具的工程实践。为了方便公开分享,文中对公司名称、平台名称、内部域名、资源路径、项目标识和真实业务数据都做了脱敏。下面主要聊问题抽象、技术方案和工程取舍,不涉及内部系统细节。

适合这些同学读:普通技术从业者、前端研发、工程效率方向同学,以及对国际化协作流程感兴趣的同行。阅读时不需要了解具体业务;如果了解 i18next、Webpack 插件、MutationObserver 和 iframe 通信,会更容易理解实现细节。

RQTQci

先说清楚:它不是翻译系统

从产品角度看,这个工具解决的不是“怎么翻译”本身,而是“怎么把页面上的内容和翻译资源准确对应起来”。

在多语言协作里,研发同学通常熟悉的是代码里的 i18n key,产品、运营、QA、翻译同学更熟悉的是页面上真实看到的文案。两边关心的是同一个东西,但使用的是两套不同语言:一边是 key,一边是页面位置和上下文。

这个工具的定位,就是在业务页面和翻译平台之间补一层连接器:

所以它不是一个替代翻译平台的新系统,也不是一个页面内的万能编辑器。它更像是一副临时打开的“国际化透视眼镜”:打开后,页面上的文案可以被点到、被定位、被预览;关闭后,业务页面仍然按原来的方式运行。

问题其实很朴素:页面和资源对不上

做多语言系统的团队,大概率都遇到过类似场景:

这些问题单看都不大,但累计起来会拖慢协作节奏。根源其实很朴素:页面上看到的文案和翻译资源之间,缺一条可见的链路。

我们希望解决的问题也很明确:

把目标收敛以后,方案的复杂度就可以控制在一个相对可接受的范围内。

哪些做,哪些先不做

这个工具的目标:

它刻意不承担这些职责:

这类边界很重要。它让工具可以轻量落地,也避免把平台侧权限、审计、发布等复杂职责转移到页面运行时。

整体思路:把定位信息放进国际化链路

方案的核心思路可以概括为一句话:在国际化结果里放入一份可恢复的定位信息,再在运行时把它还原成页面上的可视化交互能力。

mGLPh3

抽象后的链路如下:

业务代码调用 t(key)
        |
        v
i18next postProcessor 拿到 key 和翻译后的 value
        |
        v
把 key / value / projectId 压缩编码后包进特殊标记
        |
        v
页面在可视化模式下渲染出可被识别的标记载体
        |
        v
运行时脚本扫描 DOM,解出元信息并还原真实展示
        |
        v
为元素写入 data-i18n-key / data-i18n-val / data-i18n-pid
        |
        v
编辑模式下创建可点击标记点
        |
        v
外层工具接收选中、修改预览、截图等事件

这里最关键的选择是:把元信息放进国际化链路,而不是放进业务组件。

如果从组件层做,理论上可以更干净,比如要求每个文案组件都显式传入 key、value 和可编辑能力。但现实里,业务页面可能有组件库、Hook、工具函数、历史封装等多种国际化入口,逐个改造成本会很高。

i18next postProcessor 的位置刚好合适:它能拿到 key 和最终 value,又处于页面渲染之前。只要业务代码继续正常调用 t(key),工具就有机会为结果附加定位信息。

构建期:先把 key 放进结果里

构建期主要有两个动作:

  1. 注册 i18next post processor,在翻译结果返回前处理 value。
  2. 通过 Webpack 插件向 HTML 注入一个轻量环境检测脚本,用于判断是否需要加载完整运行时。

1. 翻译结果如何携带元信息

post processor 中能拿到翻译 key、翻译后的 value,以及国际化配置里携带的项目标识。工具会把这些信息组合成一个 payload:

key|value|projectId

然后做压缩和编码:

key/value/projectId
        |
        v
字符串拼接
        |
        v
deflate 压缩
        |
        v
Base64 编码
        |
        v
使用零宽字符作为边界包裹

脱敏后的伪代码类似这样:

const visualizationPostProcessor = {
  type: 'postProcessor',
  name: 'visualization',
  process(value: string, key: string, _pluginOptions: unknown, translator: any) {
    if (!window.__IS_I18N_VISUALIZATION_ENABLED__) {
      return value;
    }
 
    const projectId = translator.options?.backend?.projectId || '';
    const payload = `${key}|${value}|${projectId}`;
    const compressed = compressToBase64(payload);
 
    return `${ZERO_WIDTH_MARKER}${compressed}${ZERO_WIDTH_MARKER}`;
  },
};

这里有一个容易误解的点:当前实现不是把 marker 追加到原 value 后面,而是在可视化模式下返回一个特殊标记载体。随后运行时脚本会扫描 DOM,把这个标记解开并还原成真实 value。

这样做的好处是标记结构更纯粹,运行时识别更直接;代价是运行时脚本必须足够早、足够稳定地完成还原,否则在可视化模式下可能出现短暂空白或未还原状态。这也是后续可以继续优化体验的地方。

2. 为什么选 post processor

选择 post processor,而不是包一层新的 t 函数,主要是为了降低业务接入成本。

如果包一层 t 函数,需要保证所有业务调用都切换到新的入口。但在真实项目里,国际化调用可能散落在组件、工具函数、表单配置、组件库封装里,替换成本和遗漏风险都不低。

post processor 是国际化框架本身提供的扩展点。它把能力放在统一链路上,业务侧继续按原来的方式写代码,工具侧集中承担复杂度。

3. 为什么要压缩和编码

压缩和编码主要解决传输形态问题:

压缩后再 Base64,可以让 payload 更稳定地放进标记中。

但需要说清楚:压缩不是安全手段。只要内容到了浏览器端,就不能把它当作真正不可见或不可还原的信息。权限、审计、发布和脱敏仍然应该由平台侧控制,前端标记只服务定位和预览。

4. 为什么用零宽字符作为边界

零宽字符的优势是肉眼不可见,同时便于运行时用正则识别:

const pattern = new RegExp(`${ZERO_WIDTH_MARKER}.*?${ZERO_WIDTH_MARKER}`, 'g');

它的适用性比较好,不强依赖前端框架,也不需要业务组件支持额外属性。

不过它不是银弹。复制粘贴、复杂文本嵌套、特殊排版、服务端渲染和部分富文本场景,都可能带来额外问题。因此它更适合作为可视化模式下的技术载体,而不适合作为长期保留在页面内容里的业务数据。

按需加载:默认别打扰页面

Webpack 插件做的事情比较薄,但很关键:向 HTML 中注入一个环境检测脚本。

这个环境检测脚本只负责判断当前是否应该启用可视化模式。例如:

如果不满足条件,就把可视化开关置为关闭,业务页面正常展示原翻译结果。

如果满足条件,再设置运行时开关,并按版本加载完整的初始化脚本。

抽象后的流程如下:

HTML 加载
  |
  v
执行轻量环境检测脚本
  |
  +-- 非可视化环境:不开启,不加载完整运行时
  |
  +-- 可视化环境:设置运行时开关,加载初始化脚本

这层设计体现的是“辅助能力默认关闭”的原则。可视化工具是给特定协作场景用的,不应该让普通用户长期承担它的运行成本。

运行期:再从 DOM 里把信息找回来

运行时初始化后,会先注入标记点样式,再从 document.body 开始扫描 DOM。

扫描使用的是队列式遍历,而不是递归。原因很简单:真实业务页面的 DOM 层级不可控,递归写法在极端深层结构下有栈溢出风险,队列遍历更稳。

伪代码可以简化成这样:

function processNodeUsingQueue(root: Node) {
  const queue = [root];
 
  while (queue.length > 0) {
    const current = queue.shift();
    if (!current) continue;
 
    if (shouldSkip(current)) {
      continue;
    }
 
    if (isLeafNode(current)) {
      handleLeafNode(current);
    } else {
      queue.push(...Array.from(current.childNodes));
    }
  }
}

文本节点处理

如果叶子节点是文本节点,运行时会检查里面是否包含零宽字符包裹的压缩片段。

发现匹配片段后,会:

  1. 取出压缩内容。
  2. 解压得到 keyvalueprojectId
  3. 创建一个新的 span
  4. 写入 data-i18n-keydata-i18n-valdata-i18n-pid
  5. span.textContent 设置为真实 value。
  6. 用新的 fragment 替换原文本节点。

还原后的结构大致如下:

<span
  data-i18n-key="page.example.title"
  data-i18n-val="Example title"
  data-i18n-pid="project-placeholder"
>
  Example title
</span>

对用户来说,页面展示的是正常文案。对工具来说,这个元素已经具备了可定位能力。

属性节点处理

有些国际化内容不在文本节点里,而是在属性里。当前实现主要覆盖:

元素处理属性典型场景
imgsrc国际化图片地址
inputplaceholder输入框占位提示
textareaplaceholder多行输入占位提示

对于这些元素,运行时会尝试从对应属性中识别标记,解压后把 data-i18n-* 写到元素上,并把属性恢复成真实 value。

这个覆盖范围不算大,但比较务实。文本、图片、placeholder 已经能覆盖很多页面上的国际化定位需求。更复杂的属性、富文本和组件内部状态,可以留给后续按场景扩展。

动态页面:后渲染的内容也要照顾到

现代前端页面并不是一次性渲染完成的。弹窗、异步列表、分页表格、条件渲染、懒加载区域,都会在首屏之后继续插入 DOM。

因此,运行时会挂一个 MutationObserver。当有新节点加入页面时,继续对新增节点执行同样的解析流程。

这里有一个实现细节很重要:处理 mutation 前先暂停 observer,处理完成后再恢复。

原因是工具自己在处理节点时,也会替换 DOM。如果不暂停监听,工具自身造成的 DOM 变化可能再次触发 observer,轻则重复处理,重则形成递归触发。

脱敏后的伪代码如下:

function handleMutations(mutations: MutationRecord[]) {
  pauseObserver();
 
  for (const mutation of mutations) {
    if (mutation.type === 'childList') {
      processAddedNodes(mutation.addedNodes);
    }
  }
 
  resumeObserver();
}

当前实现虽然配置了属性监听和属性过滤,但主要处理逻辑集中在新增节点上。也就是说,它更适合解决“后续插入的新 DOM 如何识别”,而不是完整覆盖“已有节点属性被频繁更新”的所有情况。

如果后续要强化这块,可以继续补充对 attributes mutation 的处理,尤其是 srcplaceholder 这类字段被框架二次更新的场景。

编辑模式:让文案真的可以被点到

当外层工具切换到编辑模式时,运行时会扫描页面中所有带 data-i18n-key 的元素,为每个可见元素创建一个标记点。

标记点不是直接包裹原元素,而是追加到 body 上,通过 getBoundingClientRect() 计算位置,再用绝对定位放到对应元素附近。

这样做有几个好处:

点击标记点后的流程:

用户点击标记点
        |
        v
根据 marker 上的 key / pid 找到原始元素
        |
        v
读取原始元素上的 key / value / pid
        |
        v
清理上一个选中态
        |
        v
给当前 marker 和原始元素添加选中态
        |
        v
通知外层工具当前选中的国际化项

编辑模式下,运行时会在捕获阶段监听页面点击事件。只有标记点和带控制属性的工具元素允许继续交互,其他页面点击会被阻止。

这是为了避免用户在可视化编辑时误触业务操作。比如点击一个按钮本来只是想选中文案,却触发了提交、跳转或弹窗,这种体验会很混乱。

工具通信:iframe 里也要能协作

可视化工具通常会把业务页面嵌入到一个容器中。容器负责翻译项列表、编辑面板、保存入口等;业务页面负责展示真实 UI 和执行预览。

两边需要互相通信:

方向动作说明
父容器到子页面切换模式在预览模式和编辑模式之间切换
父容器到子页面修改 value把面板中的临时文案同步到页面预览
父容器到子页面获取截图获取当前页面展示上下文
子页面到父容器选中元素告诉工具当前点中了哪个 key

底层可以基于 postMessage,上层再封装成更好用的 RPC 风格接口。当前实现里,页面侧暴露了类似这些能力:

const methods = {
  switchMode(mode) {
    setMode(mode);
  },
 
  modifyElementValue(payload) {
    updateActiveElementContent(payload.val);
    return createEventPayload('MODIFY_I18N_ELEMENT_VALUE', payload);
  },
 
  async getScreenshot() {
    return createEventPayload('GET_SCREENSHOT', {
      blob: await getScreenshot(),
    });
  },
};

对使用者来说,体验接近这样:

点击页面标记点
        |
右侧面板定位到对应翻译项
        |
修改文案
        |
页面实时更新预览
        |
确认后再进入平台保存流程

这里要强调边界:页面内修改只是预览,不等于翻译资源已经持久化。真正的保存、校验、审批、发布、回滚,仍然应该由平台侧承担。

支持到哪里,边界在哪里

为了避免读者误解,可以把当前方案的支持范围明确列出来。

能力当前状态说明
文这篇文章案定位支持通过文本节点标记解析为 span[data-i18n-key]
图片地址定位支持当前处理 img[src]
输入占位文案支持当前处理 input[placeholder]textarea[placeholder]
动态插入 DOM支持通过 MutationObserver 处理新增节点
页面内实时预览支持修改选中元素的 textContent、placeholder 或 src
iframe 双向通信支持用于模式切换、选中通知、预览修改、截图
翻译资源保存不负责应由平台侧处理
权限和审计不负责应由平台侧处理
富文本精细编辑暂不完整可作为后续专项能力
插值、复数、组合文案暂不完整当前更偏整段定位和预览
标记点随布局实时刷新暂不完整滚动、缩放、布局变化后需要优化刷新策略

这个表也体现了产品思路:先把“定位”和“预览”做好,而不是一开始就追求覆盖所有国际化复杂形态。

几个当时的取舍

1. 选 DOM 层增强,而不是组件层改造

组件层改造看起来更规范,每个文案组件都显式带上 key、value 和编辑能力。但它对业务页面侵入更强,也容易和具体组件库绑定。

DOM 层增强的思路,是把复杂度收敛在工具内部。只要页面使用统一国际化链路,运行时就能从最终 DOM 中恢复定位信息。

这不是完美方案,但它很适合工程效率工具:少打扰业务,多在工具侧兜住复杂性。

2. 标记信息只服务定位,不承担安全职责

key、value、projectId 进入浏览器后,就不能当成真正保密的信息。压缩、Base64、零宽字符都只是传输形态,不是安全边界。

因此更稳妥的原则是:

3. 实时编辑要克制

页面侧支持修改文本、placeholder、src,已经可以覆盖大多数“看一下改完效果”的场景。

如果继续扩展到富文本编辑、复杂组件状态、插值变量、复数规则、布局自适应校验,工具复杂度会快速上升。对这个产品来说,实时编辑的定位更接近“预览”,而不是“所见即所得发布器”。

4. MutationObserver 要控制边界

监听整个 body 很方便,但页面越复杂,性能压力越明显。

当前方案已经做了一些控制:队列遍历、跳过已带标记属性的节点、处理前暂停 observer、属性过滤等。

后续还可以继续优化:

5. 标记点定位需要考虑布局变化

当前标记点的位置基于创建时的 getBoundingClientRect()。这足够支撑基本编辑流程,但在滚动、窗口缩放、异步布局变化、折叠展开等场景下,标记点可能漂移。

后续可以考虑:

还不完美的地方

诚实地说,这个方案离“通吃所有页面”还有距离。

这些问题不影响方案的基本价值,但提醒我们:可视化工具越接近真实业务页面,就越要尊重页面复杂性,保持适度和克制。

我觉得可以带走的经验

第一,把能力放到正确的链路位置。

国际化可视化既不是单纯的 DOM 工具,也不是单纯的翻译平台功能。它需要同时知道 key、value 和页面位置。post processor 加运行时 DOM 解析,刚好把这几件事串起来。

第二,辅助能力要默认关闭。

环境判断、按需加载、运行时注入,既能减少对业务页面的侵入,也方便单独排查工具自身的问题。

第三,协作工具不一定要做重。

团队真正需要的往往不是一个完整大系统,而是把原本模糊的沟通对象变得可见:这句话在哪、对应哪个 key、改完大概长什么样。只要这几个问题能被快速回答,协作成本就会明显下降。

第四,对外分享要技术脱密,但不能把技术讲空。

内部域名、资源地址、项目 ID、真实业务数据应该去掉;但方案的关键链路、工程取舍、适用边界和踩坑经验应该保留。这样文章既安全,也对同行有参考价值。

最后回头看

这次实践的核心思路其实不复杂:在国际化结果里埋入可恢复的定位信息,再在运行时把它还原成页面上的可视化交互能力。

用到的技术也都比较常见:i18next postProcessor、压缩编码、零宽字符、Webpack 插件、按需脚本加载、MutationObserverdata-* 属性、iframe 通信、DOM 截图。每项单独看都不高深,但组合在一起,刚好解决了一个真实存在的协作问题。

对其他团队来说,这类工具最大的启发可能不是照搬实现,而是一个思路:当业务流程里有大量靠截图、口头描述、人工查找完成的重复动作时,可以试着把上下文信息往链路里前移一点,再在用户真正需要的时刻把它展示出来。

工具不一定要很重,但要让协作里“看不见的东西”变得看得见。

2026 © Lizhenyui.