国际化和本地化React应用程序:I18N变得容易
#javascript #网络开发人员 #react #i18n

在我担任软件工程师职业生涯的开始时,我从事几种利基B2B产品,这些产品只与说英语并总部位于美国的最终用户。在2018年,当我获得激动人心的机会来娱乐自己的生活并搬到巴塞罗那以供typeform工作时,一切都改变了。突然,我不仅与来自数十个不同国家 /地区的其他前端开发人员组成的多样化团队,而且我还建立了一个需要为世界各地用户服务的产品。从那里开始,我开始在国际金融交易所工作,我们需要在那里提供财务数据,时间和日期,并以我们的用户习惯的语言和格式提供文本内容,并支持10种以上的语言。

与如此多样化的团队合作,在支持世界各地用户需要的产品上,我学到了一两件事,让整个全球用户的React应用程序感觉像是家。在这篇文章中,我想分享我学到的东西。

什么是国际化?

简单地说,国际化(有时缩短到I18N,其中18个代表“ I”和“ N”之间的字符数)是指developing software in a way that enables localization

本地化是指适应软件以满足目标市场的语言,文化和其他要求。这可以包括数字,日期和时间格式,分类注意事项,翻译等等。

处理日期和时间

很难高估本地日期和时间的重要性。对于来自美国的一个人,2012年1月2日是2022年1月2日。对澳大利亚的某人,这意味着2022年2月1日。除了在布局方面差异外,还有
分离器,不同单词和不同语言环境中使用的不同时钟周期的差异。

这怎么可能有问题?一个示例:一家总部位于美国的公司的Web应用程序告诉全球用户,需要在2022年1月2日采取重要的,需要采取行动的步骤。从快速浏览一下,欧洲或澳大利亚用户将其视为2月1日(而不是预期的1月2日),并只是忘记了它,直到1月3日在电子邮件中收到通知,他们未能按照截止日期执行所需的步骤。<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< /p>

试试看!

定位日期曾经很痛苦,并且可能涉及为不同地区生成不同的捆绑包,因为例如,诸如时刻之类的图书馆数据非常沉重。幸运的是,根据CanIUse的说法,在撰写本文时,浏览器对Intl.DateTimeFormat的支持大于97%。这意味着大多数应用程序应该能够利用浏览器的
日期本地化功能而无需依赖重型第三方依赖性。

我发现处理语言环境的日期和时间格式的最佳方法是创建一个利用Intl.DateTimeFormat的简单组件:

import React, { BaseHTMLAttributes, useMemo } from 'react';

interface Props extends BaseHTMLAttributes<HTMLTimeElement>, Intl.DateTimeFormatOptions {
  fractionalSecondDigits?: 1 | 2 | 3;
  value?: string | number | Date;
  locale?: string;
}

const getDateString = (value: Props['value']): string | undefined => {
  if (!value) {
    return undefined;
  }

  try {
    const valueAsDate = new Date(value);

    return valueAsDate.toISOString();
  } catch {
    return undefined;
  }
};

export const FormatDate: React.FC<Props> = ({
  fractionalSecondDigits,
  hour,
  minute,
  day,
  month,
  year,
  second,
  value,
  timeZoneName,
  locale = 'default',
  ...rest
}) => {
  const dateFormatter: Intl.DateTimeFormat = useMemo(
    () =>
      new Intl.DateTimeFormat(locale, {
        fractionalSecondDigits,
        hour,
        minute,
        day,
        month,
        year,
        second,
        timeZoneName,
      }),
    [fractionalSecondDigits, hour, minute, day, month, year, second, timeZoneName, locale],
  );
  const formattedValue = useMemo(() => {
    if (value) {
      try {
        const dateFromValue = new Date(value);
        return dateFormatter.format(dateFromValue);
      } catch {
        return '';
      }
    }

    return '';
  }, [dateFormatter, value]);

  return (
    // The dateTime attribute shows the ISO date being formatted with a quick DOM inspection
    <time dateTime={getDateString(value)} {...rest}>
      {formattedValue}
    </time>
  );
};

上面的组件代码用于您在上面的“尝试”部分中看到的日期。它像这样使用:

<FormatDate day="2-digit" month="2-digit" year="numeric" value={new Date()} />

有关更多可用选项,请参见full documentation

相对时间

