likes
comments
collection
share

如何让 Typescript 和 i18n 擦出火花💥

作者站长头像
站长
· 阅读数 67

这篇文章将介绍如何在项目中将 i18n 与 Typescript 融合,以便为开发者提供更好的开发体验。更好的开发体验指的是提供键入提示,自动补充,参数类型校验等等,避开低级的错误。

优化前: 如何让 Typescript 和 i18n 擦出火花💥 优化后: 如何让 Typescript 和 i18n 擦出火花💥

如果你很清楚如何实现,继续阅读下去可能不会有太大的帮助。

正文开始📝

回想一下在前端的项目中,i18n 通常出现的形式是存在多份语言映射表,同一个 key 在不同的语言文件中翻译为不同的内容:

// en.json
{
  "hello": "Hello"
}

// zh-CN.json
{
  "hello": "你好"
}

在需要展示文案的位置,使用 i18n 工具包提供的方法,传递 “key” 来指定需要展示的多语言文本。

<div>{t('hello')}</div>

React 中常用的 i18n 工具包有react-intlreact-i18next,大致使用方法差不多,这里使用的是 react-i18next 的写法。

问题出现🚧

这里的 t 方法可以接受任意参数,即使传入不存在的 key 值没有任何问题,甚至大部分 i18n 翻译库会自动 fallback 到直接将 key 展示出来。

如何让 Typescript 和 i18n 擦出火花💥

无疑这里会给低级错误留机会,在 Typescript 中,我们当然不想发生这种情况。

想要告诉 Typescript t 方法接受哪些翻译的 key 值很简单。我们可以将其全部枚举出来,使用 | 符号组合。并声明一个 I18nT 的函数类型。

type TranslateKeys = 'event.title' | 'event.description' | ...

type I18nT = {
   (key: TranslateKeys): string
}

应用这个类型和具体使用的工具有关,可能需要声明合并类型断言的形式来让程序理解 t 的类型。

// 在 i18next 项目中,t 方法的类型是 TFunciton,可以使用声明合并

declare module 'i18next' {
  interface TFunction extends I18nT {
  }
}

// 在我的项目中,t 方法是其他 js 模块提供的,可以使用类型断言然后 export 出去。
const i18n = createI18n()
export default i18n.t.bind(i18n) as I18nT

完成之后就可以在使用 t 方法的时候编辑器自动提醒的功能,同时输入不存在的 key 时,tslint, tsc 会校验通过。

如何让 Typescript 和 i18n 擦出火花💥

直接从 JSON 文件中读取类型📄

上面的例子中 TranslateKeys 是手动维护的,这铁定不行,每次 CURD 时都要修改成本太高了。其实 Typescript 支持直接 import json 文件。可以从 import 得到的对象上去获得参数类型。

type I18nStoreType = typeof import('../assets/en.json')

export type I18nT = {
   (key: keyof I18nStoreType): string
}

import 一个 json 文件的返回值是文件内容的 Object,使用 typeof 得到该对象类型,再使用 keyof 关键字指定 t 方法的 key 类型为该对象类型的 key。

tsconfig 相关的配置项是 resolveJsonModule,配置完之后通常需要重启 VSCode 才能生效。

// tsconfig.json
{
  "compilerOptions": {
    // ...
    "resolveJsonModule": true
  }
}

有了 I18nStoreType 之后还可以更进一步,就是让 Typescript 顺便告诉我们传了某个 key 值之后,对应的翻译内容是什么。

这样做的好处是,避免因为手误在错误的位置,输入一个存在的 key,这种情况下 Typescript 也帮不了你。而要确认是否有填错的唯一方法,就是把 key 复制出来,去翻译文件中确认翻译内容对不对。

Typescript 中我们可以对一个类型别名进行【键入】的操作,类似于 JS 中 object[key] 对象取值的操作,再结合泛型可以改写成以下这样。

