likes
comments
collection
share

0 插件实现 DX 🎨 体验极好的国际化方案!附代码

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

0 插件实现 DX 🎨 体验极好的国际化方案!附代码

欢迎大家关注公众号『JavaScript与编程艺术』高质量文章优先公众号发布。

产品需求

中英文文本切换能力以及数字、复数、日期的国际化。本文将探讨 react-i18n 和 react-intl 两个库如何实现我们的需求。

当然这只是我们的产品需求,技术侧我们有自己的追求,我们看看技术侧额外效果。

额外效果 🎨

目标:DX 体验增强,提高开发效率和可维护性,同时降低误写导致 bug。

市面上所有的国际化方案都有类似的通病“key不能提示手写容易出错”、“要查看翻译文本必须全局搜索或者切换到国际化文件”,面对这些痛点我们经常需要配置 VSCode 插件来完成这是一个很繁琐的过程,

如果无需 VSCode 插件直接通过 TS 类型实现,你就说 city 不 city。

先预览为快:

  • Key 能自动提示 0 插件实现 DX 🎨 体验极好的国际化方案!附代码
  • Hover 能展示 key 对应的文本 0 插件实现 DX 🎨 体验极好的国际化方案!附代码
  • 插值变量也能提示:若翻译文本有动态插值比如:
    • "你好,字段{name}缺少{requirements},则当输入 t('login.missingKey', { name: '密码', requirements: '特殊字符' }) 能提示 namerequirements,防止写错字段。

你是否怦然心动?我们一起来实现一遍。

实现

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 文件的灵活性,比如能加注释,无需繁琐的双引号。

  1. 如何拿到所有的插值变量。重点是 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 就是将两侧空格去掉先了解下即可)。接下来是将其转换成 interfacerecord,即 { 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 规范写法。比如对复数的国际化。

The Missing Guide to the ICU Message Format

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)

0 插件实现 DX 🎨 体验极好的国际化方案!附代码

0 插件实现 DX 🎨 体验极好的国际化方案!附代码

umi-plugin-locale 覆写了 alias 导致解析到低版本的 react-intl

Umi 的这种做法很坑(当然他也是为了解决自己的问题,后文会讲到)。即使你在项目里面 import reactIntl from "react-intl" 而且安装了高版本 npm i react-intl@6.6.8 也会解析到 2.7.2

有 3 种解法:

  1. 升级 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. 解法 2 思路:让 import 能解析到 6.x 的 react-intl(项目级别非全局级别)。

    1. 第一步:修改项目 alias 增加 react-intl@6(为什么要加@6,因为我们不能做全局修改,否则 umi-plugin-locale 可能也会被解析到 react-intl@6
    2. 第二步:修改 K 组件库,新增并导出 init 方法,接受传入的 react-intl@6
    3. 缺点是会修改组件的调用方式。当然因为初始化是一次性的成本尚可接受。

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 先执行

  1. 组件库改写。规避 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

因为低版本才有这个文件

0 插件实现 DX 🎨 体验极好的国际化方案!附代码

github.com/umijs/umi/b…

WHY 2:为什么我们的组件库要用这两方法 createIntl / createIntlCache 而非 IntlProvider

这两个方法本身是让非组件场景也能国际化,比如一个普通的函数内部,或者在 node.js 服务端环境。当然组件内也是可以用的,我们为什么不用官方推荐的 IntlProvider 呢?

因为模板代码少对组件侵入性小,不需要给每个组件套一层 IntlProvider。 这在解法 3 里面我们对比过代码。

总结

TS 还是蛮强大的该学还得学。

欢迎大家关注公众号『JavaScript与编程艺术』高质量文章优先公众号发布。

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