许多应用程序利用相对时间,例如User commented 3 minutes agoLivestream starting in 42 seconds。对我们来说幸运的是,浏览器也为此提供了解决方案:Intl.RelativeTimeFormat

试试看!

Intl.DateTimeFormat一样,我发现创建一个简单的组件来利用Intl.RelativeTimeFormat

import React, { BaseHTMLAttributes, useMemo } from 'react';

/**
* style is omitted from Intl.RelativeTimeFormatOptions and renamed to formatStyle to prevent a clash with the
* BaseHTMLAttributes "style" (which would be the CSS style object)
*/
interface Props extends BaseHTMLAttributes<HTMLSpanElement>, Omit<Intl.RelativeTimeFormatOptions, 'style'> {
  formatStyle?: Intl.RelativeTimeFormatStyle;
  unit: Intl.RelativeTimeFormatUnit;
  value: number;
  locale?: string;
}

export const FormatRelativeTime: React.FC<Props> = ({ formatStyle, locale = 'default', numeric, unit, value, ...rest }) => {
  const relativeTimeFormatter: Intl.RelativeTimeFormat = useMemo(
    () =>
      new Intl.RelativeTimeFormat(locale, { style: formatStyle, numeric }),
    [locale, formatStyle, numeric],
  );

  return (
    // The unit and value data attributes make debugging easier with a quick DOM inspection
    <span data-unit={unit} data-value={value} {...rest}>
      {relativeTimeFormatter.format(value, unit)}
    </span>
  );
};

上面的组件代码用于您在上面的“尝试”部分中看到的日期。它像这样使用:

5分钟前

<FormatRelativeTime unit="minutes" value={-5}/>

在10秒内

<FormatRelativeTime unit="seconds" value={10}/>

有关更多可用选项,请参见full documentation

处理数字

与日期一样,不同的地区处理数字和货币格式的方式不同。例如,在美国,我们使用逗号
作为成千上万的分离器(1,024)和十进制分离器(3.14)。在欧洲的大部分地区,该时期用作数千个分离器(1.024),逗号用作小数分离器(3,14)。 Intl.NumberFormat具有出色的浏览器支持,可以处理货币格式,百分比格式,各种符号等等。

试试看!

与日期和时间格式一样,我发现最容易创建一个简单的组件来利用Intl.NumberFormat

import React, { BaseHTMLAttributes, useMemo } from 'react';

/**
* style is omitted from Intl.NumberFormatOptions and renamed to formatStyle to prevent a clash with the
* BaseHTMLAttributes "style" (which would be the CSS style object)
*/
interface Props extends BaseHTMLAttributes<HTMLSpanElement>, Omit<Intl.NumberFormatOptions, 'style'> {
  formatStyle?: string;
  locale?: string;
  value?: number;
}

export const FormatNumber: React.FC<Props> = ({
  currency,
  currencySign,
  useGrouping,
  minimumIntegerDigits,
  minimumFractionDigits,
  maximumFractionDigits,
  minimumSignificantDigits,
  maximumSignificantDigits,
  formatStyle,
  locale,
  value,
  ...rest
}) => {
  const numberFormatter: Intl.NumberFormat = useMemo(
    () =>
      new Intl.NumberFormat(locale, {
        style: formatStyle,
        currency,
        currencySign,
        useGrouping,
        minimumIntegerDigits,
        minimumFractionDigits,
        maximumFractionDigits,
        minimumSignificantDigits,
        maximumSignificantDigits,
      }),
    [
      formatStyle,
      locale,
      currency,
      currencySign,
      useGrouping,
      minimumIntegerDigits,
      minimumFractionDigits,
      maximumFractionDigits,
      minimumSignificantDigits,
      maximumSignificantDigits,
    ],
  );

  return (
    // The number data attribute makes debugging easier with a quick DOM inspection
    <span data-number={value?.toString()} {...rest}>
      {value === undefined || Number.isNaN(value) ? '' : numberFormatter.format(value)}
    </span>
  );
};

注意:如果您的应用程序需要支持格式化的加密货币价格或价值,则比特币实际上由Intl.NumberFormat API支持使用货币代码BTC,但许多鲜为人知的硬币项目却没有。在working with currency values in TypeScript上查看我的帖子以了解该解决方案。

有关更多可用选项,请参见full documentation

来自INTL的更多信息

