使用 react-intl 库实现 React 项目国际化(案例分享)

923 阅读11分钟

react-intl 库实现 React 项目国际化(案例分享)

本文将从真实案例出发,分享如何使用 react-intl 库在 React 项目中实现国际化。

背景介绍

在当今全球化趋势下,国际化已经成为软件开发中的一个重要组成部分。

前段时间开发了一个开源的浏览器插件

这个插件主要使用 React + Typescript + antd 开发,由于想要支持国际化,于是选择了 react-Intl 这个库来实现。

访问 react-Intl 的 github 仓库,你会发现,主页是 formatjs,里面包含很多个功能包(语言、数字、日期时间格式化等),react-Intl 只是其中的一个,如果是Vue项目,也可以使用里面的 vue-intl 库。

接下来先介绍下 react-Intl 的基础用法,然后分享下我在 NiceTab 插件中的用法。

基础用法

安装

安装 react-intl 依赖,react 项目前提是安装 react

npm i -S react react-intl
pnpm i -S react react-intl
yarn i -S react react-intl

具体的安装可参考 官方文档

最小应用

我们先实现一个简单的小应用:


import { IntlProvider, FormattedMessage } from 'react-intl';

const messagesCn = {
  language: "当前语言:{language}",
  home: "首页"
};

const messagesEn = {
  language: "Current Language: {language}",
  home: "Home"
};

const messages = {
  'zh-CN': messagesCn,
  'en-US': messagesEn,
};

const locale = 'en-US';
const languageNames = {
  'zh-CN': '中文',
  'en-US': 'English',
};

export default function App() {
  return (
    <IntlProvider messages={messages[locale]} locale={locale} defaultLocale="en-US">
      <p>
        <FormattedMessage
          id="language"
          defaultMessage="Current Language: {language}"
          values={{language: languageNames[locale]}}
        />
      </p>
      <p>
        <FormattedMessage
          id="home"
          defaultMessage="Home"
        />
      </p>
    </IntlProvider>
  )
}

渲染结果:

// locale = 'en-US'
Current Language: English
Home

将 locale 改为 zh-CN, 再看看效果:

// locale = 'zh-CN'
当前语言:中文
首页

这就实现了一个简单的国际化小应用

  • react-intl 中引入 IntlProvider, FormattedMessage
  • 将需要进行国际化的内容使用 <IntlProvider></IntlProvider> 包裹,传入配置好的 messages 模板定义以及当前使用的 locale
  • 然后在需要格式化展示的地方,使用 message id 进行渲染即可。

备注:locale 的值可以创建一个 state 变量,通过按钮切换 locale 的值来动态实现语言的切换。

命令式 API

上面最小应用示例中,使用了 FormattedMessage 组件方式来渲染文本,我们也可以使用命令式的方式来渲染。

import { IntlProvider, useIntl } from 'react-intl';

const messagesCn = {
  language: "当前语言:{language}",
  home: "首页"
};

const messagesEn = {
  language: "Current Language: {language}",
  home: "Home"
};

const messages = {
  'zh-CN': messagesCn,
  'en-US': messagesEn,
};

const locale = 'zh-CN';
// const locale = 'en-US';
const languageNames = {
  'zh-CN': '中文',
  'en-US': 'English',
};

// 组件外使用
function getMessage(id) {
  const curr = messages?.[locale] || {};
  return curr?.[id];
}

function ContentBox() {
  const intl = useIntl();
  return (
    <>
      <p>
        {intl.formatMessage(
          { id: 'language', defaultMessage: 'Current Language: {language}' },
          { language: languageNames[locale] }
        )}
      </p>
      <p>{intl.formatMessage({ id: 'home', defaultMessage: 'Home' })}</p>
    </>
  );
}

export default function App() {
  return (
    <IntlProvider messages={messages[locale]} locale={locale} defaultLocale="en-US">
      <ContentBox></ContentBox>
    </IntlProvider>
  )
}
  • useIntlreact-intl 的 hook api,它返回一个 intl 对象,通过 intl 对象来调用相关的方法。
  • IntlProvider 包裹的 React 组件中,可以使用 useIntl hook 来命令式的格式化文案,使用起来更灵活。
  • locale 变量的值可以创建一个 state 变量,通过按钮切换 locale 的值来动态实现语言的切换。

