资讯详情

SkyWalking UI 国际化实战:语言包配置、组件库联动与 key 校验

📅 2026/11/2 4:04:35 | 华诺云谱 👁 阅读
SkyWalking UI 国际化实战:语言包配置、组件库联动与 key 校验
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第二步逐行翻译 valuekey 一个字都不能动。这一步是最耗时的也是最容易出错的。翻译时的优先级是菜单和导航 按钮和表单 表格表头 提示和错误信息 帮助说明文字。前三类能覆盖 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-cnuse()的调用方式也改成了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在模板里实时$tdata() { return { columns: [{ labelKey: service.name, prop: name }], }; }el-table-column v-forcol in columns :keycol.prop :label$t(col.labelKey) :propcol.prop /这类问题靠肉眼很难发现因为它在切换时“大部分是对的”只有个别表头不对劲。我一般的排查手段是切到目标语言后在控制台执行document.body.innerText抓取整页文本再用正则匹配纯 ASCII 的连续单词把疑似残留英文的位置打印出来逐一核对。5.2 长文案引发的布局问题不同语种的长度差异远超想象。同一句话中文字符数通常是最短的德语、俄语往往要长出 30% 到 50%。按钮、标签页、表格列头这些固定宽度的地方翻译完直接溢出或者被省略号截断。几个实用的处理方式按钮容器不要写死width改用min-widthpadding让内容自己撑开。表格列头开启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 的不带的就是硬编码的一眼分辨比全局搜索快得多。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