近年来,Intl对象一直在开发,还有一些值得探索的功能:

  • Intl.Collator可用于语言敏感的字符串比较(即排序)。例如,这个因素和其他字符中的因素(例如,â,â,â,ð)。
  • Intl.ListFormat 对于将项目组格式化为字符串格式很有用。例如,德语和其他几种语言不使用牛津逗号,而英语则不使用。
  • Intl.PluralRules对于多元化项目很有用,因为多元化规则之间的多元化规则不同。它也可以用于序数值(例如,第1,第3,第5位)。
  • Intl.Segmenter是 有助于以环境敏感的方式分裂字符串。您是否知道有一些语言(例如日语,中文和泰语)不使用单词之间的空格?这意味着String.prototype.split(' ')实际上不会用这些语言中的句子中提供一系列单词。浏览器对Intl.Segmenter的支持还可以,但是在写作时特别缺少Firefox支持。

请注意,此功能中的某些功能也可以在下一节中建议的I18N库中找到。

翻译

截至2022年,there are 1.453 billion people in the world who speak English
其中只有大约26%是英语的人。全球人口约为80亿,这意味着全球人口的约18%说英语。这里有一个明确的业务案例,用于许多应用程序来支持其他语言。

接下来,我将分解如何服务和管理翻译。

服务翻译

我相信,对于JavaScript生态系统中的所有内容,我都会有很多有效的批评,但我坚信这是您想对开源社区的专家信任的情况。我在React应用中看到的最常见的库是react-i18next

初始化I18Next

首先,决定要如何使用翻译。它们可以捆绑到您的应用程序的入口点捆绑包中(可能不建议使用),通过您的public目录通过HTTP请求加载,或通过第三方服务加载。首先,我建议您通过HTTP后端从您的public目录提供服务,但是您可以找到可用的后端here的列表。

接下来,安装依赖项(在适用的首选后端替换):

yarn add react-i18next i18next i18next-chained-backend i18next-http-backend i18next-localstorage-backend i18next-browser-languagedetector

i18n.ts

import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-chained-backend';
import LocalStorageBackend from 'i18next-localstorage-backend';
import HTTPBackend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';

const backends = process.env.ENVIRONMENT === 'development'
    // in dev, always pull the latest translations, as they could be updating constantly
    ? [HTTPBackend]
    // in production, attempt to get locale storage-cached translations before hitting HTTP backend
    : [LocalStorageBackend, HTTPBackend];

i18n.use(Backend).use(LanguageDetector).use(initReactI18next).init({
    backend: {
      backends,
      backendOptions: [
        {
          prefix: '@@i18n_',
          expirationTime: 7 * 24 * 60 * 60 * 1000 * 7, // 1 week by default,
          store: window.sessionStorage,
          defaultVersion: process.env.CI_COMMIT_SHA, // This could be your app version
        },
        {
          loadPath: '/locales/{{lng}}/{{ns}}.json',
        },
      ],
    },
    supportedLngs: ['en-US', 'es-MX'],
    load: 'all',
    fallbackLng: 'en-US',
    ns: ['common'],
    defaultNS: 'common',
    interpolation: {
      escapeValue: false, // not needed for react, as it escapes by default
    },
});

export default i18n;

public/locales/en-us/common.json

{
  "greeting": "Hello {{name}}",
  "itemsToReview_one": "You have {{count}} item to review.",
  "itemsToReview_other": "You have {{count}} items to review.",
  "errors": {
    "userNotFound": "User not found",
    "urgentNotice": "<0>URGENT!</0> There was a problem. Click <1>here</1> for help."
  }
}

public/locales/es-mx/common.json

{
  "greeting": "Hola {{name}}",
  "itemsToReview_one": "Tienes {{count}} cosa que revisar.",
  "itemsToReview_other": "Tienes {{count}} cosas que revisar.",
  "errors": {
    "userNotFound": "Usuario no encontrado",
    "urgentNotice": "<0>¡Urgente!</0> Había un problema. Haga clic <1>aquí</1> para obtener ayuda."
  }
}

下一步是将您的i18n实例导入应用程序的输入点(index.tsx之类):

import './i18n';

完成此操作后,您可以开始使用useTranslation()钩子用翻译的字符串替换硬编码字符串:

import { useTranslation, Trans } from 'react-i18next';

interface Props {
  error: any;
  itemsToReview: number;
  name: string | undefined;
}