关于 FormattedMessageuseIntl 更详细的参数,可以参考官方文档。

messages 模板分类管理

随着项目的逐渐扩大,需要配置格式化的内容越来越多,为了方便管理和扩展,我们将不同语言的 messages 分别保存到对应的语言包文件中。

zh-CN.json 文件

// zh-CN.json
{
  "language": "当前语言:{language}",
  "home": "首页"
}

en-US.json 文件

// en-US.json
{
  "language": "Current Language: {language}",
  "home": "Home"
}

然后将 json 文件引入即可。

import messagesCn from './locale/zh-CN.json';
import messagesEn from './locale/en-US.json';

const messages = {
  'zh-CN': messagesCn,
  'en-US': messagesEn,
};

// ......

你还可以使用相同的方式增加更多语言的支持,只需要新增对应语言的语言包文件并引入。

到这里,基础的国际化功能其实就已经实现了,接下来介绍下常用的 api。

常用 api

useIntl 创建的 intl 对象中有很多方法:

  • useIntl: hook 方法比较常用,用法前面已提到过
  • formatMessage: 格式化文本 message
  • formatDate: 日期格式化
  • formatTime: 时间格式化
  • formatNumber: 数值、金额格式化
  • formatPlural: 复数类别格式化
  • createIntlCache 和 createIntl: 后面会讲到
  • ...其他方法

当然这些方法都有对应的组件,可以通过组件方式进行使用 components

formatDateformatNumber 为例,大概看下他们的一些简单应用。

formatDate

类型定义:

function formatDate(
  value: number | Date,
  options?: Intl.DateTimeFormatOptions & {format?: string}
): string

interface DateTimeFormatOptions {
  localeMatcher?: "best fit" | "lookup" | undefined;
  weekday?: "long" | "short" | "narrow" | undefined;
  era?: "long" | "short" | "narrow" | undefined;
  year?: "numeric" | "2-digit" | undefined;
  month?: "numeric" | "2-digit" | "long" | "short" | "narrow" | undefined;
  day?: "numeric" | "2-digit" | undefined;
  hour?: "numeric" | "2-digit" | undefined;
  minute?: "numeric" | "2-digit" | undefined;
  second?: "numeric" | "2-digit" | undefined;
  timeZoneName?: "short" | "long" | "shortOffset" | "longOffset" | "shortGeneric" | "longGeneric" | undefined;
  formatMatcher?: "best fit" | "basic" | undefined;
  hour12?: boolean | undefined;
  timeZone?: string | undefined;
}

背后使用了 Intl.DateTimeFormat,参考下 mdn 相关的 api 介绍,也就基本掌握了 formatDate 的大致用法了。

// ...... 省略
const locale = 'en-US';

function ContentBox() {
  const intl = useIntl();
  return (
    <>
      <strong>formatData方法:</strong>
      <div>calendar: {intl.formatDate(new Date(), { calendar: 'long' })}</div>
      <div>weekday-long: {intl.formatDate(new Date(), { weekday: 'long' })}</div>
      <div>year: {intl.formatDate(new Date(), { year: 'numeric' })}</div>
      <div>month: {intl.formatDate(new Date(), { month: 'numeric' })}</div>
      <div>month: {intl.formatDate(new Date(), { day: 'numeric' })}</div>
      <div>dateStyle-full: {intl.formatDate(new Date(), { dateStyle: 'full' })}</div>
    </>
  );
}

export default function App() {
  return (
    <IntlProvider messages={messages[locale]} locale={locale} defaultLocale="en-US">
      <ContentBox></ContentBox>
    </IntlProvider>
  )
}

看下渲染结果(测试时间是 2024-12-10):

formatData方法:
calendar: 12/10/2024
weekday-long: Tuesday
year: 2024
month: 12
month: 10
dateStyle-full: Tuesday, December 10, 2024

将 locale 改为 zh-CN 后,再看下渲染结果(测试时间是 2024-12-10):

formatData方法:
calendar: 2024/12/10
weekday-long: 星期二
year: 2024
month: 12
month: 10
dateStyle-full: 2024年12月10日星期二

formatDate 的用法介绍到这里,其他用法可参考 Intl.DateTimeFormat 文档。

formatNumber

类型定义:

function formatNumber(
  value: number,
  options?: Intl.NumberFormatOptions & {format?: string}
): string

这个方法背后使用了 Intl.NumberFormat,参考下 mdn 相关的 api 介绍,也就基本掌握了 formatDate 的大致用法了。

// ...... 省略
const locale = 'en-US';

const currencyTypes = {
  'zh-CN': 'CNY',
  'en-US': 'USD',
};

function ContentBox() {
  const intl = useIntl();
  return (
    <>
      <strong>formatNumber方法:</strong>
      <div>
        money:
        {intl.formatNumber(88888888, {
          style: 'currency',
          currency: currencyTypes[locale],
        })}
      </div>
      <div>
        speed:
        {intl.formatNumber(100, { style: 'unit', unit: 'kilometer-per-hour' })}
      </div>
    </>
  );
}

export default function App() {
  return (
    <IntlProvider messages={messages[locale]} locale={locale} defaultLocale="en-US">
      <ContentBox></ContentBox>
    </IntlProvider>
  )
}

看下渲染结果:

formatNumber方法:
money:$88,888,888.00
speed:100 km/h

formatDate 的用法介绍到这里,其他用法可参考 Intl.NumberFormat 文档。

兼容性

在使用 api 时,注意 api 的兼容性问题,如果要在 IE 或者低版本浏览器中使用,可以引入官方提供的 polyfill 包。

详细信息和使用方式可以参考官方文档,Runtime Requirements

formatMessage 简单用法

接下来着重介绍下 formatMessage 这个方法。

在前面最小应用里已经初步使用 formatMessage 来给 message 进行格式化了。

简单用法

  • 直接原样输出
  • 类似占位符的方式 {language},在使用时动态的传递参数来替换占位符中的变量。

中文 message 模板:

{
  "language": "当前语言:{language}",
  "home": "首页"
}

英文 message 模板:

{
  "language": "Current Language: {language}",
  "home": "Home"
}

使用示例:

// 不含占位符
intl.formatMessage({ id: 'home', defaultMessage: 'Home' });
// 包含占位符
intl.formatMessage(
  { id: 'language', defaultMessage: 'Current Language: {language}' },
  { language: languageNames[locale] }
);

参数介绍

  • 参数一:Message Descriptor 描述对象
  • 参数二:values 占位变量填充对象

MessageDescriptor 结构:

type MessageDescriptor = {
  id: string
  defaultMessage?: string
  description?: string | object
}

前面的 language 格式化的例子中,message 模板中定义了一个 {language} 占位符变量,所以 formatMessage 第二个参数传了一个填充对象,其中 language 的值根据当前的 locale 值进行切换。

{ language: languageNames[locale] }

根据 messages 模板中定义的变量个数,values 对象传递相应的变量键值即可。

formatMessage 复杂用法

可参考官方示例文档:[Formatted Argument](Formatted Argument)

占位符格式{key, type, format}

前面的简单用法中只是使用了格式中的 key,没有用到 typeformat

  • 其中 key 可以自定义一个变量名,用来变量传参。
  • type 是预设的一些值 (plural, number, date, time, select, selectordinal),表示该用哪种类型进行格式化。
  • format 是可选的,是对当前类型数据格式化的具体规则设置。

接下来介绍下这几个类型的用法:

plural 复数形式

这个用法主要使用在英文中,因为英文中个数和复数的单位不同,展示会有一些区别,比如:展示选中的标签个数。

  • 未选中:No tags
  • 选中一个:1 tag
  • 选中多个:选中个数 tags (6 tags)

messages 模板中我们可以配置为:

{
  "tag.count": "{count, plural, =0 {No tags} one {# tag} other {# tags} }"
}

组件中使用:

// ...... 省略
const locale = 'en-US';

const currencyTypes = {
  'zh-CN': 'CNY',
  'en-US': 'USD',
};

function ContentBox() {
  const intl = useIntl();
  return (
    <>
      <strong>Formatted Argument: plural</strong>
      <div>{intl.formatMessage({ id: 'tag.count' }, { count: 0 })}</div>
      <div>{intl.formatMessage({ id: 'tag.count' }, { count: 1 })}</div>
      <div>{intl.formatMessage({ id: 'tag.count' }, { count: 6 })}</div>
    </>
  );
}

