国际化探索:提升开发体验与灵活性

在上一篇文章中,我们探讨了通过颗粒化方案实现平台的国际化改造。虽然该方案在减少语言包体积方面表现出色,但对开发者的友好度欠佳。

国际化探索:颗粒化方案

本文将介绍一个另一种方案,着重提升开发体验,同时保持语言包体积的优化。

优化方案

该方案在第一版的基础上进行了优化,主要改进如下:

  1. 单语言加载:只加载当前语言所需的语言包,减少不必要的加载。
  2. namespace 拆分与路由拆分:根据不同的命名空间和路由,对语言包进行拆分,进一步减小单个语言包的体积。
  3. 提高开发体验:引入 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 拆分(路由拆分)

通过 loadNSunloadNS 方法,可以动态加载不同路由下的语言包内容。

同时也可以设置如 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 函数 的内部的逻辑:

  1. 转为数组

    因为其参数既支持数组也支持字符串,所以统一转为数组进行处理。

  2. 找到每一个标识对应的翻译,并进行拼接。

  3. 翻译参数处理

    t 函数支持匿名参数、命名参数以及默认值等内容,来动态的返回其翻译。

  4. 返回最终翻译

使用

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
  }
}

类型提示

通过 NestedTranslationPath 类型,实现对翻译键的强校验和智能提示:

语言包强校验

在一开始 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 函数路径提示

需要从两个方面来获取:

  1. 语言包声明上

回头看一下 I18nService 的声明:

typescript 复制代码
class I18nService<T extends Record<string, NestedTranslation<string>>> {}

用到了 NestedTranslation。它会解析所有的语言包得到所有的 key。

  1. 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 体积。

相关推荐
大聪明了4 分钟前
Nuxt3 使用 ElementUI Plus报错问题
前端
Ama_tor10 分钟前
网页制作16-Javascipt时间特效の设置D-DAY倒计时
前端·javascript·html
小天努力学java21 分钟前
【软考-架构】11.3、设计模式-新
设计模式·架构
几何心凉22 分钟前
两款好用的工具,大模型训练事半功倍.....
前端
Dontla1 小时前
黑马node.js教程(nodejs教程)——AJAX-Day01-04.案例_地区查询——查询某个省某个城市所有地区(代码示例)
前端·ajax·node.js
威哥爱编程1 小时前
vue2和vue3的响应式原理有何不同?
前端·vue.js
呆呆的猫1 小时前
【前端】Vue3 + AntdVue + Ts + Vite4 + pnpm + Pinia 实战
前端
qq_456001651 小时前
30、Vuex 为啥可以进行缓存处理
前端
浪裡遊1 小时前
Nginx快速上手
运维·前端·后端·nginx
jason_yang1 小时前
Clean Code与代码重构
设计模式·架构·代码规范