0 插件实现 DX 🎨 体验极好的国际化方案!附代码
欢迎大家关注公众号『
JavaScript与编程艺术
』高质量文章优先公众号发布。
产品需求
中英文文本切换能力以及数字、复数、日期的国际化。本文将探讨 react-i18n 和 react-intl 两个库如何实现我们的需求。
当然这只是我们的产品需求,技术侧我们有自己的追求,我们看看技术侧额外效果。
额外效果 🎨
目标:DX 体验增强,提高开发效率和可维护性,同时降低误写导致 bug。
市面上所有的国际化方案都有类似的通病“key不能提示手写容易出错”、“要查看翻译文本必须全局搜索或者切换到国际化文件”,面对这些痛点我们经常需要配置 VSCode 插件来完成这是一个很繁琐的过程,
如果无需 VSCode 插件直接通过 TS 类型实现,你就说 city 不 city。
先预览为快:
- Key 能自动提示
- Hover 能展示 key 对应的文本
- 插值变量也能提示:若翻译文本有动态插值比如:
"你好,字段{name}缺少{requirements}
,则当输入t('login.missingKey', { name: '密码', requirements: '特殊字符' })
能提示name
和requirements
,防止写错字段。
你是否怦然心动?我们一起来实现一遍。
实现
1. react-i18next 版本
无论从流行程度还是代码侵入性或模板代码的多少,我们应该优先选择 react-i18next。不过有特殊情况需要使用其他库,后文会讲到。
重点代码剖析
我们先实现第一个效果:key 提示
能提示所有 key 的关键在拿到翻译文件内的所有字段。TS 的 keyof
刚好派上用场。
type IKeys = keyof typeof EN_US | keyof typeof ZH_CN // 做 merge 防止有些 key 只有某种语言才有
比如,zh-cn.js 文件内容
export const ZH_CN = {
foo: 'hello',
bar: 'world'
} as const
则获取到的 IKeys
类型是 "foo" | "bar"
。
第二个效果:翻译文本显示
hover 能显示 key 对应的翻译。关键是 as const
,它能让值显示字面量而非 string。
export const ZH_CN = {
foo: 'hello',
bar: 'world'
-}
+} as const
我们来试试,未加 as const
ZH_CN 的类型是:
{
foo: string;
bar: string;
}
增加后变成:
{
readonly foo: "hello";
readonly bar: "world";
}
这就是反显翻译文本的关键。那么如何由 key
拿到对应的 value
?JS 使用点或方括号,TS 类似使用方括号:(typeof ZH_CN)[ID]
,typeof
是拿到整个翻译的类型你可以将其看做对象,[ID]
就是对其取值。这就是下面这段代码的含义,这样我们就实现了 hover 展示翻译文本的效果。
type ITranslatedText<ID extends IKeys> = (typeof ZH_CN)[ID]
不加
as const
也行,不过要使用.json
,但我偏好 ts 文件的灵活性,比如能加注释,无需繁琐的双引号。
- 如何拿到所有的插值变量。重点是 Template Literal Types +
infer
+递归
获取所有。
3.1 首先是获取字符串中大括号之间的字段
type ExtractKeys<
T extends string,
Keys extends any[] = [],
> = T extends `${string}{{${infer K}}}${infer Rest}` ? ExtractKeys<Rest, [...Keys, Trim<K>]> : Keys
我们测试下如果 type foo = ExtractKeys<'hello {{foo}} {{bar}}'>
则 foo
的类型是 ["foo", "bar"]
,我们成功把所有必填字段拿到了(Trim
就是将两侧空格去掉先了解下即可)。接下来是将其转换成 interface
或 record
,即 { foo: string; bar:string }
。
3.2 tuple
to record
接下来我们的目标是将 tuple 或 array ["foo", "bar"]
转成 { foo: string; bar: string }
。第一步是获取到数组的所有元素,重点来了,可以通过 Array[nubmer]
这种形式将 tuple 转成联合类型 value1 | value2 | ...
。
接下来是遍历元素生成 record
有三种方法,理解难易程度从低到高:
type ArrToInterface<A extends string[]> = { [P in A[number]]: string }
type ArrToInterface<A extends string[]> = Record<A[number], string>
type ArrToInterface<A extends string[], L extends string = A[number]> = { [P in L]: string }
至此我们就神奇地从一个毫不起眼的字符串 "{{ foo }} {{bar}}"
得到了类型 { foo: string; bar: string }
!
此部分代码:
/**
* @example
* type foo = ExtractKeys<'hello {{foo}} {{bar}}'>
* // foo is `["foo", "bar"]`
*/
type ExtractKeys<
T extends string,
Keys extends any[] = [],
> = T extends `${string}{{${infer K}}}${infer Rest}` ? ExtractKeys<Rest, [...Keys, Trim<K>]> : Keys
/**
* @example
* type foo = ArrToInterface<["foo", "bar"]>
* // foo is `{"foo": string, "bar": string}`
*/
type ArrToInterface<A extends string[]> = { [P in A[number]]: string }
/**
* @example
* type foo = Fields<'hello {{foo}} {{bar}}'>
* // foo is `interface { hello: string; bar: string }`
*/
type Fields<T extends string> = ArrToInterface<ExtractKeys<T>>
完整代码Trim 不是重点,大家感兴趣可以看看 Type Challenge - Trim,存在多种实现。
utils/i18.ts
import type { EN_US } from '@/locales/en-US'
import type { ZH_CN } from '@/locales/zh-CN'
import { useTranslation } from 'react-i18next'
import { LocaleContext } from '@/ConfigProvider'
import { useContext } from 'react'
import type { TOptionsBase } from 'i18next'
/**
* 不推荐使用 react-i18n 或 react-intl 对应方法,请使用本文件导出的方法。
*
* 因为入参有提示(不仅能提示 key 而且能提示插值字段名) & 返回值也能精确显示对应的翻译文案。
*
* @param locale
* @returns
*
* @example
* const t = useTranslator('en-US')
* const title = t('foo.bar') // 提示 foo.bar,鼠标 hover title 将显示对应的中文翻译
*
* // 如果 foo.bar 内部有插值,如 `你好,字段{{name}}缺少{{value}}"`
* const title = t('foo.bar', { name: '密码', value: '特殊字符' })
* // 将提示 name 和 value
*/
export function useTranslator(locale?: ISupportedLocale) {
const ctx = useContext(LocaleContext)
const lng: string = locale ?? ctx.locale ?? 'zh-CN'
const { t: trans } = useTranslation(undefined, { lng, i18n: i18next })
type IOpts = TOptionsBase
return function translate<ID extends IKeys>(
id: ID,
opt?: IOpts & Fields<ITranslatedText<ID>>,
): ITranslatedText<ID> {
// @ts-expect-error
return trans(id, opt)
}
}
/**
* @example
* type Bar = TrimStart<' field'>
*/
type TrimStart<T extends string> = T extends ` ${infer R}` ? TrimStart<R> : T
/**
* @example
* type Foo = TrimEnd<'field '>
*/
type TrimEnd<T extends string> = T extends `${infer R} ` ? TrimEnd<R> : T
/**
* @example
* type Baz = Trim<' field '>
*/
type Trim<T extends string> = TrimStart<TrimEnd<T>>
/**
* @example
* type foo = Fields<'hello {{foo}} {{bar}}'>
* // foo is `interface { hello: string; bar: string }`
*/
type Fields<T extends string> = ArrToInterface<ExtractKeys<T>>
/**
* @example
* type foo = ExtractKeys<'hello {{foo}} {{bar}}'>
* // foo is `["foo", "bar"]`
*/
type ExtractKeys<
T extends string,
Keys extends any[] = [],
> = T extends `${string}{{${infer K}}}${infer Rest}` ? ExtractKeys<Rest, [...Keys, Trim<K>]> : Keys
/**
* @example
* type foo = ArrToInterface<["foo", "bar"]>
* // foo is `{"foo": string, "bar": string}`
*/
type ArrToInterface<A extends string[]> = { [P in A[number]]: string }
/**
* Imperative API 非组件内使用。组件内请使用 useTranslator。
* @param locale
* @returns
*
* @example
* const t = createTranslator('en-US')
* const title = t('foo.bar')
*/
export function createTranslator(locale: ISupportedLocale = 'zh-CN') {
if (!i18next) {
i18next = initI18next()
}
return i18next.getFixedT(locale)
}
type ITranslatedText<ID extends IKeys> = (typeof ZH_CN)[ID]
type IKeys = keyof typeof EN_US | keyof typeof ZH_CN
/** `'en-US' | 'en' | 'zh-CN' | 'zh'` */
export type ISupportedLocale = 'en-US' | 'en' | 'zh-CN' | 'zh' | string
接下来我们讲讲如何使用 react-intl 实现文首的效果。首先我们来看看二者的差异。
react-i18next/i18next
vs react-intl/FormatJS
相同点底层都是使用 Intl.xx 系列,最大不同点是 react-intl
使用 ICU 规范写法。比如对复数的国际化。
react-i18next:
"recipe.comment_count_one": "{{count}} comment"
"recipe.comment_count_other": "{{count}} comments"
react-intl:
"recipe.comment_count": "{count, plural, one {# comment} other {# comments}}"
我们也可以看到双大括号和单大括号的区别,为什么后者使用单大括号大家可以简单思考下。
何时应该使用 react-intl
当存在多实例导致翻译失败时。比如项目使用了 react-i18next,它依赖的组件也用了 reat-i18next 会存在两处 init
调用,表现为翻译文本直接展示 key
,在初始化 react-i18next 设置 debug: true
后控制台展示“i18next: init: i18next is already initialized. You should call init just once!”
,用任何办法都无法解决,包括官方推荐的 createInstance
或 lazy init。只能将组件库改成 react-intl(后面我们还会说到在 umi 中使用 react-intl 的坑)。
2. react-intl 代码最佳实践
首先申明为我们为什么使用 createIntl
/ createIntlCache
而不是官方推荐的 useXxx
+ IntlProvdier
是因为后者模板代码太多,每个组件都得包裹一层 IntlProvider
,对组件侵入性强,而前者写法几乎和 react-i18next 一样简洁。
仅贴出不同代码:
import { LocaleContext } from '@/ConfigProvider'
import { useContext } from 'react'
import { createIntl, createIntlCache, type IntlCache, type MessageDescriptor } from 'react-intl'
/**
* 不推荐使用 react-i18n 或 react-intl 对应方法,请使用本文件导出的方法。
*
* 因为入参有提示(不仅能提示 key 而且能提示插值字段名) & 返回值也能精确显示对应的翻译文案。
*
* @param locale
* @returns
*
* @example
* const t = useTranslator('en-US')
* const title = t('foo.bar') // 提示 foo.bar,鼠标 hover title 将显示对应的中文翻译
*
* // 如果 foo.bar 内部有插值,如 `你好,字段{name}缺少{value}"`
* const title = t('foo.bar', { name: '密码', value: '特殊字符' })
* // 将提示 name 和 value
*/
export function useTranslator(locale?: ISupportedLocale) {
const ctx = useContext(LocaleContext)
const lng: string = locale ?? ctx.locale ?? DEFAULT_LOCALE
return createTranslator(lng)
}
/**
* Imperative API 非组件内使用。组件内请使用 useTranslator。
* @param locale
* @returns
*
* @example
* const t = createTranslator('en-US')
* const title = t('foo.bar')
*/
export function createTranslator(locale: ISupportedLocale = 'zh-CN') {
const intl = getIntl(locale)
type IValues<T extends string> = Fields<T> & Parameters<typeof intl.formatMessage>[1]
return function translate<ID extends IKeys>(
id: ID,
values?: IValues<ITranslatedText<ID>>,
descriptor?: Omit<MessageDescriptor, 'id'>,
): ITranslatedText<ID> {
// @ts-expect-error
return intl.formatMessage({ id, ...descriptor }, values)
}
}
同样我们封装了 useTranslator
达到我们的智能提示。我们重点看看这一行代码:
type IValues<T extends string> = Fields<T> & Parameters<typeof intl.formatMessage>[1]
将插值字段和 formatMessage
函数的第二个参数做一个 merge,这样我们通过封装即达到了字段智能提示的效果而且 API 和 react-i18next 几乎一样。
const t = createTranslator('en-US')
const title = t('foo.bar', { baz: 'hello' })
umi 中使用 react-intl 的坑 🤢
为了修复 react-i18next 多实例冲突国际化失效问题(展示 key 而非翻译文本)。我们决定修复方案是组件库改成新库 react-intl。
但出现新问题另一个项目引入组件库 K 后某页面白屏(undefined is not a function)。
整个排查过程很曲折,白屏原因是该项目为 umi 2.x 项目,其插件 umi-plugin-locale 修改了 webpack 配置 alias 让其指向低版本 2.7.2 的 react-intl,在 K 组件内部 import react-intl
也被解析到低版本的 react-intl,预期解析到 6.6.8,由于低版本没有 createIntl / createIntlCache 方法导致引入组件库后页面白屏(undefined is not a function)
umi-plugin-locale 覆写了 alias 导致解析到低版本的 react-intl
Umi 的这种做法很坑(当然他也是为了解决自己的问题,后文会讲到)。即使你在项目里面
import reactIntl from "react-intl"
而且安装了高版本npm i react-intl@6.6.8
也会解析到2.7.2
。
有 3 种解法:
-
升级 umi 至少到 3,相当于间接升级了 react-intl 到 3.x 虽然不能到 6.x 但是问题能解决。缺点是如果我们依赖了 3.x 不能提供的方法,仍然会出现新问题。我们能直接升级到 6 吗?答案不能这依赖了 umi,目前社区有很多呼声要求升级 react-intl 或者暴露出更多方法、fix: add the api exposed by react-intl #8822、[Bug] plugin-locale export 出的api 比 react-intl 原生的api 缺失很多 #8809
-
解法 2 思路:让
import
能解析到 6.x 的 react-intl(项目级别非全局级别)。- 第一步:修改项目
alias
增加react-intl@6
(为什么要加@6,因为我们不能做全局修改,否则 umi-plugin-locale 可能也会被解析到react-intl@6
) - 第二步:修改 K 组件库,新增并导出
init
方法,接受传入的react-intl@6
。 - 缺点是会修改组件的调用方式。当然因为初始化是一次性的成本尚可接受。
- 第一步:修改项目
Before
// pages/xx
import { Result } from "K"
<Result status={404} locale='en'>
Now
// app.ts - 只需初始化一次
import { init } from "K"
import reactIntl from "react-intl@6" // 假设已经配置了 alias
init(reactIntl)
// pages/xx - 调用不受影响
<Result status={404} locale='en'>
须保证 init 先执行
- 组件库改写。规避
createIntl / createIntlCache
改成IntlProvider
。缺点模板代码很多,使用的仍然是低版本 react-intl。
Before
// K/src/Result.tsx
export const Result = () => {
const t = useTranslation()
return <div>t('hello')<div>
}
Now
import IntlProvider from 'react-intl'
const ResultCore = () => {
const t = useTranslation()
return <div>t('hello')<div>
}
export const Result = () => {
return <IntlProvider messages={} locale={} defaultLocale={} ...>
<ResultCore .../>
</IntlProvider>
}
注意开发每一个组件库都需要加类似的模板代码!
总结
倾向于解法 2。
思路一起跟过来的朋友可能会有一些疑惑,这里有几个为什么。
WHY 1:为什么 umi 要使用低版本的 react-intl
因为低版本才有这个文件
WHY 2:为什么我们的组件库要用这两方法 createIntl
/ createIntlCache
而非 IntlProvider
这两个方法本身是让非组件场景也能国际化,比如一个普通的函数内部,或者在 node.js 服务端环境。当然组件内也是可以用的,我们为什么不用官方推荐的 IntlProvider
呢?
因为模板代码少对组件侵入性小,不需要给每个组件套一层 IntlProvider。 这在解法 3 里面我们对比过代码。
总结
TS 还是蛮强大的该学还得学。
欢迎大家关注公众号『JavaScript与编程艺术
』高质量文章优先公众号发布。
转载自:https://juejin.cn/post/7389211334641254409