1. 先搞清楚 SkyWalking UI 的语言包到底管哪些文案
SkyWalking UI 的国际化配置,说白了就是把前端里所有硬编码的英文文案抽成 key,再按语言文件喂回去。但真正动手之前得先明白一件事:$t能解决的只是四类文案里的一类,剩下三类漏掉任何一个,切换语言后界面就会出现“半中文半英文”的尴尬。这套分层认知决定了后面所有配置和排错思路。
1.1 界面文案的四种来源,只有一类走语言包
我在本地把整个 UI 的文案来源捋过一遍,按可控程度排下来大概是这样:
- 静态文案:菜单名、表头、按钮、表单 label、空状态提示。这类全在
src/assets/lang/*.ts里,是$t的主战场,占比最大,也是最容易做干净的部分。 - 组件库内置文案:分页器的“条/页”、日期选择器的月份名、表格无数据时的“暂无数据”、上传组件的提示。这些藏在组件库自己的 locale 包里,跟你写的语言文件毫无关系,必须单独设置,否则中文界面里永远夹着几处英文。
- 后端返回的枚举和状态:告警规则的触发类型、服务状态、Span 类型这类的值,接口直接返回英文枚举串。前端要做的是“值 → i18n key”的映射,而不是翻译返回值本身。
- 用户自定义数据:服务名、端点名、标签键值、实例名。这类内容永远不翻译,翻译了反而是事故。
分层清楚之后再看语言文件,你就知道哪些 key 该写、哪些根本不该出现在语言包里。很多新手把后端返回的枚举也硬塞进语言文件里做全量映射,结果接口一加新枚举,界面就直接显示空字符串,这是典型的边界没划清。
1.2 为什么选 vue-i18n 而不是自己撸一个字典
SkyWalking UI 用的是 Vue 技术栈,国际化沿用社区主流的 vue-i18n。有人可能觉得,不就是个字典查表,自己写个getText(key)不香吗?实际操作下来,自研方案会在四个地方翻车:
- 占位符插值:
{name}这类动态参数,自研方案要自己写正则替换,还得处理参数缺失的空值。vue-i18n 原生支持,一行$t('key', { name })搞定。 - 响应式:语言切换后视图要自动刷新。vue-i18n 挂在 Vue 实例上,
locale一改,所有$t自动重算。自研方案要么强制刷新页面,要么手写全局事件,成本反而更高。 - 回退机制:某个 key 在新语种里没翻译,
fallbackLocale会兜底到英文,界面不至于出现service.list.title这种裸 key。这个能力在小语种渐进翻译的场景里非常关键。 - 复数与格式化:虽然 SkyWalking 界面里复数场景不多,但像“共 N 个实例”这类句子,用
$tc配合管道符|写起来更省事。
需要说明的是,SkyWalking UI 某些版本分支上用的是 Vue 2 + Element UI,另一些新版本走的是 Vue 3 + Element Plus 的路线。两者的 i18n 库和组件库 locale 引入路径都不一样,动手前先看本地
package.json确认版本,别照着旧文章抄代码,这是我踩过的第一个坑。
2. 语言包的结构设计与注册链路
语言文件长什么样,决定了后面好不好维护。我见过不少项目的语言文件是几百行扁平 key 堆在一起,a1、b2这种命名都有,改一处文案要在几千行里翻半天。SkyWalking UI 的键结构相对规整,理解它的划分逻辑,新增语种时才能保持结构一致。
2.1 从 en.ts 出发看键的三层划分
以本地这份代码为例,语言文件默认导出一个大对象,顶层大致分成三块:
export default { // 第一层:通用词汇,跨页面复用 common: { auto: 'Auto', operation: 'Operation', search: 'Search', cancel: 'Cancel', confirm: 'Confirm', noData: 'No Data', }, // 第二层:菜单与导航 menu: { dashboard: 'Dashboard', topology: 'Topology', trace: 'Trace', alarm: 'Alarm', settings: 'Settings', }, // 第三层:页面级命名空间 service: { title: 'Services', instance: 'Instance', endpoint: 'Endpoint', cpm: 'CPM', }, };这个三层划分的意义在于:common是复用池,任何页面都能取;menu跟路由配置强绑定,改 key 要同步改路由;页面级命名空间则是隔离区,页面下线时整块删掉,不会误伤别处。
命名上有几个约定值得坚持:一律小驼峰,不用下划线和短横线;key 要表达语义,title、placeholder、emptyTip这种后缀比text1、text2强一百倍;避免用缩写,svc、ep这种过半年自己都想不起来。
我在维护语言包时还有一个习惯:key 的顺序保持和英文文件完全一致。翻译时对着英文文件一行行往下推,新增 key 也插在对应位置,这样 diff 出来的改动是干净的,review 时一眼就能看出谁多加了谁漏了。
2.2 从文件到$t的注册链路
理解注册链路,是为了出问题时知道该在哪一层排查。整个链路大概是:
语言文件 (assets/lang/en.ts) ↓ import i18n 实例 (i18n/index.ts) ↓ Vue.use(VueI18n) + new VueI18n({ messages }) 根实例注入 (new Vue({ i18n })) ↓ 组件内 this.$t('menu.dashboard')对应的核心代码:
import Vue from 'vue'; import VueI18n from 'vue-i18n'; import en from '@/assets/lang/en'; import zhCN from '@/assets/lang/zh-CN'; Vue.use(VueI18n); const i18n = new VueI18n({ locale: 'en', fallbackLocale: 'en', silentTranslationWarn: true, // 生产环境关掉缺 key 的噪音告警 messages: { en, 'zh-CN': zhCN, }, }); export default i18n;fallbackLocale这一行千万别省。我试过把某个语种的 key 漏了一大片,因为没设回退,界面上直接显示service.labels.cpm这种裸字符串,看着比不翻译还难受。设了回退之后,漏翻的地方退化成英文,用户至少能看懂,也不会显得界面崩了。
silentTranslationWarn则是一个取舍:开发阶段建议关掉,让缺 key 的告警刷屏,逼着自己补齐;提测前再打开,避免控制台噪音干扰排查其它问题。
3. 新增一门语言的完整实操
理论讲完,下面是我实际加一门语言时走的完整流程。整个动作拆成六步,每步都有容易翻车的地方。
3.1 第一步到第三步:复制、翻译、注册
第一步,复制模板。直接从en.ts复制成目标语言文件,比如ja.ts。注意文件名要和语言代码一致,后缀是.ts不是.js,因为项目里语言文件是 TS 模块导出的。
cp src/assets/lang/en.ts src/assets/lang/ja.ts第二步,逐行翻译 value,key 一个字都不能动。这一步是最耗时的,也是最容易出错的。翻译时的优先级是:菜单和导航 > 按钮和表单 > 表格表头 > 提示和错误信息 > 帮助说明文字。前三类能覆盖 80% 的视觉面积,先把它们做完,界面看起来就“像个日语界面”了。
第三步,注册到 messages。
import ja from '@/assets/lang/ja'; const i18n = new VueI18n({ locale: detectLocale(), fallbackLocale: 'en', messages: { en, 'zh-CN': zhCN, ja }, });语言代码必须遵循 BCP 47 规范,中文是zh-CN不是zh_CN,带地区的语种写成pt-BR、es-419这种形式。locale匹配是精确字符串比对,后缀写成下划线会导致匹配失败、静默回退到英文,而且不报错,非常隐蔽。
3.2 第四步:语言切换器加选项并持久化
SkyWalking UI 的右上角通常有个语言下拉,选项列表往往也是硬编码的数组。新增语种时必须同步加进去:
export const LANGUAGES = [ { label: 'English', value: 'en' }, { label: '简体中文', value: 'zh-CN' }, { label: '日本語', value: 'ja' }, ];注意 label 用的是各语种的自称,不是当前界面语言的翻译。日语选项在任何界面语言下都写「日本語」,中文选项写「简体中文」,这样用户永远能找到自己的语言。
切换动作本身很简单,但有两件事必须一起做:
changeLang(lang: string) { this.$i18n.locale = lang; localStorage.setItem('skywalking-locale', lang); document.documentElement.setAttribute('lang', lang); ElementLocale.use(elementLocaleMap[lang]); }localStorage是持久化偏好,否则用户刷新一次就退回默认语言,体验很差。写入html的lang属性看起来可有可无,实际上影响屏幕阅读器的发音、浏览器自动翻译的触发,以及 CSS 里:lang()选择器的匹配,是个小成本高收益的动作。
初次进入时的语言探测建议按“本地存储 > 浏览器语言 > 默认英文”的顺序:
function detectLocale(): string { const saved = localStorage.getItem('skywalking-locale'); if (saved && SUPPORTED_LOCALES.includes(saved)) return saved; const nav = (navigator.language || 'en').toLowerCase(); if (nav.startsWith('zh')) return 'zh-CN'; if (nav.startsWith('ja')) return 'ja'; return 'en'; }这里有个细节:navigator.language可能是zh-TW、zh-HK,直接拿它当 locale 用会匹配不上zh-CN,所以要用前缀判断做一次归并。我一开始偷懒直接赋值,结果繁体环境的用户看到的还是英文,查了半天才反应过来。
3.3 第五步:组件库和日期库的联动切换
这是最容易漏的一步,也是我在实际项目里被反馈最多的问题。语言切换之后,分页器还是“10/page”,日期选择器还是英文月份,表格空数据还是“No Data”。原因是 Element UI 有自己独立的 locale 体系,和你自己的 i18n 完全不联动。
Element UI 的官方做法是:
import ElementLocale from 'element-ui/lib/locale'; import elementEn from 'element-ui/lib/locale/lang/en'; import elementZhCN from 'element-ui/lib/locale/lang/zh-CN'; import elementJa from 'element-ui/lib/locale/lang/ja'; export const elementLocaleMap = { en: elementEn, 'zh-CN': elementZhCN, ja: elementJa, }; // 初始化 ElementLocale.use(elementLocaleMap[currentLocale]);注意ElementLocale.use()可以在运行时调用,不需要重启应用,切换语言时调一次就能让分页器、日期面板、上传组件的文案立刻跟上。
如果项目用的是 Element Plus,路径变成element-plus/es/locale/lang/zh-cn,而且导出形式是import zhCn from 'element-plus/es/locale/lang/zh-cn',use()的调用方式也改成了app.use(ElementPlus, { locale })或配合 ConfigProvider 动态切换。版本不同写法差异很大,抄代码之前先确认自己用的是哪一套。
日期库同理。项目里如果用 dayjs 做时间轴格式化,要同步dayjs.locale('zh-cn');用 moment 就是moment.locale('zh-cn')。这一块不处理,时间轴上的“Jan”“Feb”会一直杵在中文界面里。
提醒一句:Element UI 的 locale 包只覆盖组件内置文案,你在
<el-button>搜索</el-button>里硬编码的中文它是不会管的。这类硬编码必须逐个换成$t,否则就会出现“组件库切了、自己的文案没切”的错位。我一般用正则扫一遍模板,把>纯中文<的写法全部打出来人工过一遍。
3.4 第六步:本地跑通再提 PR
做完上面五步,跑一遍npm run serve,重点验证这几个场景:
| 验证场景 | 期望结果 | 常见异常 |
|---|---|---|
| 首次访问、无本地存储 | 按浏览器语言选中 | 始终英文 |
| 切换后刷新页面 | 保持上次选择 | 退回英文 |
| 切换后打开分页器 | 组件库文案跟随 | 仍是英文 |
| 切换后打开日期选择器 | 月份名跟随 | 仍是英文 |
| 打开任意弹窗、抽屉 | 内部文案全部翻译 | 局部残留英文 |
| 打开浏览器控制台 | 无缺 key 告警 | 出现Cannot translate |
这个表我每次新增语种都会过一遍,能挡掉八成低级的遗漏。
4. 键一致性和翻译质量的自动化校验
人工对几百个 key 是低效且不可靠的。语言文件本质是结构化数据,完全可以用脚本把一致性检查自动化,这一步做完能省掉大量 review 时的来回。
4.1 写一个 key diff 脚本
核心思路:把语言对象递归拍平成a.b.c形式的路径集合,然后和基准语言(英文)做差集。
// scripts/check-i18n.js require('esbuild-register/dist/node').register(); const fs = require('fs'); const path = require('path'); const LANG_DIR = path.resolve(__dirname, '../src/assets/lang'); const BASE_LOCALE = 'en'; function flatten(obj, prefix = '') { const result = {}; for (const key of Object.keys(obj)) { const value = obj[key]; const fullKey = prefix ? `${prefix}.${key}` : key; if (value && typeof value === 'object' && !Array.isArray(value)) { Object.assign(result, flatten(value, fullKey)); } else { result[fullKey] = value; } } return result; } const base = flatten(require(path.join(LANG_DIR, `${BASE_LOCALE}.ts`)).default); const files = fs.readdirSync(LANG_DIR).filter( (f) => f.endsWith('.ts') && f !== `${BASE_LOCALE}.ts` ); let failed = false; for (const file of files) { const locale = path.basename(file, '.ts'); const target = flatten(require(path.join(LANG_DIR, file)).default); const missing = Object.keys(base).filter((k) => !(k in target)); const extra = Object.keys(target).filter((k) => !(k in base)); const empty = Object.keys(target).filter( (k) => typeof target[k] === 'string' && target[k].trim() === '' ); if (missing.length || extra.length || empty.length) { failed = true; console.error(`\n[${locale}] missing=${missing.length} extra=${extra.length} empty=${empty.length}`); missing.slice(0, 10).forEach((k) => console.error(` - missing: ${k}`)); extra.slice(0, 10).forEach((k) => console.error(` + extra: ${k}`)); } else { console.log(`[${locale}] OK, ${Object.keys(target).length} keys`); } } process.exit(failed ? 1 : 0);esbuild-register的作用是让 Node 能直接requireTS 文件。如果你不想引入额外依赖,也可以用ts-node注册,或者干脆在构建前先tsc编译语言目录再做比对。三种方式我都试过,esbuild-register启动最快,适合挂在pre-commit上。
配套的package.json脚本:
{ "scripts": { "check:i18n": "node scripts/check-i18n.js" } }在 CI 里跑一次,缺 key 直接让流水线红掉,比人工肉眼扫靠谱得多。extra那一项同样重要,英文文件里已经删掉的 key 如果残留在其它语种里,会让后面维护的人以为它还有用。
4.2 占位符一致性必须单独校验
key 对齐了不代表内容对。这是另一个隐蔽的坑:英文写的是Showing {count} results,翻译时把{count}弄丢了,运行时这句就变成“共条结果”,而且不会报错。
const placeholderOf = (str) => (String(str).match(/\{[a-zA-Z0-9_]+\}/g) || []).sort().join(','); for (const key of Object.keys(base)) { const expect = placeholderOf(base[key]); const actual = placeholderOf(target[key]); if (expect !== actual) { console.error(`[placeholder mismatch] ${key}: "${expect}" -> "${actual}"`); failed = true; } }注意排序一下再比,{a}{b}和{b}{a}顺序不同但含义一致,不该报错。另外 vue-i18n 里还有@:key这种引用语法和|复数分隔符,如果文案里用到了,同时也要做占位符检查,别让译者把管道符当成普通竖线删掉。
4.3 术语表比逐句翻译更重要
界面国际化最难的不是“翻得准”,而是“翻得一致”。同一个Instance,一会儿译成“实例”,一会儿译成“节点”,用户会以为是两个不同的东西。所以开工前先定一张术语表,所有语种都按这张表走:
| 英文原文 | 建议译法 | 备注 |
|---|---|---|
| Service | 服务 | 不要译成“服务端” |
| Service Instance | 服务实例 | 与“服务”区分 |
| Endpoint | 端点 | 也可用“接口”,全库必须统一 |
| Trace | 追踪 | 链路追踪语境 |
| Span | 跨度 | 首次出现可加注原文 |
| Trace Segment | 追踪段 | |
| Topology | 拓扑 | 顶部菜单用“拓扑图”更直观 |
| Metrics | 指标 | 不要与“度量”混用 |
| Dashboard | 仪表盘 | 不用“看板” |
| Alarm | 告警 | 不用“警报”“报警” |
| Heatmap | 热力图 | |
| Profiling | 性能剖析 | |
| OAP / CPM / SLA / P99 | 保持原文 | 专有名词不译 |
这张表也是我 review 时的依据。有人觉得术语表是形式主义,实际上它把“翻译”从主观判断变成了机械比对,审校效率至少翻倍。特别是多人协作时,没有这张表,同一个文件里出现三种译法几乎是必然的。
另外还有几个细节约定值得写进规范里:中文文案里不要中英文混排时加空格(除非是专有名词与中文之间);标点一律用目标语言的全角符号;句子结尾不加句号,短语保持简洁;按钮文案控制在 6 个字以内,超过会挤爆布局。
5. 常见问题与排查实录
做了几轮之后,我把遇到过的坑整理成了一份速查表,新同事上手时基本能靠它自查。
5.1 切换语言后部分文案不生效
这是出现频率最高的反馈。排查顺序建议固定下来:
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 整块区域没变化 | 该区域文案是硬编码,没接$t | 全局搜索中英文关键词定位 |
| 只有组件库部分没变 | Element 的 locale 没切换 | 检查ElementLocale.use()是否被调用 |
| 单独几个 key 显示裸字符串 | 该 key 在目标语种缺失 | 跑check:i18n脚本 |
| 切换后刷新又变回去 | 偏好没写入 localStorage | 检查切换函数的写入逻辑 |
| 首屏是目标语言,路由跳转后变回英文 | 路由懒加载的组件提前缓存了$t结果 | 不要在 data 里存this.$t(...)的返回值 |
最后一条特别值得展开。有些老代码习惯在data()里初始化表头数组:
data() { return { columns: [ { label: this.$t('service.name'), prop: 'name' }, ], }; }data()只执行一次,$t的结果被固化在内存里,i18n.locale改了也不会重算。正确做法是把 label 存成 key,在模板里实时$t:
data() { return { columns: [{ labelKey: 'service.name', prop: 'name' }], }; }<el-table-column v-for="col in columns" :key="col.prop" :label="$t(col.labelKey)" :prop="col.prop" />这类问题靠肉眼很难发现,因为它在切换时“大部分是对的”,只有个别表头不对劲。我一般的排查手段是:切到目标语言后,在控制台执行document.body.innerText抓取整页文本,再用正则匹配纯 ASCII 的连续单词,把疑似残留英文的位置打印出来,逐一核对。
5.2 长文案引发的布局问题
不同语种的长度差异远超想象。同一句话,中文字符数通常是最短的,德语、俄语往往要长出 30% 到 50%。按钮、标签页、表格列头这些固定宽度的地方,翻译完直接溢出或者被省略号截断。
几个实用的处理方式:
- 按钮容器不要写死
width,改用min-width+padding,让内容自己撑开。 - 表格列头开启
show-overflow-tooltip,让长文案可以被悬停查看。 - 顶栏菜单项加
white-space: nowrap,避免换行导致高度跳动。 - 关键操作按钮在目标语种下单独截图确认,别只看默认语言。
我吃过一次亏:某个语言下“确认删除”这一串文字把对话框底部的按钮挤出容器,用户点不到取消按钮。这种问题在英文界面下完全无法复现,只能靠在每个语种下走一遍关键路径。
5.3 构建、缓存和体积方面的三个经验
语言包会被全量打进 bundle。如果只加了中英两种,影响可以忽略;但如果语种到五六个,主包体积会明显变大。这时候可以考虑把语言文件改成动态import(),按需加载:
async function loadLocale(lang: string) { const module = await import(`@/assets/lang/${lang}.ts`); i18n.setLocaleMessage(lang, module.default); i18n.locale = lang; }注意动态 import 的路径里带变量时,打包工具会生成一个上下文模块把所有匹配文件都打进去,不会真正减包。要真正按需加载,得显式写成映射表的形式,让打包工具能静态分析出依赖边界。
浏览器缓存会骗人。改完语言文件后如果发现界面没变化,先确认不是旧 JS 缓存。开发阶段用硬刷新加禁用缓存,或者直接重启 dev server,别在这上面浪费半小时。
后端返回的枚举要建映射表。前面提过,接口返回的ALARM、NORMAL这类值是英文枚举,不能直接展示。做法是建一张{ value: 'ALARM', i18nKey: 'status.alarm' }的映射表,模板里统一走$t(映射后的key)。当接口新增枚举而映射表没跟上时,加一层兜底:查不到就原样展示,至少不会显示空白。
6. 我个人在长期维护语言包时的几个习惯
语言包这东西的特点是“一次做完,长期维护”。新增一个语种可能就两三天,但接下来每次功能迭代都要同步更新所有语种,这才是真正的成本所在。所以我在实际操作中养成了几个习惯。
第一,每次改功能先改英文文件,英文是基准,其它语种靠check:i18n脚本兜着。CI 里挂了这条检查之后,漏翻基本不会带到主干。
第二,新增 key 时顺手写上简短的上下文注释。比如alarm.duration: 'Duration'这种词,脱离上下文根本不知道是“告警持续时长”还是“告警规则窗口”,翻译人员只能猜。加一行注释就能避免误译,成本极低。
第三,不要追求 100% 覆盖再发版。小语种允许阶段性缺失,靠fallbackLocale退化成英文,先让主路径可见。等主要页面翻完了再逐步补全提示类、帮助类的长文案,比憋一个大 PR 效率高得多。
第四,验收必须真机截图。我在本地开三个不同语言的窗口并排对比过关键页面,中文、英文、日文各一份,截图存档。后面谁改坏了样式,一看截图就知道差在哪。这个小动作省下了无数“在我这儿是好的”的扯皮。
最后分享一个排查小技巧:如果怀疑某个文案没接$t,可以在控制台临时把$t蒙一层,让所有翻译结果都加上标记,比如统一前缀__。刷新之后,页面上带标记的是走 i18n 的,不带的就是硬编码的,一眼分辨,比全局搜索快得多。