在上一篇文章中,我们探讨了通过颗粒化方案实现平台的国际化改造。虽然该方案在减少语言包体积方面表现出色,但对开发者的友好度欠佳。
本文将介绍一个另一种方案,着重提升开发体验,同时保持语言包体积的优化。
优化方案
该方案在第一版的基础上进行了优化,主要改进如下:
- 单语言加载:只加载当前语言所需的语言包,减少不必要的加载。
- namespace 拆分与路由拆分:根据不同的命名空间和路由,对语言包进行拆分,进一步减小单个语言包的体积。
- 提高开发体验:引入 TypeScript 智能提示,提升开发效率和体验。
i18n 实现
以下是 I18nService
的部分实现:
typescript
type NestedTranslation<T extends string> = {
[K in T]: string | NestedTranslation<T>
}
export type InheritTranslation<T extends NestedTranslation<string>> = {
[K in keyof T]: T[K] extends object ? InheritTranslation<T[K]> : string
}
type Language = 'zh' | 'en'
export class I18nService<T extends Record<string, NestedTranslation<string>>> {
public language: Language
private namespaces: Set<string>
private resources: Map<string, Record<Language, any>>
private defaultLanguage: Language = 'zh'
constructor({
defaultNS,
defaultLanguage,
}: {
defaultNS?: string
defaultLanguage?: Language
} = {}) {
if (defaultLanguage) this.defaultLanguage = defaultLanguage
if (defaultNS) this.currentNS = defaultNS
this.language = this.detectLanguage()
this.namespaces = new Set([])
this.resources = new Map()
}
private detectLanguage(): Language {
const storedLang = localStorage.getItem('language')
if (storedLang && ['zh', 'en'].includes(storedLang)) {
return storedLang as Language
}
// 2. 查看用户的浏览器语言
const userLang = navigator.language || navigator.userLanguage
if (userLang.startsWith('zh')) return 'zh'
if (userLang.startsWith('en')) return 'en'
// 3. 默认语言
return this.defaultLanguage
}
// ...
}
看一下 I18nService 内部的实现,最基础的内容就是语言的检测,如果需要通过其他参数可以自行处理。
NS 拆分(路由拆分)
通过 loadNS
和 unloadNS
方法,可以动态加载不同路由下的语言包内容。
同时也可以设置如 common
等通用语言包来进行全局使用,此功能的想法来此上一篇文章提及的 I18nProvider
。
typescript
/**
* 加载资源
* @param ns 命名空间
* @param resource 资源
*/
public async loadNS(ns: string, resource: Partial<Record<Language, any>>) {
if (this.namespaces.has(ns)) return
// 只加载当前语言资源
this.resources.set(ns, {
...(this.resources.get(ns) || {}),
[this.language]: resource[this.language],
})
this.namespaces.add(ns)
}
public async unloadNS(ns: string) {
this.resources.delete(ns)
this.namespaces.delete(ns)
}
public setNS<K extends keyof T & string>(ns: K) {
this.currentNS = ns
}
同时也只会加载当前检测到的语言对应的语言包,能够进一步节省其语言包使用空间。
单语言加载
但其实还有另一种实现,这里并没有使用,通过资源路径的方式取加载,这样更方便,只是是异步的。
我们可以将 i18n 文件统一放在 public
目录下,然后进行统一访问,就能实现如下功能:
typescript
public async loadNS(ns: string, pathTemplate?: string) {
if (this.namespaces.has(ns)) return
const path = pathTemplate
? pathTemplate.replace('{lang}', this.language)
: `/i18n/${this.language}/${ns}`
const resource = await import(/* @vite-ignore */ path)
this.resources.set(ns, {
...(this.resources.get(ns) || {}),
[this.language]: resource[this.language],
})
this.namespaces.add(ns)
}
// 使用
loadNS('test', '/i18n/{lang}/test')
这样就会去找对应语言包目录下的 ns,实现更加精准的加载。
翻译函数
优化后的 t
函数支持参数传递、函数形式,并增强了 TypeScript 类型支持:
typescript
/**
* 翻译
* @param key 翻译键
* @param args 参数
* @returns 翻译结果
*/
public t<K extends keyof T & string, P extends Path<T[K]>>(
key: `${K}:${P}` | P | Array<`${K}:${P}` | P>,
...args: any[]
): string {
if (!Array.isArray(key)) {
key = [key]
}
// 1. 找到对应 translation
let rawValue: string | undefined | ((...args: any[]) => string)
for (const k of key) {
const [ns, path] = this.parseKey(k)
const resource = this.resources.get(ns ?? this.currentNS)
if (!resource) {
console.error(`[i18n] Namespace ${ns} not loaded for key "${k}"`)
return
}
let raw = path.reduce((obj, k) => obj?.[k], resource[this.language])
if (rawValue === undefined) {
rawValue = raw
} else {
rawValue += raw
}
}
// 2. 处理参数
let result = typeof rawValue === 'function' ? rawValue(...args) : rawValue
if (typeof result === 'string') {
result = this.processParameters(result, args)
} else if (typeof result === 'undefined') {
return
}
return result
}
private parseKey(key: string): [string | undefined, string[]] {
if (key.includes(':')) {
const [ns, path] = key.split(':')
return [ns, path.split('.')]
}
return [undefined, key.split('.')]
}
private processParameters(result: string, args: any[]): string {
const params = args[0]
const isObjectParams = params && typeof params === 'object' && !Array.isArray(params)
if (args.length > 0 && !isObjectParams) {
let i = 0
result = result.replace(/{}/g, () => args[i++] ?? '')
} else if (params && typeof params === 'object') {
result = Object.entries(params).reduce(
(str, [k, v]) => str.replace(new RegExp(`{{${k}}}`, 'g'), String(v)),
result,
)
}
return result
}
看一下 t 函数
的内部的逻辑:
-
转为数组
因为其参数既支持数组也支持字符串,所以统一转为数组进行处理。
-
找到每一个标识对应的翻译,并进行拼接。
-
翻译参数处理
t 函数支持匿名参数、命名参数以及默认值等内容,来动态的返回其翻译。
-
返回最终翻译
使用
typescript
// 1. 直接使用(设置命名空间)
i18n.setNamespace('test')
t('title')
// 2. 使用命名空间
t('test:title')
// 3. 携带参数(name1: 'test - {}')
t('test:name1', applicationTitle)
// 4. 携带命名参数(name2: 'My Application - {{value}}')
t('test:name2', { value: applicationTitle })
// 5. 携带默认值
t('test:name3', { defaultValue: 'test - {value}', value: applicationTitle })
// 6. 数组
t(['test:name1', 'test:name2'])
通过案例,可以看到 t 函数的灵活,包括其提供的类型提示,很大程度上增强了开发体验。
缺失翻译处理
添加一个 handleMissingTranslation
来处理未命中的情况,来进行兜底处理。
typescript
export class I18nService<T extends Record<string, NestedTranslation<string>>> {
public t<K extends keyof T & string, P extends Path<T[K]>>(
key: `${K}:${P}` | P | Array<`${K}:${P}` | P>,
...args: any[]
): string {
if (!Array.isArray(key)) {
key = [key]
}
let rawValue: string | undefined | ((...args: any[]) => string)
for (const k of key) {
const [ns, path] = this.parseKey(k)
const resource = this.resources.get(ns ?? this.currentNS)
if (!resource) {
console.error(`[i18n] Namespace ${ns} not loaded for key "${k}"`)
// 缺失处理
return this.handleMissingTranslation(key[0], args[0])
}
let raw = path.reduce((obj, k) => obj?.[k], resource[this.language])
if (rawValue === undefined) {
rawValue = raw
} else {
rawValue += raw
}
}
let result = typeof rawValue === 'function' ? rawValue(...args) : rawValue
if (typeof result === 'string') {
result = this.processParameters(result, args)
} else if (typeof result === 'undefined') {
// 缺失处理
return this.handleMissingTranslation(key[0], args[0])
}
return result
}
private handleMissingTranslation(key: string, params: any = {}): string {
if (params.defaultValue) {
return this.processParameters(params.defaultValue, params)
}
if (this.missingTranslation) {
console.log('[i18n]', 'missingTranslation', key, params)
const customResult = this.missingTranslation(key, params)
return typeof customResult === 'string' ? customResult : key
}
return key
}
}
类型提示
通过 NestedTranslation
和 Path
类型,实现对翻译键的强校验和智能提示:
语言包强校验
在一开始 i18n 实现 里面用到了如下的类型。
typescript
type NestedTranslation<T extends string> = {
[K in T]: string | NestedTranslation<T>
}
export type InheritTranslation<T extends NestedTranslation<string>> = {
[K in keyof T]: T[K] extends object ? InheritTranslation<T[K]> : string
}
这里的用处是用于强校验不同语言包之间的 key 是否缺失。
如下所示:
typescript
const zh = {
button: {
add: '新增',
}
}
export type Translation = InheritTranslation<typeof zh>
const translation: Translation = {
button: {
// error: Property 'add' is missing in type ...
}
}
t 函数路径提示
需要从两个方面来获取:
- 语言包声明上
回头看一下 I18nService
的声明:
typescript
class I18nService<T extends Record<string, NestedTranslation<string>>> {}
用到了 NestedTranslation
。它会解析所有的语言包得到所有的 key。
- Path 拆解
t 函数
用到了 Path
typescript
type Path<T> = T extends object
? {
[K in keyof T]: `${K & string}${T[K] extends object ? `.${Path<T[K]>}` : ''}`
}[keyof T]
: never
public t<K extends keyof T & string, P extends Path<T[K]>>(
key: `${K}:${P}` | P | Array<`${K}:${P}` | P>,
...args: any[]
): string
这里的 K 是继承 T 的,而 T 就是所有语言包对象的 key 值,P 则是根据对应的 NS 得到最终是哪个语言包下面的 key 值来进行提示。
两者进行结合,最终得到了完整的类型提示:
使用
接下来,来从一个案例具体看看其用法。
语言包定义
首先我们定义一个通用的语言包,其 NS 为 common。
其目录结构如下:
typescript
|- common
|-|- en.ts # 语言包
|-|- index.ts # 加载入口
|-|- zh.ts # 语言包
语言包内容如下:
zh.ts
typescript
const translation = {
button: {
add: '新增',
delete: '删除',
edit: '编辑',
view: '查看',
}
}
en.ts
typescript
import type { Translation } from '.'
const translation: Translation = {
button: {
add: 'Add',
delete: 'Delete',
edit: 'Edit',
view: 'View',
}
}
这里可以注意到 英文语言包里的 翻译内容继承了一个类型。
这是翻译类型校验,以一个语言包为标准,确保其他语言包不会缺少字段进行校验。
其类型写在了 index.ts
文件中。
index.ts
typescript
import { i18n, type InheritTranslation } from '@/utils/i18n'
// 导出翻译文件
import zh from './zh'
import en from './en'
/** 翻译类型校验 */
export type Translation = InheritTranslation<typeof zh>
export const loadI18n = () => {
/** 加载翻译文件 */
i18n.loadNS('common', {
zh,
en,
})
/** 设置命名空间 */
i18n.setNS('common')
}
export const unloadI18n = () => {
/** 卸载翻译文件 */
i18n.unloadNS('common')
}
通过 loadNS
来加载语言包。
到这里为止是一套 NS 实现的组合包。
如果是异步加载
public/i18n
的情况,将其根据 lang 分别放置即可。这里不再做演示。ps: 如果使用这种方式的话,要注意应使用 js 或 json。
再来看一下 i18n 的入口。
typescript
import { I18nService } from './i18n'
import { loadI18n, type Translation as CommonTranslation } from './common'
export * from './i18n'
export const i18n: I18nService<{
common: CommonTranslation
}> = new I18nService()
export const t = i18n.t.bind(i18n)
// 加载公共翻译
loadI18n()
这里注意一下 I18nService<{ common: CommonTranslation }>
,这里是实现类型提示的关键,和上面的翻译类型校验同理,获取其中的所有 key,为后续的 t 函数提示做铺垫。
组件中使用
这里注意一个点,在路由切分或者其他拆分的情况下,记得将导入
import './i18n'
放在顶部,以防止其导入的组件已经用到了其 NS。
typescript
// import './i18n'
import { t } from '@/i18n';
const MyComponent = () => {
return (
<Button type='primary'>
{t('button.add')}
</Button>
);
};
第二版方案在开发体验上进行了显著优化,通过单语言加载、namespace 拆分和 TypeScript 智能提示,提高了开发效率和代码可维护性。同时,基于路由拆分和语言包拆分,分散其整体 i18n 体积。