组件库国际化体系复盘:RTL 布局与多语言文案管理

组件库国际化体系复盘:RTL 布局与多语言文案管理 组件库国际化体系复盘RTL 布局与多语言文案管理一、国际化不是翻译从把中文改成英文到体系化多语言支持的认知升级组件库做国际化最常见的一个认知误区是把它等同于翻译——把组件里的中文文案替换成英文给每个文案加一个 key用一个 i18n 库做查找替换。这种思路的结果是一个表面上国际化了的组件库放到阿拉伯语RTL 从右到左布局环境下所有的箭头、布局、阴影方向全部反了放到日语环境下换行规则导致文字断在奇怪的位置放到德语环境下一个按钮的文案长度是中文的三倍直接溢出容器。真正的国际化包含三个维度的系统性工作文案管理多语言的提取、翻译、回退、质量控制。不是翻译完就完了而是要考虑术语一致性、地区差异zh-CN vs zh-TW、以及新文案的增量翻译流程。布局适配不仅是从左到右LTR和从右到左RTL的镜像翻转还包括排版方向、图标方向、动画方向、阴影偏移的全面适配。格式差异日期、时间、数字、货币、单位的本地化格式化规则。日期格式化尤其复杂——2024/01/02在中国是 2024 年 1 月 2 日在美国可能是 2024 年 2 月 1 日。二、文案管理体系从 JSON 文件到类型安全的翻译系统2.1 文案 key 的命名规范组件库的文案需要同时考虑开发时的可维护性和运行时的性能。key 的命名遵循三层结构{组件名}.{子模块}.{语义}。// ✅ 好的 key 命名 const keys { button.confirm: 确认, button.cancel: 取消, table.empty.description: 暂无数据请尝试调整筛选条件, datepicker.month.january: 一月, upload.error.size_exceeded: 文件大小不能超过 {limit}MB, }; // ❌ 差的 key 命名——没有语义、无法定位 const badKeys { msg_001: 确认, label_23: 取消, };2.2 类型安全的 i18n 函数核心 i18n 函数需要在 TypeScript 层面保证类型安全编译器能校验 key 是否存在、参数是否匹配。/** * 类型安全的国际化文案管理器 * key 必须存在于已定义的翻译对象中参数必须匹配 */ type DefaultMessages typeof import(./locales/zh-CN).default; /** 提取所有文案 key 的联合类型 */ type MessageKey keyof DefaultMessages; /** 提取指定 key 需要接收的参数类型 */ type MessageParamsK extends MessageKey DefaultMessages[K] extends ${infer _}{${infer Param}}${infer _} ? RecordParam, string | number : undefined; class I18nManager { private locale: string; private messages: PartialRecordMessageKey, string {}; private fallbackMessages: RecordMessageKey, string; constructor(locale: string, fallbackMessages: RecordMessageKey, string) { this.locale locale; this.fallbackMessages fallbackMessages; } /** * 加载指定语言的翻译文件 */ async loadLocale(locale: string): Promiseboolean { try { // 动态导入翻译文件 const module await import(./locales/${locale}.ts); this.messages module.default; this.locale locale; return true; } catch { console.warn([i18n] 语言包 ${locale} 不存在使用回退语言); this.messages {}; return false; } } /** * 获取翻译文案 * param key 文案 key * param params 参数替换对象 */ tK extends MessageKey( key: K, ...params: MessageParamsK extends undefined ? [] : [MessageParamsK] ): string { const template this.messages[key] ?? this.fallbackMessages[key]; if (!template) { console.warn([i18n] 缺少翻译 key: ${String(key)}); return String(key); } const args params[0] as Recordstring, string | number | undefined; if (!args) return template; // 参数替换将 {name} 替换为实际值 return template.replace( /\{(\w)\}/g, (_, param: string) String(args[param] ?? {${param}}) ); } /** * 检查当前语言的翻译覆盖率 */ getCoverage(): { translated: number; missing: number; total: number } { const total Object.keys(this.fallbackMessages).length; const translated Object.keys(this.messages).length; return { translated, missing: total - translated, total }; } } // 使用示例 const zhCN: RecordMessageKey, string { button.confirm: 确认, button.cancel: 取消, upload.error.size_exceeded: 文件大小不能超过 {limit}MB, upload.error.type_unsupported: 不支持的文件类型{type}, table.empty.description: 暂无数据请尝试调整筛选条件, pagination.total: 共 {total} 条, pagination.page: 第 {current} / {total} 页, } as any; const i18n new I18nManager(zh-CN, zhCN); // TypeScript 编译期检查key 不存在时报错 i18n.t(button.confirm); // ✅ 确认 i18n.t(upload.error.size_exceeded, { limit: 5 }); // ✅ 文件大小不能超过 5MB // i18n.t(button.nonexistent); // ❌ 编译错误key 不存在2.3 文案回退策略当某个语言缺少某个 key 的翻译时回退策略按优先级目标语言的翻译如en-US中有button.confirm→ 使用目标语言的通用翻译如en-US中没有检查en组件库默认语言zh-CN的文案显示 key 本身作为最后兜底至少开发者能看到缺失提示三、RTL 布局CSS 逻辑属性与方向感知组件3.1 正确做法使用 CSS 逻辑属性传统的 CSS 布局使用物理方向left/right/margin-left/padding-right在 RTL 环境下需要手动翻转。这是国际化中重构成本最高的部分。正确的做法是从一开始就使用CSS 逻辑属性/* ❌ 物理方向RTL 下需全部手动覆盖 */ .button { margin-left: 8px; padding-right: 12px; text-align: left; border-left: 1px solid #eee; } /* ✅ 逻辑属性RTL 下自动翻转 */ .button { margin-inline-start: 8px; /* 代替 margin-left */ padding-inline-end: 12px; /* 代替 padding-right */ text-align: start; /* 代替 text-align: left */ border-inline-start: 1px solid #eee; /* 代替 border-left */ }3.2 组件中的 RTL 感知组件库需要在 React/Vue 组件中感知当前的文字方向并做出适配。核心是通过一个 context 全局传递direction值。/** * RTL 方向管理器 * 通过 Context 向所有子组件传递当前文字方向 * 子组件根据 direction 调整布局、图标方向、动画方向 */ type Direction ltr | rtl; interface RTLContextValue { direction: Direction; isRTL: boolean; } const RTLContext createContextRTLContextValue({ direction: ltr, isRTL: false, }); /** * 使用 RTL 方向的 Hook * 返回当前方向状态和常用的方向感知工具函数 */ function useRTL() { const context useContext(RTLContext); return { direction: context.direction, isRTL: context.isRTL, /** 根据方向返回对应的值 */ swap: T(ltrValue: T, rtlValue: T): T { return context.isRTL ? rtlValue : ltrValue; }, /** 根据方向返回正确的 CSS inline 起始值 */ getInlineStart: (value: number | string): React.CSSProperties { return context.isRTL ? { right: value } : { left: value }; }, /** 根据方向翻转图标 */ getTransform: (): string { return context.isRTL ? scaleX(-1) : none; }, }; } // RTL Provider 组件 function RTLProvider({ locale, children, }: { locale: string; children: ReactNode; }) { const direction: Direction RTL_LOCALES.includes(locale) ? rtl : ltr; const value { direction, isRTL: direction rtl }; useEffect(() { // 设置 HTML 根元素的 dir 属性 document.documentElement.dir direction; document.documentElement.lang locale; }, [direction, locale]); return RTLContext.Provider value{value}{children}/RTLContext.Provider; } /** 使用 RTL 布局的语言列表 */ const RTL_LOCALES [ ar, arc, dv, fa, ha, he, khw, ks, ku, ps, ur, yi, ]; // 使用示例一个支持 RTL 的箭头图标组件 function ArrowIcon() { const { getTransform } useRTL(); return ( svg style{{ transform: getTransform(), transition: transform 0.2s }} path dM10 6L4 12l6 6 / /svg ); }3.3 需要特别注意适配的点以下 UI 元素在 RTL 环境下容易出问题输入框图标位置搜索框中的搜索图标和清除图标在 RTL 下需要交换位置。步骤条方向从左到右的步骤条在 RTL 下需要变为从右到左。进度条增长方向transform-origin需要调整。下拉菜单弹出方向Popover的placement从bottom-start变为bottom-end。Carousel 滑动方向默认从左滑到右的轮播RTL 下从右滑到左。Toast 通知的滑入方向从右侧滑入变为从左侧滑入。表格排序箭头升序/降序箭头的指向含义不变但图标方向需要翻转。useRTL()Hook 的swap()方法就是为这些场景设计的// 步骤条方向 const stepDirection swap(ltr, rtl); // Popover 弹出位置 const placement swap(bottom-start, bottom-end); // Toast 出现的水平位置 const toastPosition swap({ right: 20 }, { left: 20 });四、格式本地化Intl API 的统一封装4.1 日期与时间格式化日期的格式化规则在不同地区差异巨大。new Date().toLocaleDateString()在中国返回2024/7/1在美国返回7/1/2024在德国返回1.7.2024。组件库需要对这些格式差异做统一封装。/** * 格式提供器 * 基于 Intl API 封装日期、数字、货币的本地化格式化 */ class FormatProvider { private locale: string; constructor(locale: string) { this.locale locale; } /** * 格式化日期 * param date 日期对象或时间戳 * param preset 预设格式short/medium/long/full */ formatDate( date: Date | number, preset: short | medium | long | full medium ): string { try { const options: Intl.DateTimeFormatOptions this.getDateOptions(preset); return new Intl.DateTimeFormat(this.locale, options).format(date); } catch { // 降级locale 不被支持时使用 en-US return new Intl.DateTimeFormat(en-US).format(date); } } /** * 格式化相对时间如3 分钟前 */ formatRelativeTime(date: Date | number): string { const now Date.now(); const target typeof date number ? date : date.getTime(); const diff now - target; const units: [number, Intl.RelativeTimeFormatUnit][] [ [60_000, minute], [3_600_000, hour], [86_400_000, day], [604_800_000, week], [2_592_000_000, month], [31_536_000_000, year], ]; for (const [ms, unit] of units) { if (Math.abs(diff) ms * 2 || unit year) { const value Math.round(-diff / ms); try { return new Intl.RelativeTimeFormat(this.locale, { numeric: auto, }).format(value, unit); } catch { return this.fallbackRelativeTime(diff); } } } return ; } /** * 格式化数字千分位等 */ formatNumber(value: number, decimals 0): string { try { return new Intl.NumberFormat(this.locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, }).format(value); } catch { return value.toLocaleString(); } } /** * 格式化文件大小 */ formatFileSize(bytes: number): string { const units [B, KB, MB, GB, TB]; let size bytes; let unitIndex 0; while (size 1024 unitIndex units.length - 1) { size / 1024; unitIndex; } return ${this.formatNumber(size, unitIndex 0 ? 0 : 1)} ${units[unitIndex]}; } private getDateOptions( preset: string ): Intl.DateTimeFormatOptions { const presets: Recordstring, Intl.DateTimeFormatOptions { short: { year: numeric, month: 2-digit, day: 2-digit }, medium: { year: numeric, month: short, day: numeric }, long: { year: numeric, month: long, day: numeric, weekday: long }, full: { dateStyle: full, timeStyle: medium }, }; return presets[preset] ?? presets.medium; } private fallbackRelativeTime(diff: number): string { const seconds Math.abs(diff) / 1000; if (seconds 60) return just now; if (seconds 3600) return ${Math.floor(seconds / 60)}m ago; if (seconds 86400) return ${Math.floor(seconds / 3600)}h ago; return ${Math.floor(seconds / 86400)}d ago; } }4.2 语言切换的运行时热更新组件库的国际化必须在运行时支持语言切换而不是在构建时锁定。这要求动态导入语言包不同语言的翻译文件按需加载不打包在初始 bundle 中。所有组件重新渲染语言切换后所有使用t()的组件需要同步更新。通过 React Context 的 value 变化驱动。Intl 缓存清除部分浏览器会缓存Intl.DateTimeFormat的实例切换语言时需要强制重建。五、总结组件库国际化的核心是三个体系的建设文案管理、布局适配、格式本地化。文案管理的关键是类型安全。通过 TypeScript 的 key 联合类型和参数推导在编译阶段就能发现缺失的翻译和参数不匹配问题。构建时通过 AST 扫描所有t()调用生成类型文件CI 中检查未翻译的 key。RTL 布局的核心是使用 CSS 逻辑属性margin-inline-start/padding-inline-end/text-align: start替代物理属性left/right/margin-left。在组件设计中通过useRTL()Hook 的swap()方法处理所有方向敏感的 UI 逻辑避免遍布if (isRTL)的散弹式代码。格式本地化依赖IntlAPI 的统一封装包括日期DateTimeFormat、相对时间RelativeTimeFormat、数字NumberFormat三类。封装层同时提供降级策略locale 不被支持时回退到en-US。落地路线先从文案管理切入成本最低、收益最直观然后在新增组件中强制使用 CSS 逻辑属性不修改已有组件最后以渐进方式适配 RTL 支持先在组件库 demo 中开启 RTL 测试环境逐个组件修复。