如何实现一个国际化的React应用程序
快速导读:我们如何构建一个国际化的React前端应用程序?借助本文,您可以学习如何检测用户的语言环境,将其保存在Cookie中,让用户更改其语言环境,翻译用户界面以及以适当的格式呈现货币。另外,Yury还为您准备了一些陷阱,以及您在此过程中可能会遇到的问题。
首先,让我们定义一些词汇。 “国际化”是一个长词,至少有两个广泛使用的缩写:“ intl”,“ i18n”。“本地化”可以缩短为“ l10n”。
国际化通常可以分为三个主要挑战:检测用户的语言环境,翻译UI元素,标题和提示,最后但并非最不重要的一点是,提供特定于语言环境的内容,例如日期,货币和数字。在本文中,我将只关注前端部分。我们将开发一个简单的通用React应用程序,并提供全面的国际化支持

- 检测用户的语言环境;
- 翻译UI元素,标题和提示;
- 提供特定于语言环境的内容,例如日期,货币和数字。
让我们以我的样板库为起点。在这里,我们有用于服务器端渲染的Express Web服务器,用于构建客户端JavaScript的webpack,用于将现代JavaScript转换为ES5的Babel以及用于UI实现的React。我们将使用Better-npm-run编写与操作系统无关的脚本,使用nodemon在开发环境中运行Web服务器,并使用webpack-dev-server服务资源。
我们服务器应用程序的入口点是server.js。在这里,我们正在加载Babel和babel-polyfill来用现代JavaScript编写其余的服务器代码。
服务器端业务逻辑是在src / server.jsx中实现的。在这里,我们将设置一个Express Web服务器,该服务器正在侦听端口3001。为进行渲染,我们使用来自components/App.jsx的非常简单的组件,这也是通用的应用程序部件入口点。
客户端JavaScript的入口点是src / client.jsx。在这里,我们将根组件component /App.jsx安装到Express Web服务器提供的HTML标记中的占位符react-view上。
因此,克隆存储库,运行npm install并在两个控制台选项卡中同时执行nodemon(Node自动重启工具)和webpack-dev-server(快速搭建本地运行环境的工具or一个小型的Node.js Express服务器)。
在第一个控制台选项卡中:
git clone https://github.com/yury-dymov/smashing-react-i18n.git cd smashing-react-i18n npm install npm run nodemon
在第二个控制台选项卡中
cd smashing-react-i18n npm run webpack-devserver
网站应该可以在localhost:3001上使用。打开您喜欢的浏览器,然后尝试一下。
我们准备好了!
1. 检测用户语言环境
有两种解决方案可以满足此要求。
由于某些原因,包括Skype和NBA在内的大多数受欢迎的网站都使用Geo IP查找用户的位置,并以此为基础猜测用户的语言。这种方法不仅实现成本高昂,而且还不够准确。如今,人们出行频繁,这意味着位置不一定代表用户所需的语言环境。相反,我们将使用第二种解决方案,并在服务器端处理HTTP标头Accept-Language,然后根据用户的系统语言设置提取用户的语言偏好设置。此标头由页面请求中的每个现代浏览器发送。
ACCEPT-LANGUAGE请求头
Accept-Language请求标头提供了一组自然语言,这些语言首选作为对请求的响应。可以为每个语言范围分配一个关联的“质量”值,该值表示用户对该范围指定的语言的偏好的估计。质量值默认为q = 1。例如,Accept-Language:da,en-gb; q = 0.8,en; q = 0.7表示“我更喜欢丹麦语,但会接受英式英语和其他类型的英语。”如果语言范围与语言标签匹配,则
它完全等于该标签,或者如果它完全等于该标签的前缀,则该前缀之后的第一个标签字符为-。
(值得一提的是,此方法仍然不完善。例如,用户可能会从网吧或公共计算机访问您的网站。要解决此问题,请始终实施一个小部件,用户可以使用该小部件直观地更改语言,并且可以在几秒钟内轻松找到。)
使用用户所在地检测
这是Node.js Express Web服务器的代码示例。
我们正在使用accept-language包,该包从HTTP标头中提取语言环境,并在您的网站支持的语言中找到最相关的语言。如果找不到任何内容,则您将使用网站的默认区域设置。对于重复访问的用户,我们将改为检查Cookie的值。
让我们从安装软件包开始:
npm install --save accept-language npm install --save cookie-parser js-cookie
在src/server.jsx中我们这样设置:
import cookieParser from 'cookie-parser';
import acceptLanguage from 'accept-language';
acceptLanguage.languages(['en', 'ru']);
const app = express();
app.use(cookieParser());
function detectLocale(req) {
const cookieLocale = req.cookies.locale;
return acceptLanguage.get(cookieLocale || req.headers['accept-language']) || 'en';
}
…
app.use((req, res) => {
const locale = detectLocale(req);
const componentHTML = ReactDom.renderToString(<App />);
res.cookie('locale', locale, { maxAge: (new Date() * 0.001) + (365 * 24 * 3600) });
return res.end(renderHTML(componentHTML));
});
在这里,我们将导入accept-language包,并设置支持的英语和俄语语言环境。我们还实现了detectLocale函数,该函数从cookie中获取一个区域设置值。如果未找到,则处理HTTP Accept-Language标头。最后,我们回到默认语言环境(在我们的示例中为en)。处理完请求后,我们为响应中检测到的语言环境添加HTTP标头Set-Cookie。该值将用于所有后续请求。
2.翻译UI元素、标题和提示
我将使用React Intl包来完成此任务。 它是React应用程序中最受欢迎和经过考验的i18n实现。但是,所有库都使用相同的方法:它们提供“高阶组件”(来自React中广泛使用的功能编程设计模式),从而注入国际化功能,以通过React的上下文功能来处理消息,日期,数字和货币。
首先,我们必须建立国际化提供商。为此,我们将略微更改src / server.jsx和src / client.jsx文件。
npm install --save react-intl
src/server.jsx文件:
import { IntlProvider } from 'react-intl';
…
--- const componentHTML = ReactDom.renderToString(<App />);
const componentHTML = ReactDom.renderToString(
<IntlProvider locale={locale}>
<App />
</IntlProvider>
);
…
src/client.jsx文件:
import { IntlProvider } from 'react-intl';
import Cookie from 'js-cookie';
const locale = Cookie.get('locale') || 'en';
…
--- ReactDOM.render(<App />, document.getElementById('react-view'));
ReactDOM.render(
<IntlProvider locale={locale}>
<App />
</IntlProvider>,
document.getElementById('react-view')
);
因此,现在所有IntlProvider子组件都可以访问国际化功能。让我们向应用程序中添加一些翻译后的文本,并添加一个用于更改语言环境的按钮(用于测试)。
我们有两个选项:FormattedMessage组件或formatMessage函数。不同之处在于该组件将被包裹在一个span标记中,该标记适用于文本,但不适用于alt和title等HTML属性值。让我们都尝试一下!
这是我们的src / components / App.jsx文件:
import { FormattedMessage } from 'react-intl';
…
--- <h1>Hello World!</h1>
<h1><FormattedMessage id="app.hello_world" defaultMessage="Hello World!" description="Hello world header greeting" /></h1>
请注意,id属性对于整个应用程序应该是唯一的,因此制定一些规则来命名消息是有意义的。我更喜欢遵循格式componentName.someUniqueIdWithInComponent。defaultMessage值将用于您应用程序的默认语言环境,并且description属性为翻译程序提供了一些上下文。
重新启动nodemon并刷新浏览器中的页面。您仍然应该看到“ Hello World”消息。但是,如果您在开发人员工具中打开页面,您将看到文本现在位于span标记内。在这种情况下,这不是问题,但有时我们希望仅获取文本,而无需任何其他标签。为此,我们需要直接访问React Intl提供的国际化对象。
让我们回到src / components / App.jsx:
--- import { FormattedMessage } from 'react-intl';
import { FormattedMessage, intlShape, injectIntl, defineMessages } from 'react-intl';
const propTypes = {
intl: intlShape.isRequired,
};
const messages = defineMessages({
helloWorld2: {
id: 'app.hello_world2',
defaultMessage: 'Hello World 2!',
},
});
--- export default class extends Component {
class App extends Component {
render() {
return (
<div className="App">
<h1>
<FormattedMessage
id="app.hello_world"
defaultMessage="Hello World!"
description="Hello world header greeting"
/>
</h1>
<h1>{this.props.intl.formatMessage(messages.helloWorld2)}</h1>
</div>
);
}
}
App.propTypes = propTypes;
export default injectIntl(App);
我们不得不编写更多代码。首先,我们必须使用injectIntl,它包装了我们的应用程序组件并注入了intl对象。要获取翻译后的消息,我们必须调用formatMessage方法并将消息对象作为参数传递。该消息对象必须具有唯一的id和defaultValue属性。我们使用来自React Intl的defineMessages定义此类对象。
React Intl最好的是它的生态系统。让我们将babel-plugin-react-intl添加到我们的项目中,该项目将从我们的组件中提取FormattedMessages并构建翻译词典。我们会将这本词典传递给翻译人员,他们不需要任何编程技能即可完成工作。
npm install --save-dev babel-plugin-react-intl
修改.babelrc文件:
{
"presets": [
"es2015",
"react",
"stage-0"
],
"env": {
"development": {
"plugins":[
["react-intl", {
"messagesDir": "./build/messages/"
}]
]
}
}
}
重新启动nodemon,您应该会看到在项目的根目录中已经创建了一个build / messages文件夹,其中的一些文件夹和文件反映了JavaScript项目的目录结构。我们需要将所有这些文件合并为一个JSON。随时使用我的脚本。将其另存为scripts / translate.js。
现在,我们需要向package.json添加一个新脚本:
"scripts": {
…
"build:langs": "babel scripts/translate.js | node",
…
}
试试吧!
npm run build:langs
您应该在build / lang文件夹中看到一个en.json文件,其中包含以下内容:
{
"app.hello_world": "Hello World!",
"app.hello_world2": "Hello World 2!"
}
有用!现在来有趣的部分。在服务器端,我们可以将所有翻译加载到内存中,并相应地处理每个请求。 但是,对于客户端,此方法不适用。相反,我们将发送一次带有转换的JSON文件,并且客户端将自动为我们的所有组件应用提供的文本,因此客户端只能获得所需的内容。
让我们将输出复制到public / assets文件夹,并提供一些翻译。
ln -s ../../build/lang/en.json public/assets/en.json
注意:如果您是Windows用户,则符号链接不可用,这意味着您每次重建翻译时都必须手动复制以下命令:
cp ../../build/lang/en.json public/assets/en.json
在public / assets / ru.json中,我们需要以下内容:
{
"app.hello_world": "Привет мир!",
"app.hello_world2": "Привет мир 2!"
}
现在我们需要调整服务器和客户端代码。
对于服务器端,我们的src / server.jsx文件应如下所示:
--- import { IntlProvider } from 'react-intl';
import { addLocaleData, IntlProvider } from 'react-intl';
import fs from 'fs';
import path from 'path';
import en from 'react-intl/locale-data/en';
import ru from 'react-intl/locale-data/ru';
addLocaleData([…ru, …en]);
const messages = {};
const localeData = {};
['en', 'ru'].forEach((locale) => {
localeData[locale] = fs.readFileSync(path.join(__dirname, '../node_modules/react-intl/locale-data/${locale}.js')).toString();
messages[locale] = require('../public/assets/${locale}.json');
});
--- function renderHTML(componentHTML) {
function renderHTML(componentHTML, locale) {
…
<script type="application/javascript" src="${assetUrl}/public/assets/bundle.js"></script>
<script type="application/javascript">${localeData[locale]}</script>
…
--- <IntlProvider locale={locale}>
<IntlProvider locale={locale} messages={messages[locale]}>
…
--- return res.end(renderHTML(componentHTML));
return res.end(renderHTML(componentHTML, locale));
在这里,我们正在执行以下操作:
- 在启动期间为
currency,DateTime,Number格式的数据缓存消息和特定于语言环境的JavaScript(以确保良好的性能); - 扩展renderHTML方法,以便我们可以将特定于语言环境的JavaScript插入到生成的HTML标记中;
- 提供翻译后的消息给IntlProvider(所有这些消息现在都可用于子组件)。
对于客户端,首先我们需要安装一个库来执行AJAX请求。我更喜欢使用isomorphic-fetch,因为我们很可能还需要从第三方API请求数据,并且isomorphic-fetch在客户端和服务器环境中都能很好地做到这一点。
npm install --save isomorphic-fetch
以下是src/client.jsx文件:
--- import { IntlProvider } from 'react-intl';
import { addLocaleData, IntlProvider } from 'react-intl';
import fetch from 'isomorphic-fetch';
const locale = Cookie.get('locale') || 'en';
fetch(`/public/assets/${locale}.json`)
.then((res) => {
if (res.status >= 400) {
throw new Error('Bad response from server');
}
return res.json();
})
.then((localeData) => {
addLocaleData(window.ReactIntlLocaleData[locale]);
ReactDOM.render(
--- <IntlProvider locale={locale}>
<IntlProvider locale={locale} messages={localeData}>
…
);
}).catch((error) => {
console.error(error);
});
我们还需要调整src / server.jsx,以便Express为我们提供翻译JSON文件。请注意,在生产中,您将使用类似nginx的东西。
app.use(cookieParser());
app.use('/public/assets', express.static('public/assets'));
初始化JavaScript后,client.jsx将从cookie中获取语言环境,并请求带有转换的JSON文件。之后,我们的单页应用程序将像以前一样工作。
是时候检查一下浏览器中的所有功能了。打开开发人员工具中的“网络”标签,然后检查客户端是否已成功获取JSON。

为了完成这一部分,让我们在src / components / LocaleButton.jsx中添加一个简单的小部件来更改语言环境:
import React, { Component, PropTypes } from 'react';
import Cookie from 'js-cookie';
const propTypes = {
locale: PropTypes.string.isRequired,
};
class LocaleButton extends Component {
constructor() {
super();
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
Cookie.set('locale', this.props.locale === 'en' ? 'ru' : 'en');
window.location.reload();
}
render() {
return <button onClick={this.handleClick}>{this.props.locale === 'en' ? 'Russian' : 'English'};
}
}
LocaleButton.propTypes = propTypes;
export default LocaleButton;
将以下内容添加到src / components / App.jsx:
import LocaleButton from './LocaleButton';
…
<h1>{this.props.intl.formatMessage(messages.helloWorld2)}</h1>
<LocaleButton locale={this.props.intl.locale} />
请注意,一旦用户更改了语言环境,我们将重新加载页面,以确保获取带有翻译的新JSON文件。 测试时间很高!好的,我们已经学习了如何检测用户的语言环境以及如何显示翻译后的消息。在进行最后一部分之前,让我们讨论另外两个重要主题。
实用化和模板化
用英语,大多数单词采用两种可能的形式之一:“one apple”,“many apples”。在其他语言中,事情要复杂得多。例如,俄语有四种不同形式。希望React Intl将帮助我们相应地处理多元化。它还支持模板,因此您可以提供在渲染过程中将插入模板的变量。 运作方式如下。
在src / components / App.jsx中,我们具有以下内容:
const messages = defineMessages({
counting: {
id: 'app.counting',
defaultMessage: 'I need to buy {count, number} {count, plural, one {apple} other {apples}}'
},
…
<LocaleButton locale={this.props.intl.locale} />
<div>{this.props.intl.formatMessage(messages.counting, { count: 1 })}</div>
<div>{this.props.intl.formatMessage(messages.counting, { count: 2 })}</div>
<div>{this.props.intl.formatMessage(messages.counting, { count: 5 })}</div>
在这里,我们定义了一个带有变量count的模板。如果计数等于1、21等,我们将打印“ 1 apple”,否则打印“ 2 apples”。
我们必须在formatMessage的values选项中传递所有变量。
让我们重建翻译文件并添加俄语翻译,以检查我们是否可以为英语以外的其他语言提供两种以上的变体。
npm run build:langs
这里是public/assers/ru.json文件:
{
…
"app.counting": "Мне нужно купить {count, number} {count, plural, one {яблоко} few {яблока} many {яблок}}"
}
现在涵盖了所有用例。让我们前进吧!
3. 提供特定于语言环境的内容,例如日期,货币和数字
您的数据将根据区域设置以不同的方式表示。例如,俄语将显示500,00 $和10.12.2016,而美国英语将显示$ 500.00和12/10/2016。
React Intl为此类数据以及相对时间渲染提供了React组件,如果您不覆盖默认值,则会每10秒自动更新一次。
将此添加到src / components / App.jsx:
--- import { FormattedMessage, intlShape, injectIntl, defineMessages } from 'react-intl';
import {
FormattedDate,
FormattedRelative,
FormattedNumber,
FormattedMessage,
intlShape,
injectIntl,
defineMessages,
} from 'react-intl';
…
<div>{this.props.intl.formatMessage(messages.counting, { count: 5 })}</div>
<div><FormattedDate value={Date.now()} /></div>
<div><FormattedNumber value="1000" currency="USD" currencyDisplay="symbol" style="currency" /></div>
<div><FormattedRelative value={Date.now()} /></div>
刷新浏览器并检查页面。您需要等待10秒钟才能看到FormattedRelative组件已更新。
您可以在官方Wiki中找到更多示例。
酷吧?好吧,现在我们可能会面临另一个问题,这会影响通用渲染。

DateTime在服务器端和客户端都可能具有不同的值,根据定义,这将破坏通用呈现。为了解决这个问题,React Intl提供了一个特殊的属性initialNow。这提供了服务器时间戳,该时间戳最初将由客户端JavaScript用作时间戳。这样,服务器和客户端的校验和将相等。装入所有组件后,它们将使用浏览器的当前时间戳,一切将正常运行。因此,此技巧仅用于初始化客户端JavaScript,以保留通用呈现。
这是src / server.jsx:
--- function renderHTML(componentHTML, locale) {
function renderHTML(componentHTML, locale, initialNow) {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello React</title>
</head>
<body>
<div id="react-view">${componentHTML}</div>
<script type="application/javascript" src="${assetUrl}/public/assets/bundle.js"></script>
<script type="application/javascript">${localeData[locale]}</script>
<script type="application/javascript">window.INITIAL_NOW=${JSON.stringify(initialNow)}</script>
</body>
</html>
`;
}
const initialNow = Date.now();
const componentHTML = ReactDom.renderToString(
--- <IntlProvider locale={locale} messages={messages[locale]}>
<IntlProvider initialNow={initialNow} locale={locale} messages={messages[locale]}>
<App />
</IntlProvider>
);
res.cookie('locale', locale, { maxAge: (new Date() * 0.001) + (365 * 24 * 3600) });
--- return res.end(renderHTML(componentHTML, locale));
return res.end(renderHTML(componentHTML, locale, initialNow));
这里是src/client.jsx:
--- <IntlProvider locale={locale} messages={localeData}>
<IntlProvider initialNow={parseInt(window.INITIAL_NOW, 10)} locale={locale} messages={localeData}>
重新启动nodemon,问题几乎消失了!由于我们使用的是Date.now()而不是数据库提供的某些时间戳,因此它可能会持续存在。为了使示例更真实,请在app.jsx中将Date.now()替换为最近的时间戳,例如1480187019228。(当服务器无法以正确的格式呈现DateTime时,您可能会面临另一个问题,这也将破坏通用呈现。这是因为默认情况下,Node.js的版本4并未内置Intl支持。要解决此问题,遵循官方Wiki中描述的解决方案之一。)
4. 问题
到目前为止听起来不错,不是吗? 鉴于浏览器和平台的多样性,我们作为前端开发人员始终必须非常谨慎。React Intl使用本地Intl浏览器API来处理DateTime和Number格式。尽管事实上它是在2012年推出的,但并非所有现代浏览器都支持它。自iOS 10起,甚至Safari也仅部分支持它。以下是CanIUse的全部表格供参考。

这意味着,如果您愿意涵盖少数本机不支持Intl API的浏览器,则需要使用polyfill。幸运的是,有一个Intl.js。听起来似乎又是一个完美的解决方案,但是根据我的经验,它有其自身的缺点。首先,您需要将其添加到JavaScript包中,而且它很繁重。您还希望仅将polyfill交付给本机不支持Intl API的浏览器,以减小捆绑包的大小。所有这些技术都是众所周知的,您可能会在Intl.js的文档中找到它们以及如何使用webpack进行查找。但是,最大的问题是Intl.js并非100%准确,这意味着服务器和客户端之间的DataTime和Number表示形式可能会有所不同,这将再次破坏服务器端的呈现。请参阅相关的GitHub问题以获取更多详细信息。
我想出了另一种解决方案,该解决方案当然有其自身的缺点,但是对我来说效果很好。我实现了一个非常浅的polyfill,它只有一项功能。尽管在许多情况下肯定无法使用,但它只增加了2KB的捆绑包大小,因此甚至不需要为过时的浏览器实现动态代码加载,从而使整体解决方案更加简单。如果您认为此方法对您有用,请随意进行扩展。
结论
好吧,现在您可能会觉得事情变得太复杂了,并且您可能会想自己实现所有事情。我曾经做过一次;我不推荐。最终,您会在React Intl的实现背后得到相同的想法,或者更糟的是,您可能认为没有很多选择可以使某些事情变得更好或做不同的事情。
您可能认为可以依靠Moment.js来解决Intl API支持问题(我不会提及其他具有相同功能的库,因为它们不被支持或无法使用)。幸运的是,我尝试了一下,因此可以节省很多时间。我了解到Moment.js非常庞大,而且非常笨重,因此尽管它可能对某些人有用,但我不建议这样做。
开发自己的polyfill听起来并不好,因为您一定要在一段时间内与bug对抗并为解决方案提供支持。最重要的是,目前还没有完美的解决方案,因此请选择最适合您的解决方案。
(如果您在某个时候感到迷失或无法正常工作,请检查我的存储库的“解决方案”分支。)
希望本文能为您提供构建国际化React前端应用程序所需的所有知识。现在,您应该知道如何检测用户的语言环境,将其保存在Cookie中,让用户更改其语言环境,翻译用户界面,并以适当的格式呈现货币,日期时间和数字!现在,您还应该意识到可能会遇到的一些陷阱和问题,因此请选择适合您要求的选项,捆绑包大小的预算以及要支持的语言数量。