export default function App() {
  return (
    <IntlProvider messages={messages[locale]} locale={locale} defaultLocale="en-US">
      <ContentBox></ContentBox>
    </IntlProvider>
  )
}

渲染结果:

Formatted Argument: plural
No tags
1 tag
6 tags

可以看到,formatMessage 会根据设置的 messages 模板和传递的 values 填充对象值,自动进行格式化。

number 数值格式化

来看下常用的数字格式化 messages 模板示例:

// en-US.json
{
  "passRate": "Pass rate: {rate, number, ::percent}",
  "price": "Price: {price, number, ::sign-always currency/USD}",
  "precision": "Precision: {precision, number, ::.##}",
  "precision2": "Precision2: {precision, number, ::.00}"
}

在组件中使用:

<strong>Formatted Argument: number</strong>
<div>{intl.formatMessage({ id: 'passRate' }, { rate: 0.456 })}</div>
<div>{intl.formatMessage({ id: 'price' }, { num: 168.54 })}</div>
<div>{intl.formatMessage({ id: 'precision' }, { precision: 12.5678 })}</div>
<div>{intl.formatMessage({ id: 'precision' }, { precision: 123 })}</div>
<div>{intl.formatMessage({ id: 'precision2' }, { precision: 123 })}</div>

渲染结果:

Pass rate: 46%
Price: +$168.54
Precision: 12.57
Precision: 123
Precision2: 123.00

日期时间格式化

来看下常用的日期时间格式化 messages 模板示例:

{
  "date": "Date: {dateValue, date, long}",
  "time": "Time: {timeValue, time, medium}"
}

占位符中第三个参数可以设置为 short/medium/long/full,可以根据需求自行设置。

在组件中使用:

<strong>Formatted Argument: date/time</strong>
<div>{intl.formatMessage({ id: 'date' }, { dateValue: new Date() })}</div>
<div>{intl.formatMessage({ id: 'time' }, { timeValue: new Date() })}</div>

渲染结果:

Formatted Argument: date/time
Date: December 11, 2024
Time: 11:04:50 AM

选择格式化

详细介绍可参考select-format

占位符格式{key, select, matches}

matches 的书写格式为 变量值 {输出内容}

  • 类似 switch case 语句
  • 变量值是用来进行匹配的,当 key 的值与变量值相同,则展示对应的输出内容

举个例子:

en-US.json 文件:

// en-US.json
{
  "goodMorning": "Good morning, {gender, select, male {Mr} female {Ms} other {}} Wang"
}

zh-CN.json:

// zh-CN.json
{
  "goodMorning": "早上好,王{gender, select, male {先生} female {女士} other {老师}}"
}

在组件中使用:

<strong>Formatted Argument: select</strong>
<div>{intl.formatMessage({ id: 'goodMorning' }, { gender: 'male' })}</div>
<div>{intl.formatMessage({ id: 'goodMorning' }, { gender: 'female' })}</div>

渲染结果(英文):

Formatted Argument: select
Good morning, Mr Wang
Good morning, Ms Wang

渲染结果(中文):

Formatted Argument: select
早上好,王先生
早上好,王女士

组件外使用

有些情况下,我们需要在组件外使用message,比如 js 方法执行过程中,进行消息提示等场景。

有两种方式:

直接对象属性取值

const messagesCn = {
  success: "成功",
  hello: "您好,{name}",
};

const messagesEn = {
  success: "Success",
  hello: "Hello, {name}",
};

const messages = {
  'zh-CN': messagesCn,
  'en-US': messagesEn,
};

let locale = 'zh-CN';
export function setLocale(value) {
  locale = value;
}

// 组件外使用
function getMessage(locale, id) {
  const curr = messages?.[locale] || {};
  return curr?.[id];
}

function getMessages(locale) {
  return messages?.[locale] || {};
}

function showMsg() {
  alert(getMessage('zh-CN', 'success'));
}

这种对象属性取值的方式针对没有占位符的模板可以使用,如果有占位符和复杂格式化的模板无法使用。

使用 createIntl 方法

// initLocale.js
import { createIntl, createIntlCache } from 'react-intl';

const messagesCn = {
  success: "成功",
  hello: "您好,{name}",
};

const messagesEn = {
  success: "Success",
  hello: "Hello, {name}",
};