export const Greeting: React.FC<Props> = ({ error, itemsToReview, name }) => {
  const { t } = useTranslation('common');

  if (error) {
    return (
      <div>
        <Trans t={t} i18nKey="common:errors.urgentNotice">
          <strong />
          <a href="/support" />
        </Trans>

        <p>{t('common:errors.userNotFound')}</p>
      </div>
    );
  }

  return (
    <div>
      <span>{t('common:greeting', { name })}</span>
      <span>{t('common:itemsToReview', { count: itemsToReview })}</span>
    </div>
  );
};

在上面的示例中,您可以看到一些关键react-i18next概念的示例:

  • common,传递给useTranslation()并在i18nKey中备份的是名称空间。为了使i18Next从配置的后端加载名称空间,您需要将其传递给useTranslation(),该命名空间可以占用一系列名称空间。键始终以其命名空间为前缀,默认情况下,该键匹配了该文件的.json之前的文件空间,该文件位于。
  • useTranslation()返回的t是处理不需要插值的简单字符串的功能。第一个参数是i18nKey,第二个是键所需的变量对象。您还可以看到一个键common:itemsToReview的示例,该键根据计数具有不同的pluralization rules。请注意,某些语言比英语更复杂的复数规则。
  • koude26用于将更复杂的项目插入您的翻译中(例如React Elements)。在这种情况下,它用于将支持链接翻译为完整短语的一部分。

我喜欢使用react-i18next上的抽象组件来帮助与同行在整个业务中调试翻译问题。

import React, { BaseHTMLAttributes, Suspense } from 'react';
import { Trans, useTranslation } from 'react-i18next';

interface Props extends BaseHTMLAttributes<HTMLSpanElement> {
  element?:
    | 'span'
    | 'b'
    | 'strong'
    | 'em'
    | 'p'
    | 'h1'
    | 'h2'
    | 'h3'
    | 'h4'
    | 'h5'
    | 'h6';
  i18nKey: string;
  variables?: Record<string, any>;
}

const useTranslationProps = ({
  children,
  i18nKey,
  variables,
  ...rest
}: Omit<Props, 'element'>) => {
  const namespace = i18nKey.split(':')[0];
  const { t } = useTranslation(namespace);

  return {
    ...rest,
    'data-i18n-key': i18nKey,
    'children': children ? (
      <Trans t={t} i18nKey={i18nKey} values={variables}>
        {children}
      </Trans>
    ) : (
      t(i18nKey, variables)
    ),
  };
};

const TextInner: React.FC<Props> = ({ element = 'span', ...rest }) => {
  const props = useTranslationProps(rest);

  switch (element) {
    case 'b':
      return <b {...props} />;
    case 'strong':
      return <strong {...props} />;
    case 'em':
      return <em {...props} />;
    case 'p':
      return <p {...props} />;
    case 'h1':
      return <h1 {...props} />;
    case 'h2':
      return <h2 {...props} />;
    case 'h3':
      return <h3 {...props} />;
    case 'h4':
      return <h4 {...props} />;
    case 'h5':
      return <h5 {...props} />;
    case 'h6':
      return <h6 {...props} />;
    case 'span':
    default:
      return <span {...props} />;
  }
};

export const Text: React.FC<Props> = (props) => (
  // I would recommend having a higher-level Suspense with a loading spinner fallback
  <Suspense fallback="[...]">
    <TextInner {...props} />
  </Suspense>
);

这是什么成就:

  • 它为您提供一个一致的界面,将翻译的文本添加到您的界面中,无论插值如何。
  • 它将data-i18n-key数据属性添加到DOM中的翻译文本中,因此您的同行在整个业务中都可以将您指向引起麻烦的翻译密钥,无论他们使用哪种语言使用该应用程序。
  • 它可以确保将所需的名称空间加载到您的应用程序中。

简单示例使用上面的键

<Text i18nKey="common:greeting" variables={{name}}/>

插值使用键的示例

<Text i18nKey="common:errors.urgentNotice">
    <strong/>
    <a href="/support"/>
</Text>

使用适当的lang属性更新html标签

使用适当的lang属性更新应用程序的html标签很重要,因为它为屏幕读取器和其他辅助技术提供了上下文。这是一个可以做到这一点的示例钩子:

import { useEffect } from 'react';
import { useTranslation } from 'react-18next';

