ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

组件库国际化实战:Vue 3 轻量 i18n 模块设计与实现

组件库国际化实战:Vue 3 轻量 i18n 模块设计与实现 前端项目是怎么做国际化的很多团队的第一反应是引入 vue-i18n 或者 react-intl把文案抽成 JSON再统一用 t 函数包裹一下。这套思路放在普通业务项目里没有任何问题但一旦场景切换成组件库麻烦就来了分页器的“共 x 条”是组件内部的文案日期面板的“今天”也是空状态里的“暂无数据”还是。一个组件库动辄几十个组件这些自带文案谁来管业务项目自己的语言包和组件库内置语言包撞了怎么办宿主项目没装 vue-i18n 又该怎么办我最近在整理组件库文档时“三、国际化组件”这一章正好就是回答这些问题的。它不等同于应用级 i18n而是把“国际化”折叠进“组件”这个单位里做成一套独立运转的轻量模块既能开箱即用又能按需覆盖还要支持运行时切换。这篇文章我会把这章的方案选型、核心实现和踩坑过程一次性讲清楚适合正在搭组件库的前端开发也适合想搞清楚 Vue 3 里怎么做运行时国际化的同学。1. 整体设计与思路拆解1.1 先想清楚国际化组件的边界在哪里业务项目的国际化通常由三部分组成翻译函数 t、按语言划分的文案包、当前语言状态。三者配合页面渲染时调用 t 读取文案切换语言后触发重新渲染。这套逻辑往组件库里放表面看起来差不多实际有一个关键差异业务应用只有一个全局语言包组件库却要为每个组件准备独立的文案片段。以分页器为例。组件内部必然有“前往”“页”“共 {total} 条”这些文案组件库需要在内部把这些文案按语言分类维护。如果某一天新增一个日语环境组件库并不能要求接入方去补一套日语包只能靠组件库自己维护内置翻译。这就决定了国际化在组件库里不是“工具函数集合”而是一个需要和组件生命周期捆绑的模块。我在设计时把“国际化组件”拆成两层理解。第一层是基础设施负责管理语言包、提供 t 函数、维护响应式语言状态这些能力包装成 Provider 组件对外可见。第二层是组件接入层各个组件内部通过 useI18n 获取翻译能力所有内置文案都从语言包取而不是在代码里写死中文或英文。两层组合起来才算是完整的国际化组件方案。1.2 为什么不建议在组件库里直接依赖 vue-i18n很多刚接触组件库开发的同学会问用 vue-i18n 不就行了我也这么试过踩坑之后才明白组件库场景不能简单复用应用级 i18n 库。最直接的问题是版本冲突。组件库作为 npm 包发布如果直接把 vue-i18n 写进 dependencies宿主项目里很可能已经存在另一个版本的 vue-i18n。两个实例同时存在时语言切换状态各自独立页面里就会冒出“A 部分是中文、B 部分是英文”的诡异现象。即使把 vue-i18n 放进 peerDependencies 声明为外部依赖宿主项目用的是 9 还是 10、配置方式有没有变化这些都不是组件库作者能控制的。其次vue-i18n 这类库本身包含消息编译、复数规则、日期格式化等较为完整的能力对应用来说很实用但组件库的文案规模通常只有几十到几百条根本用不到这么多能力。引入和业务方重复的依赖只会增加接入成本也让组件库的可控性下降。我最终采用的方案是自研一个轻量国际化模块内部不依赖任何 i18n 第三方库。语言包按组件命名空间组织t 函数只做模板字符串替换语言状态用 shallowRef 维护。功能上看起来“简陋”但组件库所需的开箱即用、按需覆盖、运行时切换全都能满足。后面还会提到如果宿主项目本身已经在用 vue-i18n这套轻量模块也能做一层适配两边互不干扰。1.3 三种方案选型的对比与决策做国际化组件前可以先对照一下三种常见方案的定位避免一开始选错方向。方案适用场景优点缺点vue-i18n / react-intl普通业务应用功能成熟生态完善复数、日期格式化开箱即用依赖较重对组件库接入方有版本捆绑风险组件库内置轻量 i18n 模块公共组件库、多项目复用组件零外部依赖语言包按组件拆分运行时切换可控复杂格式化能力需要自己扩展维护桥接模式轻量模块 适配层组件库接入方已在用 vue-i18n复用宿主翻译函数减少重复配置需要额外维护适配层代码对接成本不可忽略如果只是开发一个业务应用直接用 vue-i18n 没有问题。但如果目标是把组件沉淀成多个团队复用的基础设施轻量自研模块会更可控。很多成熟的组件库也是这么做的Element Plus 内置自己的 locale 机制再由 config-provider 对外暴露配置入口Naive UI 则用 NConfigProvider useLocale 的组合。它们本质上都是“内置国际化模块 外部可覆盖”的设计思路这里面的关键点不是翻译功能有多全而是让接入方在不同技术栈下都能平滑使用。2. 核心细节解析与实操要点2.1 语言包组织方式按组件命名空间分片语言包怎么组织决定了后续维护成本。我建议组件的内置文案按命名空间分片每个组件一个文件目录层级按语言划分。lang/ zh-CN/ pagination.ts datePicker.ts empty.ts en-US/ pagination.ts datePicker.ts empty.ts这里有个容易忽略的设计点key 要尽量扁平化。分页器面板的文案不要设计成嵌套对象直接写成pagination.total、pagination.jumpTo这种带前缀的扁平 key 就好。扁平的 key 合并起来特别方便业务方想覆盖某个文案时直接传入一个同 key 的扁平对象就行不需要关心深层次对象的深浅合并问题。我整理一份组件语言包的真实示例供参考// lang/zh-CN/pagination.ts export default { pagination.total: 共 {total} 条, pagination.jumpTo: 前往, pagination.page: 页, pagination.prev: 上一页, pagination.next: 下一页, }// lang/en-US/pagination.ts export default { pagination.total: {total} items, pagination.jumpTo: Go to, pagination.page: Page, pagination.prev: Previous, pagination.next: Next, }这种组织方式最直接的好处是组件按需加载时对应的语言片段也会跟着加载不会出现把整个英文语言包一股脑塞进主包的情况。2.2 响应式语言状态与依赖注入机制语言切换的核心是“状态变化后所有消费翻译的组件重新渲染”。在 Vue 3 里用 shallowRef 保存当前语言是最稳的做法。之所以用 shallowRef 而不是 reactive是因为语言信息本质上是一个字符串语言包的引用变化也不需要深层递归监听浅层响应式就足够了还能减少不必要的依赖追踪开销。组件库场景下语言状态往往只有一份全局配置但组件树可能很深。如果用 props 层层传递 locale一旦组件层级调整每一层都要跟着改成本很高。更合理的做法是 provide / inject顶层 I18nProvider 提供 context内部包含当前语言、翻译函数、语言包和切换方法任意深度的子组件都能直接通过 useI18n 取到。对比组件通信的另一种常见方式——事件总线语言切换用 provide / inject 也更干净。事件总线适合跨组件状态联动但全局监听容易失控排查问题时经常找不到是谁订阅了谁。而 provide / inject 在组件库这种场景下足够显式只要顺着 provider 的父级链就能定位到语言来源。2.3 翻译函数中的插值、占位符与兜底策略t 函数是国际化组件最常用的对外 API。它接收一个 key 和可选参数返回翻译后的字符串。一个好消息是组件库场景不需要一开始就把复数规则、语种分支做得特别复杂模板字符串替换就能覆盖绝大多数 UI 文案。插值语法我建议统一采用{name}形式实现起来很直观function translateTemplate(template: string, params?: Recordstring, any): string { if (!params) return template return template.replace(/\{(\w)\}/g, (_, name: string) { const value params[name] return value ! null ? String(value) : {${name}} }) }我踩过的一个细节是当某个 key 缺失时如果直接返回 undefined渲染到页面上会是空白像一种“无声的 bug”。我最终把兜底逻辑改成找不到 key 时返回 key 本身。这样用户看到渲染结果里有一个pagination.total这样的字符串马上就能意识到是语言包 key 写错或没注册排查速度快很多。另一个容易忽略的问题t 函数虽然叫函数但最好保持“无副作用、可重复执行”的特性。不要在渲染过程中对语言包做增删改所有的语言包合并都通过统一入口执行避免重复渲染时出现不可预测的中间状态。2.4 日期、数字格式化不能只靠文案翻译国际化的范围不止文字翻译日期格式、数字千分位、货币符号同样需要本地化。如果组件库里没有日期选择器、时间线这类组件这个问题可以暂时跳过但只要涉及日期类组件就一定要把“文案”和“格式”放在一起同步处理。我在日期组件里同时使用 dayjs 的 locale 能力。切换语言时不只更新语言包还要同步调用 dayjs.locale()否则会出现分页器文字已经是中文、日期面板还是英文甚至周末列位置对不上的情况。function applyLocaleSideEffects(locale: string) { if (locale.startsWith(zh)) { dayjs.locale(zh-cn) } else if (locale en-US) { dayjs.locale(en) } else { dayjs.locale(en) } }这里有一个额外提醒不要试图用 Intl.DateTimeFormat 的 locale 字符串和自己的语言包 key 一一对齐。Intl接受的规范语言标签和组件库内部语言 key 可能不完全一致比如内置语言包定义为zh-CN但在某些执行环境下 Intl 更偏好小写形式做一层映射会更稳妥。3. 实操过程与核心环节实现3.1 搭建最简国际化模块createI18n 与 I18nProvider我直接把一套可以跑起来的最小实现贴出来基于 Vue 3 的 Composition API。先创建核心工厂函数 createI18n它管理语言包、当前语言和合并逻辑import { shallowRef, type Ref } from vue export type LocaleMessage Recordstring, string export type LocaleDictionary Recordstring, LocaleMessage export interface I18nContext { locale: Refstring messages: RefLocaleDictionary t: (key: string, params?: Recordstring, any) string setLocale: (locale: string) void mergeMessages: (locale: string, dict: LocaleMessage) void } function translate(ctx: I18nContext, key: string, params?: Recordstring, any): string { const dict ctx.messages.value[ctx.locale.value] || {} const template dict[key] if (template null) { return key } if (!params) return template return template.replace(/\{(\w)\}/g, (_, name: string) { const value params[name] return value ! null ? String(value) : {${name}} }) } export function createI18n(defaultLocale zh-CN, initialMessages: LocaleDictionary {}) { const locale shallowRef(defaultLocale) const innerMessages: LocaleDictionary { ...initialMessages } const userMessages: LocaleDictionary {} function buildMessages(): LocaleDictionary { const merged: LocaleDictionary {} const locales new Set([...Object.keys(innerMessages), ...Object.keys(userMessages)]) locales.forEach((lang) { merged[lang] { ...(innerMessages[lang] || {}), ...(userMessages[lang] || {}), } }) return merged } const messages shallowRefLocaleDictionary(buildMessages()) const context: I18nContext { locale, messages, t: (key, params) translate(context, key, params), setLocale(next) { if (next locale.value) return locale.value next applyLocaleSideEffects(next) }, mergeMessages(lang, dict) { if (!userMessages[lang]) userMessages[lang] {} Object.assign(userMessages[lang], dict) messages.value buildMessages() }, } return context }这里有两个关键的响应式细节。第一messages 使用 shallowRef 保存整个合并后的语言包对象merge 之后重新赋值触发依赖它的渲染函数更新。第二t 函数内部同时读取了 locale.value 和 messages.value 两个响应式源所以在模板里调用 t 的组件不管语言切换还是语言包合并都能收到更新通知。然后创建 I18nProvider 组件把 context 注入到组件树中script setup langts import { provide, watch } from vue import { createI18n, I18nKey, type LocaleDictionary } from ./i18n const props defineProps{ locale?: string messages?: LocaleDictionary }() const ctx createI18n(props.locale || zh-CN, props.messages) provide(I18nKey, ctx) watch( () props.locale, (val) { if (val) ctx.setLocale(val) } ) /script template slot / /template需要额外指出的是I18nProvider 的 props.locale 是一个受控入口适合父组件完全掌控语言状态的场景。如果组件库接入方没有使用 Provider而是希望自己管理语言可以让 useI18n 返回一个默认实例保证组件不会因为找不到注入而报错。这也是组件库容错设计的一部分。3.2 useI18n 组合式函数与组件内消费子组件消费国际化能力时统一通过 useI18n 取 context。为了健壮我在 useI18n 里做了一层默认实例兜底import { inject } from vue import { I18nKey, createI18n, type I18nContext } from ./i18n let globalContext: I18nContext | null null export function useI18n(): I18nContext { const injected inject(I18nKey, null) if (injected) return injected if (!globalContext) { globalContext createI18n(zh-CN) } return globalContext }接着以分页器组件为例看一个业务组件如何接入。重点不是写完整分页逻辑而是展示文案覆盖的三级优先级。script setup langts import { computed } from vue import { useI18n } from ../i18n const props withDefaults( defineProps{ total: number pageSize?: number texts?: { total?: string } }(), { pageSize: 10 } ) const { t } useI18n() const totalText computed(() { if (props.texts?.total) return props.texts.total return t(pagination.total, { total: props.total }) }) /script template div classpagination span{{ totalText }}/span /div /template这套设计的优先级是组件 props 传入的 texts 最优先其次是业务方通过 mergeMessages 覆盖的语言包最后才是组件库内置语言包。三级覆盖看起来简单但能覆盖绝大多数真实使用场景既支持“单个组件实例单独配置文案”也支持“全局统一覆盖语言包”。3.3 语言包按需加载与合并时机刚才的 createI18n 里已经提供了 mergeMessages这个入口要放在组件的 setup 或模块初始化中调用。比如分页器组件可以在 setup 阶段把自己需要的语言片段合并进全局语言包import zhPagination from ../lang/zh-CN/pagination import enPagination from ../lang/en-US/pagination const loaded Symbol(pagination-i18n-loaded) export function usePaginationI18n() { const i18n useI18n() const ctx i18n as I18nContext { [loaded]?: boolean } if (!ctx[loaded]) { ctx.mergeMessages(zh-CN, zhPagination) ctx.mergeMessages(en-US, enPagination) ctx[loaded] true } }这里要注意一个“实例与模块级状态”的区别。如果同一个页面里有两个互相隔离的 I18nProvider把语言包加载标志放在模块级很容易造成第二个 Provider 的语言包没有被正确合并。稳妥的做法是把合并后的临时状态挂在 context 上而不是挂在模块级的全局变量上。我早期在这个问题上翻过车排查到最后才发现是两个 Provider 实例共用了模块级标志位。3.4 业务项目如何接入这套国际化能力组件库的国际化模块最终要被业务项目使用接入方式需要足够简单。业务方不必为组件库的每个 key 都准备文案只需要覆盖想修改的部分。import { createApp } from vue import App from ./App.vue const i18n createI18n(zh-CN, { zh-CN: { pagination.total: 一共有 {total} 条记录, }, }) const app createApp(App) app.provide(I18nKey, i18n)这样用户只覆盖了一个pagination.total其他未覆盖的pagination.prev、pagination.next依然会使用组件库内置的中文文案不会出现空白。如果接入方希望完全自定义整套文案也可以在 createI18n 的初始 messages 里把内置语言包整体覆盖掉灵活度很高。3.5 组件通信在语言切换中的角色语言切换虽然是一个状态变化但在组件通信的维度上也能看到不同解法。一个常见的场景是页面顶部的语言开关是父组件分页器、日期选择器是孙组件。语言开关切换后父组件持有的 locale 更新孙组件必须感知到变化。传统的父传子方案是把 locale 一级一级往下传子组件通过 props 接收。但只要中间多一层无关组件每一层都要补一个透传属性代码很快变得笨重。子传父方案则适合内部有独立语言开关的组件通过 emit 把新的语言通知给父组件由父组件决定是否更新全局状态script setup langts const emit defineEmits{ (e: update:locale, locale: string): void }() function switchLocale(locale: string) { emit(update:locale, locale) } /script如果项目里没有组件库 Provider 这种全局注入层事件总线或全局 Store 也能完成语言状态的同步。但从组件库工程化的长期维护角度看provide / inject 依然是语言状态的主路径父传子负责局部隔离子传父负责局部交互通知两侧配合不会打架。4. 常见问题与排查技巧实录4.1 语言切换后组件文案没有更新这个问题出现的频率非常高。表现是切换语言后页面大部分文案都变了但某个封装好的子组件还是旧语言。我排查下来原因通常不是国际化模块本身有问题而是文案没有经过响应式链路。最典型的情况是父组件在 setup 阶段提前把 t 函数的返回值保存成了一个普通字符串并作为 props 传给子组件。比如const title t(empty.title) // 这里已经变成静态字符串这样语言切换后由于 title 是一个字符串而不是 computed 或 ref子组件不会感知到任何变化自然会一直显示旧文案。另一类原因是子组件没有挂在 Provider 子树下面useI18n 拿到了默认兜底实例导致它和全局语言状态脱钩。排查时可以优先检查两点一是组件上方的 I18nProvider 层级是否真的包裹住了当前组件二是 t 调用是否发生在渲染阶段或者是否用 computed 包了一层再传递。4.2 语言包 key 覆盖不生效业务方通过 mergeMessages 传入覆盖文案但页面显示的仍然不是覆盖后的值。这种问题多半来自 key 大小写不一致或语言代码不匹配。比如内置语言包用的是zh-CN业务方在 merge 时写成了zh_CNObject.assign 时会被当成另一种语言处理导致实际合并进了错误的分区。我增加了一个开发环境的 key 校验逻辑初始化国际化模块时先遍历所有内置语言包检查每个 locale 是否拥有一致的 key 集合。开发模式下如果发现某个语言缺少 key直接在控制台输出警告提前暴露问题。这种方式比等到用户反馈“某个组件显示 key 名”要舒服得多。4.3 刷新页面后语言被重置语言状态要落地离不开持久化。第一次接入国际化的项目很容易忽略这一点前端代码里 setLocale(en-US) 切换得很欢快一刷新浏览器语言又变回默认值。解决方式很简单切换语言时同步写入 localStorage初始化时再读回来。const DEFAULT_LOCALE zh-CN const STORAGE_KEY app-locale function getInitialLocale(): string { const saved localStorage.getItem(STORAGE_KEY) return saved || DEFAULT_LOCALE } const i18n createI18n(getInitialLocale()) function onLocaleChange(lang: string) { i18n.setLocale(lang) localStorage.setItem(STORAGE_KEY, lang) }如果涉及服务端渲染还要额外注意客户端和服务端初始语言必须一致否则会出现 hydration 时文案闪烁或节点不匹配的问题。组件库场景下通常只保证浏览器端运行时切换正确即可服务端场景另行适配。4.4 常见问题排查速查表现象可能原因解决思路部分组件语言不更新t 调用结果被提前固化为字符串改为 computed 或在渲染时调用全部组件语言都不更新locale 状态没有响应式确认使用 shallowRef 且 setLocale 被正确触发只有个别 key 不翻译key 拼写不一致或语言包缺失开启 dev 模式警告快速定位缺失 key刷新后语言被重置未持久化语言状态接入 localStorage 或 cookie日期组件文案与面板语言不一致dayjs locale 与语言包未同步切换语言时同步调用 dayjs.locale多个 Provider 时语言包冲突模块级标志位共享把合并标志挂在 context 实例上这张表的每一行我都真实遇到过。尤其是“模块级标志位共享”这个问题只在页面里同时挂两个独立 Provider 时才会暴露日常开发很难发现建议大家在实现语言包按需加载时直接选择 context 实例级标志位省去后续重构。4.5 用自动化测试守住国际化模块国际化模块的边界很清晰非常适合写单元测试。我通常在模块里补一组测试覆盖四个核心用例基础翻译、插值替换、语言切换后翻译变化、语言包合并覆盖优先级。测试代码不复杂但能挡住大多数回归问题。import { describe, expect, it } from vitest import { createI18n } from ../src/i18n describe(createI18n, () { it(should translate correctly, () { const i18n createI18n(zh-CN, { zh-CN: { pagination.total: 共 {total} 条 }, }) expect(i18n.t(pagination.total, { total: 10 })).toBe(共 10 条) }) it(should update when locale changes, () { const i18n createI18n(zh-CN, { zh-CN: { hello: 你好 }, en-US: { hello: Hello }, }) i18n.setLocale(en-US) expect(i18n.t(hello)).toBe(Hello) }) it(should merge messages with priority, () { const i18n createI18n(zh-CN, { zh-CN: { pagination.total: 共 {total} 条 }, }) i18n.mergeMessages(zh-CN, { pagination.total: 一共 {total} 条 }) expect(i18n.t(pagination.total, { total: 2 })).toBe(一共 2 条) }) it(should return key when message is missing, () { const i18n createI18n(zh-CN, { zh-CN: {} }) expect(i18n.t(not.exists)).toBe(not.exists) }) })这套测试跑起来只需要几秒钟但在组件库持续迭代时价值很大尤其是后续有人修改语言包合并逻辑测试用例能第一时间把问题暴露出来。5. 最后说一点我的实际体会最初给组件库接国际化时我也走过一段弯路直接用 vue-i18n 把内置文案全部包了一遍。刚开始一切正常直到有接入方升级了宿主项目的 vue-i18n 版本页面里出现了“一半组件中文、一半组件英文”的经典冲突现场。那一次排查花了不少时间也让我彻底下决心把国际化模块改成自研轻量方案。回头看这套设计的最大收益不是“摆脱了一个依赖”而是让组件库的语言包边界变得特别清晰。按组件命名空间拆语言包组件和文案一一对应新增一个组件时顺手带上多语言片段不需要去动全局语言包改动面非常小。如果你正在做组件库或者准备给现有组件库补国际化能力我建议从第一天就按这个思路组织别等到组件数量多了再重构。到那时语言包之间的关系已经复杂到超出预期改起来成本会大很多。
返回列表