const messages = {
  'zh-CN': messagesCn,
  'en-US': messagesEn,
};

const cache = createIntlCache();
let createdIntl = createIntl({ locale: 'zh-CN', messages: messages['zh-CN'] }, cache);

// 获取 cached intl
export const getCreatedIntl = (locale = 'zh-CN') => {
  createdIntl = createIntl({ locale, messages: messages[locale] }, cache);
  return createdIntl;
}

export default createdIntl;

通过 createdIntl 方法创建一个 intl 对象并导出。

然后在组件外进行使用。

import intl from './initLocale';

function showMsg() {
  alert(intl.formatMessage({ id: 'hello' }, { name: '张三' }));
}

showMsg();

真实项目用法案例

前面章节介绍了 react-intl 的基础用法及常用的 api 使用,接下来以一个真实项目 NiceTab 为例讲解下我的一些用法。

NiceTab浏览器插件:方便快捷的浏览器标签页管理插件,OneTab的升级替代品。

NiceTab 项目中,整个系统采用了中英双语切换,基本用法跟前面介绍的差不多,只不过根据自己的喜好针对使用方案做了一些调整。

messages 模板管理

在前面【messages 模板分类管理】这一节介绍了 messages 模板管理方式:将不同语言的模板内容分别保存到对应的 json 文件中,统一引入,然后根据 locale 切换使用。

而在 NiceTab 中,我采用了 js 模块的方式进行管理,有下面几点原因:

  • json 文件语法不够灵活(太死板),不仅格式校验非常严格,而且不方便书写占位符规则。
  • js 模块化可发挥空间大,更灵活。
  • js 模板字符串书写更方便,更易于阅读。

接下来,看下具体的实现:

首先,创建一个 locale 文件夹,用于存放所有的 messages 模板,在 locale 文件夹下新建 modules 文件夹,将不同模块的 messages 模板分类管理。

目录结构如下:

locale              
├─ modules          
│  ├─ common        
│  │  ├─ enUS.js    
│  │  ├─ index.js   
│  │  └─ zhCN.js    
│  ├─ home          
│  │  ├─ enUS.js    
│  │  ├─ index.js   
│  │  └─ zhCN.js    
│  ├─ hotkeys       
│  │  ├─ enUS.js    
│  │  ├─ index.js   
│  │  └─ zhCN.js     
│  ├─ settings      
│  │  ├─ enUS.js    
│  │  ├─ index.js   
│  │  └─ zhCN.js    
│  ├─ sync          
│  │  ├─ enUS.js    
│  │  ├─ index.js   
│  │  └─ zhCN.js       
└─ index.js         

可以看到,我是根据不同路由来划分模块的,不同项目可以根据情况选择模块的划分逻辑。

  • 在每个模块中都包含 enUS.jszhCN.js 和一个 index.js 文件。
  • 模块中的 index.js 文件负责导入各个语言的模板文件,并统一导出。
  • 最后在 locale/index.js 中导入所有模块,并作为总的对外出口,统一导出到外部。

其中 locale/index.js 文件,会暴露方法,用于根据传入的 locale 来切换对应语言的 message集合。

common 模块为例:

locale/modules/common/enUS.js 文件内容

export default {
  'common.delete': 'Delete',
  'common.edit': 'Edit',
  'common.save': 'Save',
  'common.submit': 'Submit',
}

locale/modules/common/zhCN.js 文件内容

export default {
  'common.delete': '删除',
  'common.edit': '编辑',
  'common.save': '保存',
  'common.submit': '提交',
}

locale/modules/common/index.js 文件内容

import zhCN from './zhCN';
import enUS from './enUS';

export default {
  'zh-CN': zhCN,
  'en-US': enUS
};

其他模块跟 common 模块结构一致。

然后在 locale/index.js 文件中引入所有模块。

import { createIntl, createIntlCache } from 'react-intl';
import common from './modules/common';
import home from './modules/home';
import settings from './modules/settings';
import sync from './sync';
import hotkeys from './hotkeys';

const getLocales = (language) => {
  return {
    ...home[language],
    ...common[language],
    ...settings[language],
    ...sync[language],
    ...hotkeys[language]
  }
}

export const messages = {
  'zh-CN': getLocales('zh-CN'),
  'en-US': getLocales('en-US')
};