export type I18nT<T> = {
   (key: T): I18nStoreType[T]
}

定义了一个泛型参数 <T>,Typescript 将会自动从 key 上去推断 T 所代表的类型,并将 T 类型应用到I18nStoreType[T] 上,就实现了传入指定 key 自动返回指定 value 的功能。

然而 现实是,通过 import 得到的所有 value 都是 stirng 类型,无法实现展示翻译内容的提示。

如何让 Typescript 和 i18n 擦出火花💥

why??? Typescript 对待 Object value 的态度是宽松的,因为它很可能会被再次赋予其他值,所以及时 JOSN 文件中写的是字符串字面量, Typescript 会将其推断为更宽松的 string 类型。

想要让 Typescript 理解这里的 value 是常量,可以使用 as constreadonly 关键字。

const foo = {
  val: 'val'
}
 
typeof foo.val // string
 
const foo = {
  val: 'val' as const
}
 
typeof foo.val // 字面量 'val'

那有没有办法对 import json 文件加上 as const 断言?查询相关,查找 github 的时候发现很早就有人提过这个 issue ,目前仍处于 Open 状态。

那只能自己手动生成一份了,可以编写一份 node js 代码,复制多语言 json 文件的内容,在前后分别插入 export default as const

const path = require('path')
const fs = require('fs')
 
const targetPath = path.join(
  process.cwd(),
  './assets/i18n.d.ts',
)
const sourcePath = path.join(process.cwd(), './assets/i18n/en.json')
 
const sourceContent = require(sourcePath)
fs.writeFileSync(
  targetPath,
  `export default ${JSON.stringify(sourceContent)} as const`,
)
 
console.log('✨ Generate i18n ts file successfully.')

原来直接从 JSON 文件获取类型也要改成从新增加的 d.ts 文件获取。

type I18nStoreType = typeof import('../assets/en.json')

export type I18nT<T> = {
   (key: T): I18nStoreType[T]
}

如何让 Typescript 和 i18n 擦出火花💥

到这里,我们实现了对 i18n Key 的校验,提示,以及展示该 key 对应的翻译内容。🚀🚀🚀

纯粹依赖 JSON 文件的定义,有较大的局限,例如有些翻译是带有插槽,可以传参数的。

const translactions = {
  'transactions.list.count': 'total {count} items'
}
 
t('transactions.list.count', {
  count: 10
})

