
组件库和业务项目的节奏很不一样。业务项目今天写的代码明天上线就能看到效果组件库写的东西可能要到几个月后某个不认识的开发者在另一个项目里接入才会真正被用户用到。所以组件库里最容易出问题的恰恰是那些“开发验收时看不见、一接入真实场景就跑出来”的隐藏需求——国际化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请选择日期 / 开始日期 / 结束日期 / 今天是占位符和按钮 titleEmpty暂无数据 / 暂无搜索结果是空状态太容易被忽略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 模式下提供全局$tcomposition 模式下用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。一个足够用的实现不需要引入 Lodashfunction 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 :langlocale :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作为唯一的格式化出口。组件内在需要展示日期时一律根据当前语言构建 formatterfunction 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 布局切语言不只是切文案阿拉伯语、希伯来语这类从右往左读的语言不只是把文案翻译一下就行整个界面方向都要镜像翻转。分页的“下一页”箭头要朝左日期面板的左右切换箭头要换向对话框的关闭按钮也许要移动到左下角。我见过程序员把dirrtl加到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 到 1ARIA 是 1 到 100无障碍访问经常被误解成“不就是加几个 aria 属性吗”。这个理解太浅了。无障碍应该分三个层次语义、键盘、读屏器每一层都要单独设计。第一层是语义化。能用原生button就不要用div click。原生 button 自带 Enter 和 Space 触发 click 的键盘行为也天然能被读屏器识别为按钮。我用div实现一个自定义按钮时必须手动处理 role、tabindex、keydown 事件反而比原生 button 更麻烦。如果组件结构特殊必须使用非语义标签就要配合 role 属性做语义补充业务组件建议语义标记自定义按钮优先原生button否则 rolebutton tabindex0折叠面板头部button aria-expanded aria-controls弹窗roledialog aria-modaltrue aria-labelledby下拉选择rolelistbox roleoption简化方案消息提示rolestatus礼貌播报或 rolealert阻塞播报表格排序按钮button aria-sort4.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 渲染多条消息每条消息一个rolealert结果读屏器在我处理表单时不断打断我播报好几条“已删除”“已保存”体验非常吵。正确的做法是区分场景普通、非阻塞的提示用rolestatus它隐含aria-livepolite读屏器会等当前内容播完再播。阻塞性错误比如表单校验失败用rolealert它隐含aria-liveassertive会立刻打断当前播报。还有一条实践细节不要把aria-live区域和消息列表直接绑定在一起频繁增删节点。更好的做法是维护一个固定的 live 区域只更新其中的文本内容。读屏器对连续的 DOM 增删非常敏感反复插入删除会让它重复播报。组件库内部可以抽象一个useLiveRegion组合式函数内部维护一个单独挂在 document 里的容器所有组件的提示都通过这个统一出口播报。这样既能避免全页面散落一堆 live 区域也方便调试。5. 语言切换与无障碍的交叉点让读屏器跟得上5.1 组件内部文案变了html 的 lang 没变读屏器就“跑偏”这是国际化与无障碍最容易被忽略的交叉点。读屏器会根据html lang属性决定用哪套发音规则来朗读页面。用户的业务应用原本是langzh-CN组件库的文案切成英文后界面上会出现“共 120 条”变成“120 items”的情况但html的lang属性还是中文。读屏器就会尝试用中文发音规则去读英文内容听起来像在用拼音读英语单词非常不自然。组件库本身不应该直接修改html lang——那是业务项目的领地你改了属于越权。但组件库可以在切换语言时通过ConfigProvider把lang属性写到包裹节点上至少保证组件子树内的朗读发音是正确的template div :dirdirection :langlang 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-labelcloseLabel || t(el.dialog.closeLabel, 关闭) clickemit(close) x-icon / /button这里closeLabel是 props 传入的自定义值t()里的第二个参数是兜底默认值。组件库内置默认语言包里如果没有对应文案就会直接显示兜底值不会出现空标签。5.3 日期与数字的读屏体验给鼠标用户看的和给读屏器听得分开日期面板里的日期格视觉上通常只显示一个数字“1”“2”“3”。读屏器用户如果只听到“一”“二”“三”根本不知道这是哪个月的哪一天。组件库需要给每个日期格提供更完整的语义标签td rolegridcell :aria-labelgetDateAriaLabel(day) :aria-selectedisSelected span{{ day }}/span /tdgetDateAriaLabel在中文语言包里可以生成“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 展开焦点移到第一项表单校验失败提交空表单读屏器播报错误信息消息提示出现触发删除操作读屏器礼貌播报不打断长内容严格来说还应该在 NVDAWindows、VoiceOvermacOS、TalkBackAndroid下各抽查一遍因为不同读屏器对 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([]) })这套东西做下来组件库的国际化与无障碍才不是“靠自觉”而是“有保障”的质量门槛。最后分享一个我自己的体会这两件事本质上都不是“某个版本要完成的任务”而是每写一个新组件时都要过的默认关卡。以前我把国际化想成“翻译”后来才意识到它是“给不同世界的用户都留下入口”无障碍也不是“给读屏器加几个属性”而是让那些用键盘、用屏幕阅读器的人和鼠标用户在同一条路上走得一样顺畅。组件库这种要被成千上万人复用的东西有些债不还代价只会越滚越大。