组件库的国际化远不止把界面文案翻译成多种语言那么简单。当用户切换到阿拉伯语、希伯来语或波斯语时,整个页面的阅读方向会从由左到右变为从右到左,按钮顺序、图标指向、弹层位置乃至阴影方向都需要随之调整。Vue 3 的响应式系统和组合式 API 为这类跨切面的需求提供了灵活的抽象基础,但真正落地时仍会遇到不少细节问题。本文从自研组件库的视角出发,讨论如何搭建一套可维护的多语言支持体系,并让布局能够自动适配 RTL 环境。

语言上下文是国际化的根基。在 Vue 3 中,可以使用 provide/inject 把当前语言、文本映射表以及格式化函数注入到组件树中,配合组合式函数统一管理。后面几个小节将分别介绍语言包的注入方式、格式化工具、CSS 逻辑属性,以及组件内部的方向感知逻辑。
设计语言上下文:provide/inject 与 useLocale
在组件库中,如果每个组件都自行维护一份语言包和切换逻辑,不仅会产生大量重复代码,还会导致语言切换时部分组件无法同步更新。Vue 3 的 provide/inject 机制非常适合解决这个问题。我们可以在组件库的根组件(例如 ConfigProvider)中注入当前语言和对应的翻译函数,下游组件通过 inject 获取,这样语言状态就是响应式的,切换语言时所有使用该上下文的组件都会自动重新渲染。
下面是一个最小化的语言上下文设计。先定义一个语言包结构,其中包含组件命名空间和具体文案。为了避免深层嵌套对象在切换时产生不必要的性能开销,通常使用扁平化的 key 映射,例如 button.confirm。同时,还需要考虑复数规则和特殊变量替换,因此翻译函数不应只做简单的字符串查找,而应支持模板插值和复数选择。这里使用 Intl.MessageFormat 类似的思路,但为了简化示例,我们实现一个轻量的 t 函数。
// locale/lang/zh-CN.js
export default {
'button.confirm': '确定',
'button.cancel': '取消',
'dialog.title': '提示',
'pagination.total': '共 {total} 条',
'datepicker.month': '{month} 月'
}
语言包注入时,可以借助一个 useLocale 组合式函数,在组件内部统一调用。这个函数负责从 inject 中取出当前语言包和语言代码,并提供 t 函数。为了避免组件在未包裹 ConfigProvider 时出现 inject 找不到的问题,应该提供默认值,默认语言使用英文或中文。此外,还应当向外暴露一个 useLocaleProvider 函数给根组件,用于设置和切换语言。
// composables/useLocale.js
import { ref, provide, inject, computed } from 'vue'
import zhCN from '../locale/lang/zh-CN'
import enUS from '../locale/lang/en-US'
const localeSymbol = Symbol('locale')
export function useLocaleProvider() {
const currentLang = ref('zh-CN')
const messages = ref(zhCN)
function setLang(lang) {
currentLang.value = lang
messages.value = lang === 'en-US' ? enUS : zhCN
}
const t = (key, params = {}) => {
let template = messages.value[key] || key
return template.replace(/\{(\w+)\}/g, (_, name) => params[name] ?? '')
}
provide(localeSymbol, {
lang: currentLang,
t
})
return { lang: currentLang, setLang }
}
export function useLocale() {
const context = inject(localeSymbol, {
lang: ref('zh-CN'),
t: (key) => key
})
return context
}
这种设计的优势在于:语言切换只需要修改根组件的 currentLang,所有注入的 t 函数都会因为响应式依赖而触发更新。但简单的字符串替换无法处理复数形式,例如英语中的 1 item 和 2 items。因此在实际组件库中,会引入 @formatjs/intl 或自研一套支持复数和选择的格式化引擎。接下来介绍如何利用原生 Intl API 补足这些能力。
组件级格式化:深入 Intl API 的复用
日期、数字、货币和相对时间等格式化需求,不应该由组件库自己实现一套算法,而是应该复用浏览器原生的 Intl 对象。它支持超过 400 种语言环境,并且会随浏览器更新自动完善。在 Vue 3 组件中,我们可以根据当前语言创建一个 Intl.DateTimeFormat 和 Intl.NumberFormat 实例,并通过 computed 缓存,这样在语言切换时自动重建。
以日期选择器组件为例,不同语言环境下年月日的显示顺序、分隔符、星期名称都不相同。如果手动拼接字符串,不仅容易出错,而且在切换到 RTL 语言时还可能破坏视觉平衡。使用 Intl.DateTimeFormat 的 formatToParts 方法可以拿到结构化片段,组件可以针对不同片段应用不同样式。下面是一个根据当前语言创建格式化器的组合式函数。
// composables/useIntlFormatters.js
import { computed } from 'vue'
import { useLocale } from './useLocale'
export function useIntlFormatters() {
const { lang } = useLocale()
const dateFormatter = computed(() => {
return new Intl.DateTimeFormat(lang.value, {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
})
const numberFormatter = computed(() => {
return new Intl.NumberFormat(lang.value, {
style: 'decimal',
maximumFractionDigits: 2
})
})
const formatDate = (date) => dateFormatter.value.format(date)
const formatNumber = (num) => numberFormatter.value.format(num)
return { formatDate, formatNumber }
}
复数规则同样可以交给 Intl.PluralRules 处理。例如在消息通知组件中,需要根据未读数量显示不同文案:中文所有数量都用同一种形式,而英语区分为单数和复数。组合 Intl.PluralRules 和之前定义的 t 函数,可以实现语言感知的复数选择。下面是一个简化的示例,通过 Intl.PluralRules 获取类别,再从语言包中查找对应 key。
// composables/usePlural.js
import { computed } from 'vue'
import { useLocale } from './useLocale'
export function usePlural(count) {
const { lang, t } = useLocale()
const pluralKey = computed(() => {
const pr = new Intl.PluralRules(lang.value)
const rule = pr.select(count.value)
return `notification.unread.${rule}`
})
const text = computed(() => t(pluralKey.value, { count: count.value }))
return { text }
}
这类格式化的好处是彻底摆脱了硬编码的语言规则,将复杂性交给浏览器。但需要注意,旧版浏览器或某些嵌入式 WebView 可能缺少部分 Intl 支持,需要引入 polyfill。组件库可以在文档中声明最低浏览器版本,并在构建时按需加载 polyfill。接下来讨论 RTL 适配中的布局问题,这是国际化中最容易忽略却影响巨大的部分。
CSS 逻辑属性:从物理属性到 RTL 自动适配
传统 CSS 属性如 margin-left、padding-right、border-left 等,都绑定在物理方向上。当页面切换到 RTL 语言时,原本靠左的间距应该自动变成靠右,但物理属性不会感知阅读方向变化,需要编写大量方向翻转样式。现代 CSS 提供了逻辑属性,例如 margin-inline-start、padding-inline-end、border-inline-start,它们会根据元素的 dir 属性自动映射到对应的物理方向。
在组件库的样式体系中,应当全面使用逻辑属性替代物理属性。例如一个按钮组中,相邻按钮之间的间距在 LTR 下是左侧外边距,在 RTL 下应该是右侧外边距。如果写成 margin-left: 8px,RTL 环境下间距会出现在错误的一侧,破坏视觉分组。使用 margin-inline-start: 8px 后,浏览器会根据父级 dir 自动翻转。下面是一个按钮组样式的对比。
/* 物理属性写法:RTL 下需要额外覆盖 */
.v-btn-group .v-btn + .v-btn {
margin-left: 8px;
}
html[dir="rtl"] .v-btn-group .v-btn + .v-btn {
margin-left: 0;
margin-right: 8px;
}
/* 逻辑属性写法:自动适配 */
.v-btn-group .v-btn + .v-btn {
margin-inline-start: 8px;
}
并非所有样式都能用逻辑属性解决,例如绝对定位的 left 和 right 需要根据方向动态调整。此时可以使用 inset-inline-start 等逻辑定位属性。对于复杂的弹出层组件(如 Tooltip、Dropdown),需要根据 dir 计算弹层对齐方向。好在现代浏览器对逻辑属性的支持已经相当完善,只有少数旧版 Safari 和 IE 需要注意。在组件库构建阶段,可以使用 PostCSS 插件(如 postcss-logical)自动将逻辑属性编译为带 [dir] 选择器的物理属性,以兼容不支持逻辑属性的浏览器。
除了间距和定位,文本对齐也需要使用逻辑值。例如表格的表头默认左对齐,在 RTL 语言中应该右对齐。可以用 text-align: start 替代 left。同时,滚动条、阴影方向等细节也会因为阅读方向改变而产生视觉差异,最好在组件样式中统一使用逻辑属性和逻辑值,从源头减少方向相关的分支代码。
方向感知的组件逻辑与工程化实践
有些组件不能完全依赖 CSS 逻辑属性,还需要在 JavaScript 中感知当前方向。例如分页组件的上一页/下一页图标,在 LTR 下“上一页”通常指向左箭头,RTL 下则应指向右箭头。如果图标使用 SVG,可以通过 CSS 的 transform: scaleX(-1) 来翻转,但某些图标本身有文字语义,不能简单翻转。此时应在组件内部根据 dir 计算图标名称或方向标记。
获取当前方向最简单的方式是读取根元素的 dir 属性,或从语言代码推断。更可靠的做法是在 useLocale 中同时暴露 dir 计算属性,默认根据当前语言判断,同时允许使用者手动指定。这样组件可以通过 const { dir } = useLocale() 获取,并结合 computed 调整图标方向、弹出层对齐类名、滑动方向等。下面是一个分页组件使用 dir 的简化逻辑。
// components/Pagination.vue setup 部分
import { computed } from 'vue'
import { useLocale } from '../composables/useLocale'
export default {
setup() {
const { dir, t } = useLocale()
const prevIcon = computed(() => (dir.value === 'rtl' ? 'arrow-right' : 'arrow-left'))
const nextIcon = computed(() => (dir.value === 'rtl' ? 'arrow-left' : 'arrow-right'))
return { dir, t, prevIcon, nextIcon }
}
}
在工程化层面,组件库还需要考虑语言包的按需加载。如果将所有语言包打包进主文件,会增加初始体积。可以利用动态导入和异步组件,在用户切换语言时再加载对应语言包。同时,服务端渲染(SSR)场景下要避免语言状态在服务端和客户端不一致导致的水合错误,需要在请求上下文中确定初始语言。此外,RTL 样式通常需要单独生成一份 CSS 文件,或者在运行时动态切换 dir 属性。建议在文档站点和示例中同时提供 LTR 与 RTL 预览,方便开发者测试组件在两种方向下的表现。
实际开发中,一个常见的误区是只把文案翻译了,却忽略数字、日期和方向的适配。依赖用户手动切换 dir 有时并不可靠,可以在 ConfigProvider 中根据语言自动设置根元素的 dir 属性,并允许手动覆盖。这样组件本身不需要关心根元素的 dir 是否已经被设置。最后,国际化测试也需要覆盖多语言环境,特别是 RTL 下的视觉回归测试,可以通过 Playwright 等工具模拟不同语言和方向,确保组件在各种环境下都能正常工作。