export const getLocaleMessages = (locale = 'zh-CN') => {
  return messages[locale];
}

const cache = createIntlCache();
let createdIntl = createIntl({ locale: 'zh-CN', messages: messages['zh-CN'] }, cache);

// 获取 cached intl
export const getCreatedIntl = (locale = 'zh-CN') => {
  createdIntl = createIntl({ locale, messages: messages[locale] }, cache);
  return createdIntl;
}

export default createdIntl;

locale/index.js 文件中,暴露了 getLocaleMessages 方法,用于在 React 组件外使用 message。

也可以使用 react-intlcreateIntlCachecreateIntl 方法创建一个 intl 对象,进行调用。

最后将上面导出的 messages 提供给 <IntlProvider></IntlProvider> 组件,注入到全局。

import { useState, useMemo } from 'react';
import App from './App';
import { messages } from './locale/index.js';

const defaultLanguage = navigator?.language || 'zh-CN';
export default function Root {
  const [locale, setLocale] = useState(defaultLanguage);
  const currMessages = useMemo(
    () => customMap[locale] || customMap[defaultLanguage],
    [locale]
  );
  // ...省略

  return (
    <IntlProvider locale={locale} messages={currMessages}>
      <App />
    </IntlProvider>
  )
}

以上是简板的代码示例。

formatMessage 简单封装

从前面的示例中可以看到,每次都使用 intl.formatMessage() 书写麻烦,也占用位置,可以使用 hook 方式做个小封装。

新建一个 useIntlUtils.ts 文件,内容如下:

import { useIntl, type MessageDescriptor } from 'react-intl';
import { capitalize } from '~/entrypoints/common/utils';

export type IntlForamtMessageParams = MessageDescriptor & {
  values?: Record<string, any>;
  opts?: Record<string, any>;
};

export function useIntlUtils() {
  const intl = useIntl();
  const $fmt = (
    idOrFormatMsg: string | IntlForamtMessageParams,
    options?: Record<string, any>
  ) => {
    const {
      id,
      defaultMessage = '',
      description = '',
      values = undefined,
      opts = undefined,
    } = typeof idOrFormatMsg === 'string' ? { id: idOrFormatMsg } : idOrFormatMsg;

    const descriptor = { id, defaultMessage, description };

    let message = intl.formatMessage(descriptor, values, opts);
    if (options?.capitalize) {
      message = capitalize(message);
    }
    return message;
  };

  return { $fmt, ...intl };
}

intl.formatMessage 基础上做了简单的封装处理。

封装对比 1、 $fmt 支持 intl.formatMessage 的对象参数格式,并且当只需要 message id 时,直接使用 $fmt(id) 即可。

比如之前格式化 'common.home': 'Home',需要使用 intl.formatMessage({ id: 'common.home' }),而现在只需要使用 $fmt('common.home')

2、 新增 options 参数用来进行一些自定义逻辑处理

比如传参 { capitalize: true },则对文本进行首字母大写转换,返回处理后的结构。

这样的话,后续可以根据各种场景添加不同的 options 参数,丰富自定义功能。

模板字符串

前面讲到 json 文件不够灵活,占位符的书写和阅读不方便,下面介绍下使用 js 方式的 messages 模板定义。

比如前面示例中的复数形式和选择格式化:

使用 json 方式配置模板

{
  "tag.count": "{count, plural, =0 {No tags} one {# tag} other {# tags} }",
  "goodMorning": "Good morning, {gender, select, male {Mr} female {Ms} other {}} Wang"
}

使用 js 方式来配置模板

export default {
  'tag.count': `{count, plural, 
    =0 {No tags} 
    one {# tag} 
    other {# tags}
  }`,
  'goodMorning': `Good morning, {gender, select, 
    male {Mr} 
    female {Ms} 
    other {}
  } Wang`
}

可以看出,使用 js 模板字符串 来声明 messages 模板清晰明了,书写也方便。

其实在浏览器插件项目中,可以直接使用浏览器自带的 i18n 实现多语言,不过我不太喜欢 json 的定义方式,而 react-intl 可以脱离浏览器插件环境使用,于是选择了 react-intl 来实现。

好了,就写到这里吧,如果你有其他方案,欢迎一起交流。