点一下就知道文案从哪来:国际化可视化实践
点一下就知道文案从哪来:国际化可视化实践
这篇文章整理自一次前端国际化可视化工具的工程实践。为了方便公开分享,文中对公司名称、平台名称、内部域名、资源路径、项目标识和真实业务数据都做了脱敏。下面主要聊问题抽象、技术方案和工程取舍,不涉及内部系统细节。
适合这些同学读:普通技术从业者、前端研发、工程效率方向同学,以及对国际化协作流程感兴趣的同行。阅读时不需要了解具体业务;如果了解
i18next、Webpack 插件、MutationObserver和 iframe 通信,会更容易理解实现细节。

先说清楚:它不是翻译系统
从产品角度看,这个工具解决的不是“怎么翻译”本身,而是“怎么把页面上的内容和翻译资源准确对应起来”。
在多语言协作里,研发同学通常熟悉的是代码里的 i18n key,产品、运营、QA、翻译同学更熟悉的是页面上真实看到的文案。两边关心的是同一个东西,但使用的是两套不同语言:一边是 key,一边是页面位置和上下文。
这个工具的定位,就是在业务页面和翻译平台之间补一层连接器:
- 对研发:更快定位页面文案对应的 key,减少在页面、代码和资源表之间来回查找。
- 对产品、运营、翻译:在真实页面语境下确认文案效果,而不是只面对一张资源表。
- 对平台维护者:把“截图描述、人工猜测、来回确认”变成“可以点击、可以预览、可以追踪”的流程。
所以它不是一个替代翻译平台的新系统,也不是一个页面内的万能编辑器。它更像是一副临时打开的“国际化透视眼镜”:打开后,页面上的文案可以被点到、被定位、被预览;关闭后,业务页面仍然按原来的方式运行。
问题其实很朴素:页面和资源对不上
做多语言系统的团队,大概率都遇到过类似场景:
- 产品同学发来一张页面截图,问“这句话对应哪个 key”,研发需要翻代码、查资源、交叉比对。
- 翻译同学改了某个 value,但不确定会影响页面中的哪几个位置。
- QA 在页面上发现一处译文不自然,只能描述“某个弹窗里第二个按钮下面的小字”。
- 研发自己排查问题时,也经常在页面、代码、资源文件和翻译平台之间来回跳转。
这些问题单看都不大,但累计起来会拖慢协作节奏。根源其实很朴素:页面上看到的文案和翻译资源之间,缺一条可见的链路。
我们希望解决的问题也很明确:
- 页面里点到一段文案,能看到它的 key、当前 value 和所属项目。
- 修改文案后,可以在页面上快速预览展示效果。
- 不要求业务组件改写法,不要求每个页面额外接入一套 API。
- 只在特定环境启用,不影响普通用户的线上体验。
把目标收敛以后,方案的复杂度就可以控制在一个相对可接受的范围内。
哪些做,哪些先不做
这个工具的目标:
- 在不侵入业务组件写法的前提下,为页面上的国际化内容建立可定位能力。
- 支持文本、图片地址、输入框和文本域 placeholder 等常见国际化场景。
- 在可视化编辑模式下,为可定位元素生成标记点,并支持点击选中。
- 支持工具容器和业务页面之间通信,用于切换模式、选中元素、修改预览和获取截图。
- 通过环境判断和按需加载控制影响面,避免普通访问场景加载完整运行时。
它刻意不承担这些职责:
- 不做翻译资源的最终保存、审核和发布。
- 不在业务页面常驻复杂编辑器。
- 不要求业务组件显式传入额外定位属性。
- 不把运行时逻辑绑定到某一个具体前端框架。
- 不在对外材料里暴露内部平台地址、资源域名、项目 ID 规则或真实翻译内容。
这类边界很重要。它让工具可以轻量落地,也避免把平台侧权限、审计、发布等复杂职责转移到页面运行时。
整体思路:把定位信息放进国际化链路
方案的核心思路可以概括为一句话:在国际化结果里放入一份可恢复的定位信息,再在运行时把它还原成页面上的可视化交互能力。