如果可以在输入翻译 key 的时候,Typescript 可以帮我们校验参数名就好了,不用担心因为拼写错单词漏了这个显而易见的虫子🐛。(只是举个例子,实际上单词拼写错误的问题安装 code-spell-checker 插件就可以彻底解决。

但单词是正确,只是和翻译需要的参数不一样这种场景还是很有必要覆盖到的。接下来将通过编写一个 Node 小工具,自动去解析参数并生成 Typescript i18n 的声明文件。

编写工具自动生成🔧

1. 从模板字符串中解析参数

翻译插槽通常都有固定模式,例子中使用的是尖括号 {} 来定义插槽。我们可以很容易地使用正则表达式将尖括号中的文本匹配出来。

export function getSlots(template: string, regexp: RegExp): string[] {
  const res: string[] = []

  while (true) {
    const matches = regexp.exec(template)
    if (!matches) {
      break
    }
    res.push(matches[1].trim())
  }

  return res
}

getSlots('from {min} to {max}', /{([\s\S]+?)}/g) // ['min', 'max']

这里使用了 RegExp.prototype.exec() 方法,这个方法在 global 状态下是有状态的,它将成功匹配后的位置记录在 lastIndex 属性中,基于这个特性可以遍历字符串中被尖括号包裹的内容。

2. Typescript 函数重载

我们最终的目的是在 t 方法中输入一个指定 key 时,Typescript 能给与提示,这里需要用到函数重载的知识点。Javascript 中并没有函数重载的概念,因为 JavaScript 中的变量本来就可以被赋予任意值,要实现根据不同参数类型返回不同的值通常是判断 arguments 的长度,typeof 判断参数运行时类型。 在 Typescript 中支持函数重载,可以声明同一个名字的方法多次(不同的参数类型)。

type sort = {
  (entities: number[]): number[];
  (entities: string[]): string[];
  (entities: any[]): any[];
}

上述例子中,我们声明了具有两个重载的 sort 方法,告诉 Typescript 当参数类型为 number[] 时,返回值类型也是 number[],参数类型为string[]时,返回结果也是 string[]。 使用函数重载时最后一行需要是可以兼容以上所有类型的定义,例如上例子中的 entities: any[],不过并不意味着可以给 entities 传任意参数,比如 sort(['foo', 0]) 是会报异常的。 明确下最终需要生成的文件格式,剩下的工作就是遍历 JSON 文件,拼接字符串并最终保存成 d.ts 文件。

export type I18nKey = keyof typeof import('./en.json')

export type I18nT = {
  (key: 'common.action.collect'): 'Collect'

  (
    key: 'withdraw.tips.amount_limit',
    params: {
      min: any
      max: any
    },
  ): 'Please enter amount between {min} and {max}'

  // ...

  (key: I18nKey): string
}
3. Prettier

为了保持项目整体的代码风格统一,基本上每个项目都会加入 prettier 工具来自动格式化代码。但每个项目的风格可能做不到完全一致,为了让自动生成的文件可以支持更多项目,需要给它也加上 prettier 格式化。

import * as prettier from 'prettier'
const prettierOptions = await prettier.resolveConfig(sourcePath)

fs.writeFileSync(
  targetPath,
  prettier.format(
    `
    // 字符串拼接
    `,
    {
      ...prettierOptions,
      parser: 'typescript',
    },
  ),
)

prettier resolveConfig 方法将会从 sourcePath 开始一层层文件目录往上查找配置,找到了之后配置内容返回。用的时候就是将配置项传给 format 方法,根据文件内容指定 parser。 在字符串拼接过程中还有一个值得注意的点,是关于引号的问题,最初在拼接的时候,我直接使用但引号来包裹 i18n key, value。

export function getOverlapFunctionDeclaration(
  i18nKey: string,
  value: string,
  params?: string[],
) {
  if (params && params.length) {
    return `
      (key: '${i18nKey}', params: {
        ${params.map((key: string) => `'${key}': any`)}
      }): '${value}'
    `
  }

  return `
    (key: '${i18nKey}'): '${value}'
  `
}

然而当这些字符串本身包含单引号时(例如 I'm {name}) 会导致 prettier 格式化出错,因为这不是一个合法的 ts 文件。处理方法是给需要给所有输入文本的位置都加上替换将单引'号替换成字符串 \'

 export function replaceQuotes(str: string) {
   return str.replace(/'/g, '\\\'')
 }
4. 封装成 npm package

最后,可以给程序加上可配置参数(例如插槽的正则,输入 JSON 文件的位置,输出的方法类型名字等等),加上单元测试,写好使用文档,就可以发布到 npm 上供其他人使用了。(不是本文的重点)。 为了在每次翻译更新时重新生成声明文件,可以使用 npm post[script] 的写法,例如在我的项目中每次执行 transify 会从远端重新拉取翻译内容存成 json 文件,于是我加上 posttransify script,这样每次拉到翻译后都会重新生成我的 d.ts 声明文件。

"scripts": {
  "transify": "...",
  "posttransify": "transify-ts --sourcePath=./assets/strings/i18n/en.json",
}

成果🎉

最终成果是我们应用所学知识,让 Typescript 和 i18n 完美地结合在一起。Happy Coding 🎉🎉🎉 如何让 Typescript 和 i18n 擦出火花💥

转载自:https://juejin.cn/post/6898133050224771079
评论
请登录