组件库和业务项目的节奏很不一样。业务项目今天写的代码,明天上线就能看到效果;组件库写的东西,可能要到几个月后某个不认识的开发者在另一个项目里接入,才会真正被用户用到。所以组件库里最容易出问题的,恰恰是那些“开发验收时看不见、一接入真实场景就跑出来”的隐藏需求——国际化(i18n)和无障碍访问(a11y)就是其中最典型的两项。
我记得第一次把组件库的 demo 发给朋友看,他说“分页器挺好用的”,然后转头就提了个 Issue:他们产品要切英文,分页器里那个“共 120 条”怎么传参数?接着另一个开发者说他们的 QA 团队用键盘和读屏器做测试,组件库的对话框打开之后,焦点直接停在 body 上,Tab 也跑不到按钮里。这些都不是“加个属性”能糊弄过去的,而是要从设计层面重新梳理组件库的底层能力。
所以这篇文章我会从组件库设计者的视角,讲清楚 Vue 3 组件库里的国际化与无障碍访问到底意味着什么,为什么不能等用户提了 Issue 再补,以及一套我实践过的、可以落到代码里的具体方案。内容会比较长,适合正在维护公司内部组件库、准备开源组件库,或者想在业务项目里真正理解这两个机制怎么运作的开发者读。
1. 组件库的国际化,难点从来不在“翻译”
1.1 先盘一盘组件里到底藏了多少句子
很多人一听到国际化,第一反应是“把文案翻译成英文”。真做起来就会发现,组件库里的文案远比想象中多,而且分布得特别散。
拿一个分页组件举例,表面上看就几个页码数字,实际上有:“共 {total} 条”“第 {current} / {totalPage} 页”“上一页”“下一页”“跳至第 {input} 页”“每页 {size} 条”这一堆。下拉选择器有“暂无数据”“加载中”“清除”“展开选项”。日历组件更夸张,不仅有“年”“月”“日”“周”,还有“选择日期”“开始日期”“结束日期”“今天”“清除日期”这些辅助文案。对话框虽然看起来没有文案,但右上角那个关闭按钮的 title 提示,读屏器用户会听到“关闭”,这个也必须可翻译。
我列过一个内部仓库的文案盘点,大概有十几个组件、一百多条字符串。组件库国际化第一步不是写翻译文件,而是先做一次完整盘点,把每个组件里硬编码的中文字符串全部抽到 messages 文件里。
| 组件 | 需要翻译的文案示例 | 是否容易被忽略 |
|---|---|---|
| Pagination | 共 {total} 条 / 上一页 / 下一页 / 第 {current} / {totalPage} 页 | 否,页面显眼 |
| DatePicker | 请选择日期 / 开始日期 / 结束日期 / 今天 | 是,占位符和按钮 title |
| Empty | 暂无数据 / 暂无搜索结果 | 是,空状态太容易被忽略 |
| Dialog | 关闭按钮 aria-label | 是,视觉上只是个图标 |
| Upload | 点击上传 / 文件大小超出限制 | 是,错误提示也要翻译 |
| Table | 列设置 / 全选 / 已选择 {count} 项 | 是,批量操作文案很多 |
1.2 业务层国际化和组件库国际化的本质区别
业务项目里的国际化,说白了就是“我有个全局语言状态,UI 跟着它变”。用 vue-i18n、Pinia、甚至一个全局 reactive 对象都行,因为语言状态和翻译资源全在自己手里。
组件库面对的是另一回事:你根本不知道使用者装没装 vue-i18n,不知道他的语言状态存在哪个 store 里,也不知道他什么时候切换语言。组件库又必须开箱可用——用户装上之后什么都不配置,至少得有一套默认语言能显示。这里的核心矛盾是“依赖倒置”:不是组件库去适配业务项目的国际化方案,而是业务项目把翻译能力和语言状态“注入”进组件库。
想明白这一点,很多设计决策就顺了。组件库不能硬生生告诉用户“你必须装 vue-i18n”,而是要提供一个注入点:你可以传语言包,也可以传自定义 t 函数。传了就用你的,不传就用内置默认值。这个模型不是组件库团队自己拍脑袋定的,Element Plus、Arco Design 这些成熟组件库的国际化方案,本质上都是同一套思路。
1.3 语言包结构:先定默认值,再谈覆盖
语言包结构的设计决定了后面每一步好不好做。我建议组件库内部语言包采用嵌套结构,比如:
// locale/zh-cn.ts export default { name: 'zh-cn', el: { pagination: { total: '共 {total} 条', prev: '上一页', next: '下一页' }, datePicker: { placeholder: '请选择日期', startDate: '开始日期', endDate: '结束日期' } } }嵌套结构的好处是按模块隔离,组件可以按需取用,也方便使用者定位要覆盖哪一条。命名空间里的el是整个组件库的顶级命名空间,业务方自己的 vue-i18n 文案放在自己的命名空间下,两者不会撞车。
默认语言建议内置英文。倒不是中文不好,而是“en”更接近通用默认值,国际开源项目都是这个惯例;如果你主要面向中文用户群体,内置中文也没问题,但英文包一定要有,否则海外用户拿到手里全是中文。还有一条必须写死的兜底逻辑:t(key)找不到对应文案时,降级链应该是“用户传入的当前语言包 → 组件库内置的当前语言包 → 组件库内置英文包”。最坏情况下返回 key 本身,也绝不能让页面崩出一个 undefined 或者空白。
2. 不依赖 vue-i18n 的轻量国际化内核
2.1 为什么组件库不能把 vue-i18n 写成硬依赖
设计组件库时最容易踩的坑,就是“既然业务项目都用 vue-i18n,那组件库也直接用吧”。问题在于:
第一,组件库不能假设使用方已经安装依赖。你要么把 vue-i18n 打包进组件库产物里,要么把它列成 peerDependency。前者会让组件库体积明显变大,后者等于强制用户装一个他们可能根本不需要的库。
第二,vue-i18n 有两种模式。legacy 模式下提供全局$t,composition 模式下用useI18n()拿到t。组件库内部到底用哪种?如果用全局$t,用户没启用 legacy 模式就废了;如果用useI18n,使用方需要手动传递 i18n 实例,配置成本很高。
所以更稳妥的做法是:组件库自己实现一个轻量 useLocale 内核,内部通过provide/inject传递,组件库不关心使用者用的是 vue-i18n 还是自己封装的一百行翻译函数。如果用户真的在使用 vue-i18n,他只需要把t函数或翻译结果注入进来就好。这种“管好自己的事,留好扩展口”的设计,才是组件库该有的姿态。
2.2 provide/inject 与 useLocale 的组合式写法
Vue 3 的provide/inject天然适合这种场景。核心思路是:组件库暴露一个ConfigProvider组件,业务方在应用根部用它包住整个应用,传入当前语言和消息包;内部所有组件通过useLocale()从祖先组件那里拿t函数。如果拿不到,就回退到组件库内置的默认值。
// locale-context.ts import { computed, inject, provide, ref } from 'vue' const localeContextKey = Symbol('localeContext') const DEFAULT_LANG = 'en' const DEFAULT_MESSAGES = { en: { pagination: { total: '{total} items', prev: 'Prev', next: 'Next' } } } const currentLang = ref(DEFAULT_LANG) const messageMap = ref(DEFAULT_MESSAGES) function translate(message, params) { if (!params) return message return message.replace(/\{(\w+)\}/g, (_, key) => String(params[key] ?? '')) } function resolveMessage(lang, key) { const pack = messageMap.value[lang] ?? messageMap.value[DEFAULT_LANG] return key.split('.').reduce((acc, cur) => acc?.[cur], pack) } export function provideLocale(options = {}) { if (options.lang) currentLang.value = options.lang if (options.messages) { // 这里做一次浅合并,后面会讲 deepMerge messageMap.value = { ...DEFAULT_MESSAGES, ...options.messages } } const t = (key, params) => { const value = resolveMessage(currentLang.value, key) if (typeof value !== 'string') return key return translate(value, params) } provide(localeContextKey, { lang: currentLang, t, locale: computed(() => resolveMessage(currentLang.value, '')) }) } export function useLocale() { const ctx = inject(localeContextKey, null) if (ctx) return ctx // 没有注入时走默认值,保证组件脱离 ConfigProvider 也能渲染 const t = (key, params) => { const value = resolveMessage(DEFAULT_LANG, key) return typeof value === 'string' ? translate(value, params) : key } return { lang: ref(DEFAULT_LANG), t } }ConfigProvider这个组件本身不需要渲染任何 DOM,它只是把配置通过provide传到子组件树里:
<!-- ConfigProvider.vue --> <template> <slot /> </template> <script setup> import { provideLocale } from './locale-context' const props = defineProps({ lang: { type: String, default: 'en' }, messages: { type: Object, default: null } }) provideLocale({ lang: props.lang, messages: props.messages }) </script>组件内部使用时,非常简洁:
// pagination.vue const { t } = useLocale() // 模板/渲染函数 `${t('el.pagination.total', { total: computedTotal })}`这里要重点说一个问题:t函数必须在渲染函数里被调用,才能建立响应式依赖。如果你在setup里把它缓存成一个普通变量,比如const totalText = t(...),语言切换后模板里的值是不会变的。所有需要动态语言的文本,都应该在computed或渲染函数里实时调用t。
提示:这也是很多组件库国际化实现中最隐蔽的 bug。组件开发者在 setup 里取了一次
t('xxx'),测试的时候一切正常,等到用户动态切语言,发现文案纹丝不动,怎么排查都找不到原因。只要记住“t 是响应式的,调用它的地方才是响应式的”,就能避免。
2.3 语言包深度合并与动态切换的细节
用户传入的语言包往往只是覆盖其中几条,如果直接{ ...defaultMessages, ...customMessages }做浅合并,嵌套层级的字段会被整个覆盖掉。比如默认包里有pagination.total,用户只想改datePicker.placeholder,浅合并会把整个datePicker对象都替换成用户传进来的这一条。所以需要 deepMerge。
一个足够用的实现,不需要引入 Lodash:
function isPlainObject(value) { return Object.prototype.toString.call(value) === '[object Object]' } export function deepMerge(target, source) { if (!isPlainObject(target) || !isPlainObject(source)) { return source ?? target } const result = { ...target } for (const key of Object.keys(source)) { result[key] = deepMerge(target[key], source[key]) } return result }动态切换语言的机制是:ConfigProvider的 props 变化时,provideLocale内部更新currentLang,所有通过useLocale()拿到t的组件,在下一轮渲染里重新读取messageMap[currentLang]就能拿到新文案。整个过程不需要任何事件总线,纯粹靠 Vue 3 的响应式系统完成。
2.4 如果你已经在用 vue-i18n:兼容策略
使用者那边如果是 vue-i18n 的忠实用户,他完全可以把 vue-i18n 的t函数传进来替代组件库默认翻译:
<ConfigProvider :lang="locale" :t="(key, params) => t(`el.${key}`, params)" > <App /> </ConfigProvider>这样配置之后,组件库内部的所有文案就走 vue-i18n 的翻译管道路线了。组件库代码本身不需要知道 vue-i18n 的存在,它只认自己定义的t(key, params)签名。这种“鸭子类型”的方式,让组件库不会和任何第三方 i18n 库强绑定。
注意:上面的示例刻意把
el.前缀拼在 key 前面,让组件库的文案归入 vue-i18n 命名空间,和业务文案互不干扰。这是我在实际项目中最推荐的接法。
3. 时区、日期和 RTL:格式问题才是国际化的深水区
3.1 Intl API 是组件的唯一格式化出口
翻译只是国际化的表层。真正让组件库开发者头疼的是日期、数字、货币这类“格式差异”。同一串2024-03-01,中国用户习惯看“2024年3月1日”,美国用户习惯“03/01/2024”,英国用户习惯“01/03/2024”。如果组件内部把所有日期都写死成yyyy-MM-dd,那组件库根本谈不上国际化。
正确做法是把Intl.DateTimeFormat和Intl.NumberFormat作为唯一的格式化出口。组件内在需要展示日期时,一律根据当前语言构建 formatter:
function formatDate(date, lang) { return new Intl.DateTimeFormat(lang, { year: 'numeric', month: '2-digit', day: '2-digit' }).format(date) }Intl的一个隐藏好处是,你不用自己维护“中文要年月日、英文要斜杠分隔”的规则表,只要把lang传进去,本地化规则由运行时处理。数字格式化同理,表格组件里展示金额、百分比时,直接用Intl.NumberFormat(lang)处理,千分位、小数点符号、货币位置全部自动适配。
3.2 时间字符串的经典坑:new Date('2024-01-01') 的时区偏移
这是我踩过的最深的一个坑,当时花了整整一个晚上才定位出来。
用户在选择日期范围后提交表单,后端收到的结束时间是2023-12-31T16:00:00.000Z,数据库里存的日期少了一天。根因是组件内部把日期字符串直接用new Date('2024-01-01')解析。这种带连字符的 ISO 日期字符串,规范上会按 UTC 时间 00:00 解析;东八区环境下,这个 Date 对象本地时间就变成了 2024-01-01 08:00。如果后面有人对它做toISOString()转回 UTC,就又变成2023-12-31T16:00:00.000Z,传给别人展示时,日期就错位了。
组件库在处理日期时,尽量用“字段拼装”的方式构造本地时间:
// 推荐:本地时区构造 const toLocalDate = (year, month, day) => new Date(year, month - 1, day) // 不推荐:带分隔符的字符串构造 // new Date('2024-01-01') 在部分时区会偏移日期范围比较的时候,也要用y / m / d三个字段做比较,不要直接比较getTime()。因为getTime()是绝对时间戳,包含时分秒,两个本意是同一天的日期,一旦经过时区转换,就可能出现几十个小时的偏差。
3.3 RTL 布局:切语言不只是切文案
阿拉伯语、希伯来语这类从右往左读的语言,不只是把文案翻译一下就行,整个界面方向都要镜像翻转。分页的“下一页”箭头要朝左,日期面板的左右切换箭头要换向,对话框的关闭按钮也许要移动到左下角。
我见过程序员把dir="rtl"加到<html>上之后,组件库的布局乱成一团的场景。因为早期组件库的样式全是margin-left、padding-right、float: left这类物理属性写死的。要支持 RTL,组件的布局样式应该尽量用 CSS 逻辑属性,也就是margin-inline-start、padding-inline-end、inset-inline这一类。逻辑属性会跟随writing-mode和direction自动换向,天然适配 LTR 和 RTL。
对于必须物理换向的图标,比如日期面板里左右翻页的箭头,可以统一用一个 RTL 判断,给它们加scaleX(-1)水平镜像。不要把箭头写死在组件的 JS 逻辑里。
组件库的ConfigProvider可以在内部根据语言代码自动输出方向标识,让使用者不用手动设置:
const RTL_LANGS = ['ar', 'he', 'fa', 'ur'] const direction = computed(() => { return RTL_LANGS.some((lang) => props.lang.startsWith(lang)) ? 'rtl' : 'ltr' })然后把它写到 ConfigProvider 包裹的容器上,组件库内部的样式才能统一响应方向变化。如果你做的组件库不打算支持 RTL,至少也要在文档里明确写清楚“不支持从右向左的语言”,避免用户产生预期落差。
3.4 日历组件里“一周从哪天开始”的细节
同样是日历组件,中国习惯周一作为一周的第一天,美国和日本习惯周日,中东地区又有周六开头的。这个字段和翻译无关,纯粹是本地化习惯差异。如果组件库硬编码weekStart = 1,美国用户看到自己的日历从周一而不是周日开始时,会非常困惑。
现代浏览器可以通过Intl.Locale获取这个信息:
const locale = new Intl.Locale('en-US') const firstDay = locale.weekInfo?.firstDay ?? 7weekInfo属性是相对较新的 API,老浏览器可能没有。组件库需要维护一张降级表,把常见语言代码映射到默认的周起始日,否则老环境里拿到一个 undefined 就尴尬了。日期格式化还有个容易被忽略的点:Intl.DateTimeFormat在不同 Node 版本和浏览器环境输出可能不一致,Node 13 以后默认内置完整 ICU 数据,但如果你还在维护老旧的 Node 服务,要注意full-icu的配置问题,否则服务端渲染出的日期和客户端 hydration 出来的不一致,页面会闪一下错误内容。
4. 无障碍的三个层次:语义、键盘与读屏器
4.1 语义化是 0 到 1,ARIA 是 1 到 100
无障碍访问经常被误解成“不就是加几个 aria 属性吗”。这个理解太浅了。无障碍应该分三个层次:语义、键盘、读屏器,每一层都要单独设计。
第一层是语义化。能用原生<button>就不要用<div @click>。原生 button 自带 Enter 和 Space 触发 click 的键盘行为,也天然能被读屏器识别为按钮。我用div实现一个自定义按钮时,必须手动处理 role、tabindex、keydown 事件,反而比原生 button 更麻烦。
如果组件结构特殊,必须使用非语义标签,就要配合 role 属性做语义补充:
| 业务组件 | 建议语义标记 |
|---|---|
| 自定义按钮 | 优先原生<button>;否则 role="button" + tabindex="0" |
| 折叠面板头部 | <button>+ aria-expanded + aria-controls |
| 弹窗 | role="dialog" + aria-modal="true" + aria-labelledby |
| 下拉选择 | role="listbox" + role="option"(简化方案) |
| 消息提示 | role="status"(礼貌播报)或 role="alert"(阻塞播报) |
| 表格排序按钮 | <button>+ aria-sort |
4.2 键盘交互:组件库必须自己管的“事件流”
第二层是键盘。读屏器用户和重度键盘用户完全不用鼠标,所有交互都必须能通过键盘完成。这里的核心原则是:所有可交互组件都要能被 Tab 进入,能被 Enter 或 Space 触发,能用 Esc 退出。
自定义下拉选择器的键盘行为,业内其实有近似的统一约定,我按这个顺序实现的:
- 焦点在触发器上:Enter 或 ArrowDown 展开列表,焦点移到第一项。
- 焦点在列表内:ArrowUp / ArrowDown 移动高亮,Home / End 跳到首末项。
- 选中:Enter 选中当前高亮项并关闭列表。
- 取消:Esc 关闭列表,焦点回到触发器。
日期选择器会更复杂一些:左右方向键移动日期,PageUp / PageDown 按月份切换,Shift + PageUp / Shift + PageDown 按年份切换。实际组件库开发时,我建议把这类键盘逻辑抽成组合式函数,不要散落在各个组件的模板里。
// useKeyboardNav.ts export function useKeyboardNav(containerRef, options = {}) { const { onMove, onSelect, onClose } = options function onKeydown(event) { const map = { ArrowDown: () => onMove?.('next'), ArrowUp: () => onMove?.('prev'), Enter: () => onSelect?.(), Escape: () => onClose?.() } const handler = map[event.key] if (handler) { event.preventDefault() handler() } } return { onKeydown } }这个函数的好处是:所有组件共用一套事件映射,行为保持一致,测试也更容易写。
4.3 对话框与下拉的焦点陷阱与焦点回归
对话框打开之后,用户按 Tab 不应该跑到对话框之外的页面元素上,这就是焦点陷阱。关闭之后,焦点要回到打开对话框的那个按钮上,否则键盘用户会“迷路”。这两条是无障碍规范里非常核心的交互要求,也是最容易被组件库开发者遗漏的。
Vue 3 里实现焦点陷阱并不难,核心是监听Tab/Shift + Tab,把焦点限制在容器内可聚焦元素里:
// useFocusTrap.ts import { onMounted, onUnmounted } from 'vue' export function useFocusTrap(containerRef, options = {}) { const { initialFocusRef } = options let previousFocus = null function getFocusableEls() { if (!containerRef.value) return [] return Array.from( containerRef.value.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ) ) } function trapTab(event) { if (event.key !== 'Tab') return const els = getFocusableEls() if (!els.length) return const first = els[0] const last = els[els.length - 1] if (event.shiftKey && document.activeElement === first) { event.preventDefault() last.focus() } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault() first.focus() } } onMounted(() => { previousFocus = document.activeElement const target = initialFocusRef?.value ?? getFocusableEls()[0] target?.focus() containerRef.value?.addEventListener('keydown', trapTab) }) onUnmounted(() => { containerRef.value?.removeEventListener('keydown', trapTab) // 关键:关闭后焦点回归到触发元素 previousFocus?.focus?.() }) }焦点陷阱本身是好写的,真正的难点是回归。很多组件库只处理“打开时聚焦”,忘了“关闭后回归”,导致键盘用户在对话框关闭后焦点丢到 body 上,按 Tab 只能从头开始扫页面。回归逻辑应当在组件卸载时执行,也就是onUnmounted钩子里恢复previousFocus。
4.4 aria-live 用错比不用更糟
第三层是读屏器。动态内容对读屏器用户来说是“看不见的”,比如表单提交后的成功提示、表格内异步加载的状态、消息通知的弹出。这类内容必须通过aria-live区域播报。
但aria-live也是最容易被用错的地方。很多组件库写消息通知时,用 v-for 渲染多条消息,每条消息一个role="alert",结果读屏器在我处理表单时不断打断我,播报好几条“已删除”“已保存”,体验非常吵。
正确的做法是区分场景:
- 普通、非阻塞的提示,用
role="status",它隐含aria-live="polite",读屏器会等当前内容播完再播。 - 阻塞性错误,比如表单校验失败,用
role="alert",它隐含aria-live="assertive",会立刻打断当前播报。
还有一条实践细节:不要把aria-live区域和消息列表直接绑定在一起频繁增删节点。更好的做法是维护一个固定的 live 区域,只更新其中的文本内容。读屏器对连续的 DOM 增删非常敏感,反复插入删除会让它重复播报。
组件库内部可以抽象一个useLiveRegion组合式函数,内部维护一个单独挂在 document 里的容器,所有组件的提示都通过这个统一出口播报。这样既能避免全页面散落一堆 live 区域,也方便调试。
5. 语言切换与无障碍的交叉点:让读屏器跟得上
5.1 组件内部文案变了,html 的 lang 没变,读屏器就“跑偏”
这是国际化与无障碍最容易被忽略的交叉点。读屏器会根据<html lang>属性,决定用哪套发音规则来朗读页面。用户的业务应用原本是lang="zh-CN",组件库的文案切成英文后,界面上会出现“共 120 条”变成“120 items”的情况,但<html>的lang属性还是中文。读屏器就会尝试用中文发音规则去读英文内容,听起来像在用拼音读英语单词,非常不自然。
组件库本身不应该直接修改<html lang>——那是业务项目的领地,你改了属于越权。但组件库可以在切换语言时,通过ConfigProvider把lang属性写到包裹节点上,至少保证组件子树内的朗读发音是正确的:
<template> <div :dir="direction" :lang="lang"> <slot /> </div> </template>同时,组件库也可以在provideLocale检测到语言切换时,输出一条 console 提示,提醒使用者同步更新<html lang>。这是文档和运行时提示双管齐下的做法。
5.2 aria-label 的本地化与自定义覆盖顺序
无障碍的另一个语言问题是aria-label。很多组件里的可访问名称必须走国际化,尤其是那些视觉上只有图标的控件。对话框的关闭按钮,视觉上是个 ×,读屏器用户需要听到“关闭”或“Close”。
在组件库内部,这类aria-label也应该走t函数。这样可以保证语言切换后,读屏器用户听到的文字也跟着切换。设计aria-label的优先级时,我建议遵循:用户通过 props 显式传入的 label > 用户语言包里覆盖的 label > 组件库内置默认 label。
举个例子,对话框关闭按钮的优先级实现:
<button :aria-label="closeLabel || t('el.dialog.closeLabel', '关闭')" @click="emit('close')" > <x-icon /> </button>这里closeLabel是 props 传入的自定义值,t()里的第二个参数是兜底默认值。组件库内置默认语言包里如果没有对应文案,就会直接显示兜底值,不会出现空标签。
5.3 日期与数字的读屏体验:给鼠标用户看的,和给读屏器听得分开
日期面板里的日期格,视觉上通常只显示一个数字“1”“2”“3”。读屏器用户如果只听到“一”“二”“三”,根本不知道这是哪个月的哪一天。组件库需要给每个日期格提供更完整的语义标签:
<td role="gridcell" :aria-label="getDateAriaLabel(day)" :aria-selected="isSelected" > <span>{{ day }}</span> </td>getDateAriaLabel在中文语言包里可以生成“2024年3月1日,星期五”,英文包生成“Friday, March 1, 2024”。这里同样是用Intl.DateTimeFormat(lang, { dateStyle: 'full' }).format(date)实现,不需要手工拼。
分页组件同理。视觉上页码是“1”,读屏器应该听到“第 1 页”,当前页还要额外加上“当前页”。实现上也是给页码按钮动态生成aria-label,而不是让读屏器逐字读一个孤零零的数字。
这些细节都做完之后,国际化和无障碍才真正在组件层面合流了——语言切换的同时,键盘焦点、读屏器朗读、日期数字格式化全部跟得上。做到这一步,组件库才能算真正对“不同语言、不同设备、不同用户”都可用。
6. 把国际化与无障碍写进测试,别靠“上线后再改”
6.1 jest-axe 接入组件单测的最小配置
国际化与无障碍这两类问题,靠人工 review 基本测不全,必须写进自动化测试。我用 jest-axe 在单测里跑无障碍扫描,接入成本很低。
npm i -D jest-axe axe-core在测试文件里:
import { mount } from '@vue/test-utils' import { axe, toHaveNoViolations } from 'jest-axe' import Pagination from '../src/pagination.vue' expect.extend(toHaveNoViolations) describe('Pagination a11y', () => { it('should have no axe violations', async () => { const wrapper = mount(Pagination, { attachTo: document.body, props: { total: 120, currentPage: 2, lang: 'zh-cn' } }) const results = await axe(wrapper.element) expect(results).toHaveNoViolations() wrapper.unmount() }) })有两个容易被忽略的点:
第一,mount 时必须传attachTo: document.body。如果把组件挂载在不进入 document 的游离元素上,axe 检测不到很多与布局、可见性相关的问题,等于白测。
第二,wrapper.unmount()一定要调。测试文件里组件挂载多了,焦点陷阱、全局事件监听这些副作用会互相污染,一个用例的焦点没清理,下一个用例的记录就会被干扰。
6.2 真实键盘走查:用手过一遍比扫描器更能发现问题
自动化扫描能抓语义问题,但抓不到“焦点到底落在哪”“Tab 顺序是否合理”这类交互问题。我在组件库发布前,都会做一轮手工键盘走查,用一张检查清单逐项过:
| 场景 | 操作 | 预期行为 |
|---|---|---|
| 打开对话框 | Tab 到触发按钮,按 Enter | 焦点移到对话框内,焦点可见 |
| 对话框内循环 | 连续按 Tab | 焦点在对话框内循环,不出框 |
| 关闭对话框 | 按 Esc | 对话框关闭,焦点回到触发按钮 |
| 下拉选择器 | ArrowDown 展开 | 焦点移到第一项 |
| 表单校验失败 | 提交空表单 | 读屏器播报错误信息 |
| 消息提示出现 | 触发删除操作 | 读屏器礼貌播报,不打断长内容 |
严格来说,还应该在 NVDA(Windows)、VoiceOver(macOS)、TalkBack(Android)下各抽查一遍,因为不同读屏器对 ARIA 属性的支持程度有差异。组件库团队没有条件覆盖全部平台,至少 Windows 的 NVDA 和 macOS 的 VoiceOver 要各做一轮。
6.3 多语言回归测试清单
国际化测试不能只验证“切换语言后文案变了”,还要验证“切换后布局不崩、语义不丢”。我整理过一个比较简单但有效的回归清单:
- 语言包完整性:遍历 key,确认所有语言的翻译资源都不缺失。
- 文案插值正确性:
t('el.pagination.total', { total: 120 })在 zh-cn 输出“共 120 条”,在 en 输出“120 items”。 - 日期格式差异:用
Intl.DateTimeFormat在不同 lang 下输出不同的日/月/年顺序。 - RTL 方向:
dir属性正确输出,截图走查关键组件在 LTR / RTL 下的布局。 - 语言切换后 aria-label 更新:切语言后,关闭按钮的 aria-label 跟着变。
语言包完整性的检查可以写成一个通用测试:
import zhCN from '../locale/zh-cn' import enUS from '../locale/en-us' function flattenKeys(obj, prefix = '') { return Object.keys(obj).flatMap((key) => { const fullKey = prefix ? `${prefix}.${key}` : key return typeof obj[key] === 'object' ? flattenKeys(obj[key], fullKey) : [fullKey] }) } test('all language packs cover the same keys', () => { const zhKeys = flattenKeys(zhCN.el).sort() const enKeys = flattenKeys(enUS.el).sort() expect(enKeys).toEqual(expect.arrayContaining(zhKeys)) })另外,语言切换后布局不崩这件事,建议在 Playwright 里做整页扫描,用 axe-playwright 对每个组件的示例页跑一遍:
import { test, expect } from '@playwright/test' import AxeBuilder from '@axe-core/playwright' test('date-picker page should not have a11y violations', async ({ page }) => { await page.goto('/components/date-picker') const results = await new AxeBuilder({ page }).analyze() expect(results.violations).toEqual([]) })这套东西做下来,组件库的国际化与无障碍才不是“靠自觉”,而是“有保障”的质量门槛。
最后分享一个我自己的体会:这两件事本质上都不是“某个版本要完成的任务”,而是每写一个新组件时都要过的默认关卡。以前我把国际化想成“翻译”,后来才意识到它是“给不同世界的用户都留下入口”;无障碍也不是“给读屏器加几个属性”,而是让那些用键盘、用屏幕阅读器的人,和鼠标用户在同一条路上走得一样顺畅。组件库这种要被成千上万人复用的东西,有些债不还,代价只会越滚越大。