export const useUpdateHTMLLanguage = () => {
  const { i18n } = useTranslation();

  useEffect(() => {
    document.documentElement.setAttribute('lang', i18n.resolvedLanguage);
  }, [i18n.resolvedLanguage]);
}

翻译管理

实际上在工程团队中不太可能在各个地区写下翻译,但我会分享我所看过的工作。

工程团队应致力于为一个语言环境添加新的翻译键。在这种情况下,让我们假设为此示例假设en-US。随着工程师的工作,他们会将副本添加到locales/en-US.json文件中,并继续进行工作。当他们为development环境上游上游打开拉动请求时,脚本可以在CI中运行,该脚本将检查新创建的键。另外,您可以考虑像translation-check这样的工具,该工具将创建一个简单的仪表板,您可以在其中概述Locale的丢失翻译。

还有许多第三方服务提供翻译管理和协作仪表板(找到,机车,Lokalise等)。

布局转移

值得注意的是,布局仅由默认语言设计,通常是英语。有许多语言可以比英语更详细。例如,西班牙语,葡萄牙语和俄语通常比英语更耗时,并且可能引起溢出问题。不幸的是,没有一个很好的方法来防止这种情况。我建议以一种对翻译人员不言自明的方式命名您的翻译键,以确保他们在制作翻译时会在空间限制中受到限制。

我没有找到用于测试溢出的出色自动化工具,但是我建议您在预发行过程的一部分中手动测试所有语言的翻译添加。当然,要关注的最重要领域将取决于您的应用程序,但是我经常看到标题和页脚导航溢出的问题。布局元素是最低的悬挂果实。

左右(RTL)

尽管大多数语言是从左到右(LTR)编写的,但有一些从右到左的著名语言(例如,阿拉伯语和希伯来语)。我还不需要在一个有业务需求的应用程序上工作以支持RTL,但是据我了解,有一些关键概念要牢记。

使用适当的dir属性更新html标签

该页面的html标签中的dir属性应设置为rtl,以供右至左语言,否则ltrauto或unset。

这是一个可以做到这一点的示例钩子:

import { useEffect } from 'react';
import { useTranslation } from 'react-18next';

export const useUpdateHTMLDirection = () => {
  const { i18n } = useTranslation();
  const dir = i18n.dir(); // Where dir will be 'ltr' or 'rtl';

  useEffect(() => {
    document.documentElement.setAttribute('dir', dir);
  }, [dir]);
}

请注意,这也可以与挂钩结合使用,以更新html标签上的语言。

,如果您的应用程序的部分需要从右到左键,则可以在整个页面上使用dir属性。

翻转布局

许多网站使用的"F" layout非常适合从左到右阅读的语言,但是对于从右到左的读取您的网站的用户应该翻转它。如果您使用基于Flexbox或基于网格的布局,则可以无需付出很多努力就能完成。例如:

.flex-layout {
  display: flex;
  flex-direction: row;
}

.grid-layout {
  display: grid;
  grid-template-columns: 320px 1fr;
}

[dir="rtl"] .flex-layout {
  flex-direction: row-reverse;
}

[dir="rtl"] .grid-layout {
  grid-template-columns: 1fr 320px;
}

重新调整文本

对于左至左语言,您可能想翻转标准的左右模板中的文本对齐方式,以更好地匹配翻转布局。您可以通过进行以下替换来轻松完成此操作:

.align-start {
  // text-align: left; Before
  text-align: start; // After
}

.align-end {
  // text-align: right; Before
  text-align: end; // After
}

修复间距

而不是使用Hard *-left*-right间距值(保证金和填充),您将需要使用以下
考虑写作方向性的替代品:

进一步阅读:垂直写作

某些语言在传统上是垂直写的。我还没有从事支持垂直写作的应用程序,但是我想呼吁这是另一种可能需要考虑的情况,并且通过writing-mode在CSS中提供了支持。

结论

虽然文化,传统和语言的多样性是使世界如此美丽的某些事物,但它们可能会给毫无戒心的工程师带来很多麻烦。期望任何单一工程师甚至团队都保留手动化申请所需的所有信息,这是不现实的。幸运的是,国际化和本地化应用程序从未比今天更容易,而Intl获得了良好的浏览器支持并迅速发展。如果您认为我错过或错了,请在Twitter上与我联系并分享@joshuaslate