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>
)
}
useIntl是react-intl的 hook api,它返回一个intl对象,通过intl对象来调用相关的方法。- 在
IntlProvider包裹的 React 组件中,可以使用useIntlhook 来命令式的格式化文案,使用起来更灵活。 - locale 变量的值可以创建一个 state 变量,通过按钮切换 locale 的值来动态实现语言的切换。
关于 FormattedMessage 和 useIntl 更详细的参数,可以参考官方文档。
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。
以 formatDate 和 formatNumber 为例,大概看下他们的一些简单应用。
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,没有用到 type 和 format。
- 其中
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.js、zhCN.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-intl 的 createIntlCache 和 createIntl 方法创建一个 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 来实现。
好了,就写到这里吧,如果你有其他方案,欢迎一起交流。