抽象后的链路如下:
业务代码调用 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 放进结果里
构建期主要有两个动作:
- 注册
i18nextpost processor,在翻译结果返回前处理 value。 - 通过 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. 为什么要压缩和编码
压缩和编码主要解决传输形态问题:
- key 可能很长。
- value 可能包含中文、空格、符号甚至较长句子。
- 页面里可能同时存在大量翻译内容。
- 标记需要尽量减少对页面文本结构的干扰。
压缩后再 Base64,可以让 payload 更稳定地放进标记中。
但需要说清楚:压缩不是安全手段。只要内容到了浏览器端,就不能把它当作真正不可见或不可还原的信息。权限、审计、发布和脱敏仍然应该由平台侧控制,前端标记只服务定位和预览。
4. 为什么用零宽字符作为边界
零宽字符的优势是肉眼不可见,同时便于运行时用正则识别:
const pattern = new RegExp(`${ZERO_WIDTH_MARKER}.*?${ZERO_WIDTH_MARKER}`, 'g');它的适用性比较好,不强依赖前端框架,也不需要业务组件支持额外属性。
不过它不是银弹。复制粘贴、复杂文本嵌套、特殊排版、服务端渲染和部分富文本场景,都可能带来额外问题。因此它更适合作为可视化模式下的技术载体,而不适合作为长期保留在页面内容里的业务数据。
按需加载:默认别打扰页面
Webpack 插件做的事情比较薄,但很关键:向 HTML 中注入一个环境检测脚本。
这个环境检测脚本只负责判断当前是否应该启用可视化模式。例如:
- 当前页面是否运行在工具容器 iframe 中。
- 当前 session 是否打开了开发调试开关。
如果不满足条件,就把可视化开关置为关闭,业务页面正常展示原翻译结果。
如果满足条件,再设置运行时开关,并按版本加载完整的初始化脚本。
抽象后的流程如下:
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));
}
}
}文本节点处理
如果叶子节点是文本节点,运行时会检查里面是否包含零宽字符包裹的压缩片段。
发现匹配片段后,会:
- 取出压缩内容。
- 解压得到
key、value、projectId。 - 创建一个新的
span。 - 写入
data-i18n-key、data-i18n-val、data-i18n-pid。 - 把
span.textContent设置为真实 value。 - 用新的 fragment 替换原文本节点。
还原后的结构大致如下:
<span
data-i18n-key="page.example.title"
data-i18n-val="Example title"
data-i18n-pid="project-placeholder"
>
Example title
</span>对用户来说,页面展示的是正常文案。对工具来说,这个元素已经具备了可定位能力。
属性节点处理
有些国际化内容不在文本节点里,而是在属性里。当前实现主要覆盖:
| 元素 | 处理属性 | 典型场景 |
|---|---|---|
img | src | 国际化图片地址 |
input | placeholder | 输入框占位提示 |
textarea | placeholder | 多行输入占位提示 |
对于这些元素,运行时会尝试从对应属性中识别标记,解压后把 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 的处理,尤其是 src、placeholder 这类字段被框架二次更新的场景。
编辑模式:让文案真的可以被点到
当外层工具切换到编辑模式时,运行时会扫描页面中所有带 data-i18n-key 的元素,为每个可见元素创建一个标记点。
标记点不是直接包裹原元素,而是追加到 body 上,通过 getBoundingClientRect() 计算位置,再用绝对定位放到对应元素附近。
这样做有几个好处:
- 不破坏业务 DOM 结构。
- 不要求业务组件配合。
- 标记点样式可以由工具统一控制。
- 可以单独拦截页面普通交互,减少误触按钮、链接、表单提交等行为。
点击标记点后的流程:
用户点击标记点
|
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、零宽字符都只是传输形态,不是安全边界。
因此更稳妥的原则是:
- 默认不启用完整运行时。
- 只在可视化容器或开发调试开关下启用。
- 保存、审核、发布、权限、审计继续由平台侧处理。
- 对外分享时不暴露内部域名、项目 ID 规则、接口地址和真实业务文案。
3. 实时编辑要克制
页面侧支持修改文本、placeholder、src,已经可以覆盖大多数“看一下改完效果”的场景。
如果继续扩展到富文本编辑、复杂组件状态、插值变量、复数规则、布局自适应校验,工具复杂度会快速上升。对这个产品来说,实时编辑的定位更接近“预览”,而不是“所见即所得发布器”。
4. MutationObserver 要控制边界
监听整个 body 很方便,但页面越复杂,性能压力越明显。
当前方案已经做了一些控制:队列遍历、跳过已带标记属性的节点、处理前暂停 observer、属性过滤等。
后续还可以继续优化:
- 限定扫描根节点。
- 合并批量 mutation。
- 对不可见区域延迟处理。
- 对属性变化补充更精确的处理。
- 编辑模式打开时再生成标记点,而不是长期维护覆盖层。
5. 标记点定位需要考虑布局变化
当前标记点的位置基于创建时的 getBoundingClientRect()。这足够支撑基本编辑流程,但在滚动、窗口缩放、异步布局变化、折叠展开等场景下,标记点可能漂移。
后续可以考虑:
- 监听 scroll 和 resize 后刷新标记点。
- 使用
ResizeObserver监听目标元素尺寸变化。 - 编辑模式下按需重新计算可见元素位置。
- 对不可见元素不创建标记点,减少干扰和性能开销。
还不完美的地方
诚实地说,这个方案离“通吃所有页面”还有距离。
- 嵌套翻译:一个翻译结果里再次包含被标记内容时,当前方案有一定处理,但复杂嵌套仍然需要更严谨的状态机。
- 富文本:如果一段翻译内部包含复杂 HTML 结构,简单文本节点替换不一定足够。
- 插值和复数:当前更偏整段定位,对变量级别的细粒度解释不够。
- 截图能力:DOM 截图会受到跨域图片、字体、Canvas、视频等因素影响,需要场景化兜底。
- 性能:大量动态节点频繁插入时,MutationObserver 的处理成本需要继续压测和优化。
- 可视化体验:标记点样式、遮挡、密集元素选择、滚动场景下的位置刷新,都还有打磨空间。
这些问题不影响方案的基本价值,但提醒我们:可视化工具越接近真实业务页面,就越要尊重页面复杂性,保持适度和克制。
我觉得可以带走的经验
第一,把能力放到正确的链路位置。
国际化可视化既不是单纯的 DOM 工具,也不是单纯的翻译平台功能。它需要同时知道 key、value 和页面位置。post processor 加运行时 DOM 解析,刚好把这几件事串起来。
第二,辅助能力要默认关闭。
环境判断、按需加载、运行时注入,既能减少对业务页面的侵入,也方便单独排查工具自身的问题。
第三,协作工具不一定要做重。
团队真正需要的往往不是一个完整大系统,而是把原本模糊的沟通对象变得可见:这句话在哪、对应哪个 key、改完大概长什么样。只要这几个问题能被快速回答,协作成本就会明显下降。
第四,对外分享要技术脱密,但不能把技术讲空。
内部域名、资源地址、项目 ID、真实业务数据应该去掉;但方案的关键链路、工程取舍、适用边界和踩坑经验应该保留。这样文章既安全,也对同行有参考价值。
最后回头看
这次实践的核心思路其实不复杂:在国际化结果里埋入可恢复的定位信息,再在运行时把它还原成页面上的可视化交互能力。
用到的技术也都比较常见:i18next postProcessor、压缩编码、零宽字符、Webpack 插件、按需脚本加载、MutationObserver、data-* 属性、iframe 通信、DOM 截图。每项单独看都不高深,但组合在一起,刚好解决了一个真实存在的协作问题。
对其他团队来说,这类工具最大的启发可能不是照搬实现,而是一个思路:当业务流程里有大量靠截图、口头描述、人工查找完成的重复动作时,可以试着把上下文信息往链路里前移一点,再在用户真正需要的时刻把它展示出来。
工具不一定要很重,但要让协作里“看不见的东西”变得看得见。
2026 © Lizhenyui.