如何让 Typescript 和 i18n 擦出火花?

时间:2021-2-20 作者:admin

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

优化前:

正文开始?

回想一下在前端的项目中,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 中,我们当然不想发生这种情况。

想要告诉 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 还不太熟悉的同学可以看看我的上一篇文章结合实例学习 Typescript。接下来的内容将介绍如何继续完善优化这个功能。

直接从 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 类型,无法实现展示翻译内容的提示。

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

到这里,我们实现了对 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 ???

声明:本文内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎进行举报,并提供相关证据,工作人员会在5个工作日内联系你,一经查实,本站将立刻删除涉嫌侵权内容。