Angular

FormatJS — набор библиотек для интернационализации JavaScript-приложений. В экосистеме Angular чаще всего используются:

  • react-intl — для React;
  • intl-messageformat — ядро форматирования сообщений;
  • @formatjs/intl — полифилы ECMAScript Intl API;
  • @formatjs/cli — извлечение и компиляция переводов;
  • babel-plugin-formatjs — анализ и оптимизация сообщений.

В Angular FormatJS применяется не как готовый Angular-фреймворк, а как низкоуровневая инфраструктура интернационализации.

Основные задачи:

  • локализация текстов;
  • форматирование дат;
  • форматирование чисел и валют;
  • pluralization;
  • выбор сообщений по полу и контексту;
  • runtime-переключение языка;
  • извлечение переводов в отдельные файлы.

Установка зависимостей

Базовые пакеты

npm install intl-messageformat @formatjs/intl

Для поддержки старых браузеров:

npm install @formatjs/intl-pluralrules
npm install @formatjs/intl-numberformat
npm install @formatjs/intl-datetimeformat

CLI для извлечения переводов:

npm install --save-dev @formatjs/cli

Intl API как фундамент FormatJS

FormatJS построен поверх ECMAScript Intl API.

Angular-приложение использует:

  • Intl.NumberFormat
  • Intl.DateTimeFormat
  • Intl.RelativeTimeFormat
  • Intl.PluralRules
  • Intl.ListFormat

Пример форматирования числа:

const formatter = new Intl.NumberFormat('ru-RU', {
  style: 'currency',
  currency: 'RUB'
});

console.log(formatter.format(1500));

Результат:

1 500,00 ₽

Структура локализации

Типичная структура:

src/
 ├── app/
 ├── i18n/
 │    ├── en.json
 │    ├── ru.json
 │    └── kk.json
 └── assets/

Пример ru.json:

{
  "app.title": "Панель управления",
  "menu.home": "Главная",
  "menu.profile": "Профиль"
}

Пример en.json:

{
  "app.title": "Dashboard",
  "menu.home": "Home",
  "menu.profile": "Profile"
}

Создание сервиса локализации

LocalizationService

import { Injectable } from '@angular/core';
import { IntlMessageFormat } from 'intl-messageformat';

@Injectable({
  providedIn: 'root'
})
export class LocalizationService {

  private locale = 'ru';

  private messages: Record<string, string> = {};

  setLocale(locale: string): void {
    this.locale = locale;
  }

  loadMessages(messages: Record<string, string>): void {
    this.messages = messages;
  }

  translate(
    key: string,
    values?: Record<string, unknown>
  ): string {

    const message = this.messages[key];

    if (!message) {
      return key;
    }

    const formatter = new IntlMessageFormat(
      message,
      this.locale
    );

    return formatter.format(values) as string;
  }
}

Использование сервиса

Компонент

import { Component } from '@angular/core';
import { LocalizationService } from './localization.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html'
})
export class AppComponent {

  constructor(
    public i18n: LocalizationService
  ) {}
}

Шаблон

<h1>
  {{ i18n.translate('app.title') }}
</h1>

ICU Message Syntax

FormatJS использует ICU MessageFormat.

Поддерживаются:

  • переменные;
  • plural;
  • select;
  • вложенные конструкции.

Интерполяция переменных

Сообщение

{
  "user.welcome": "Здравствуйте, {name}"
}

Использование

this.i18n.translate('user.welcome', {
  name: 'Алексей'
});

Результат:

Здравствуйте, Алексей

Plural Rules

Pluralization — одна из главных возможностей FormatJS.

Английский язык

{
  "cart.items": "{count, plural, =0 {No items} one {# item} other {# items}}"
}

Использование

this.i18n.translate('cart.items', {
  count: 5
});

Русские plural-формы

Русский язык содержит несколько форм множественного числа.

{
  "notifications":
    "{count, plural, " +
    "one {# уведомление} " +
    "few {# уведомления} " +
    "many {# уведомлений} " +
    "other {# уведомления}}"
}

Примеры:

1 уведомление
2 уведомления
5 уведомлений
21 уведомление

Sel ect Messages

Выбор сообщений по условию.

{
  "user.gender":
    "{gender, select, " +
    "male {Он вошёл} " +
    "female {Она вошла} " +
    "other {Они вошли}}"
}

Использование:

this.i18n.translate('user.gender', {
  gender: 'female'
});

Вложенные конструкции

ICU поддерживает сложную вложенность.

{
  "complex":
    "{gender, select, " +
    "male {{count, plural, one {Он добавил # файл} other {Он добавил # файлов}}} " +
    "female {{count, plural, one {Она добавила # файл} other {Она добавила # файлов}}} " +
    "other {Добавлено # файлов}}"
}

Форматирование дат

Intl.DateTimeFormat

const formatter = new Intl.DateTimeFormat('ru-RU', {
  dateStyle: 'full',
  timeStyle: 'short'
});

console.log(formatter.format(new Date()));

Интеграция в сервис

formatDate(date: Date): string {
  return new Intl.DateTimeFormat(this.locale, {
    dateStyle: 'long'
  }).format(date);
}

Использование:

<p>{{ i18n.formatDate(today) }}</p>

Форматирование валют

formatCurrency(
  value: number,
  currency: string
): string {

  return new Intl.NumberFormat(this.locale, {
    style: 'currency',
    currency
  }).format(value);
}

Форматирование процентов

new Intl.NumberFormat('ru-RU', {
  style: 'percent'
}).format(0.25);

Результат:

25 %

Relative Time Format

Позволяет отображать:

  • «5 минут назад»;
  • «через 2 дня»;
  • «1 неделю назад».

Пример

const formatter = new Intl.RelativeTimeFormat('ru', {
  numeric: 'auto'
});

formatter.format(-1, 'day');

Результат:

вчера

Dynamic Locale Loading

Angular-приложения часто загружают переводы лениво.

Загрузка JSON

async loadLocale(locale: string) {

  const messages = await import(
    `../. ./i18n/${locale}.json`
  );

  this.locale = locale;

  this.messages = messages.default;
}

Runtime-переключение языка

Компонент переключения

switchLocale(locale: string) {
  this.i18n.loadLocale(locale);
}

HTML

<button (click)="switchLocale('ru')">
  RU
</button>

<button (click)="switchLocale('en')">
  EN
</button>

Angular Pipe для FormatJS

Создание pipe

import { Pipe, PipeTransform } fr om '@angular/core';
import { LocalizationService } fr om './localization.service';

@Pipe({
  name: 't',
  pure: false
})
export class TranslatePipe
  implements PipeTransform {

  constructor(
    private i18n: LocalizationService
  ) {}

  transform(
    key: string,
    values?: Record<string, unknown>
  ): string {

    return this.i18n.translate(key, values);
  }
}

Использование pipe

<h1>{{ 'app.title' | t }}</h1>

С параметрами:

<p>
  {{
    'user.welcome'
      | t:{ name: 'Иван' }
  }}
</p>

Change Detection и локализация

Pipe должен быть pure: false, иначе Angular не обновит UI после смены языка.

Недостаток:

  • увеличение количества проверок;
  • дополнительные вызовы форматирования.

Оптимизация:

  • кэширование formatter-объектов;
  • memoization;
  • OnPush change detection.

Кэширование formatter-объектов

Создание IntlMessageFormat — дорогая операция.

Оптимизированный сервис

private cache = new Map<
  string,
  IntlMessageFormat
>();

translate(
  key: string,
  values?: Record<string, unknown>
): string {

  const cacheKey =
    `${this.locale}:${key}`;

  let formatter =
    this.cache.get(cacheKey);

  if (!formatter) {

    formatter =
      new IntlMessageFormat(
        this.messages[key],
        this.locale
      );

    this.cache.set(
      cacheKey,
      formatter
    );
  }

  return formatter.format(values) as string;
}

Lazy Translation Modules

Большие приложения разделяют переводы по feature-модулям.

Структура:

i18n/
 ├── common/
 ├── dashboard/
 ├── auth/
 └── admin/

Feature-based локализация

Dashboard translations

{
  "dashboard.title": "Статистика",
  "dashboard.users": "Пользователи"
}

Auth translations

{
  "auth.login": "Вход",
  "auth.logout": "Выход"
}

Namespace-подход

Полезно использовать namespace.

Пример:

auth.login
auth.logout
dashboard.title

Это предотвращает конфликты ключей.


Автоматическое извлечение сообщений

FormatJS CLI умеет извлекать сообщения из кода.


Формат сообщений в коде

const messages = {
  title: {
    id: 'app.title',
    defaultMessage: 'Dashboard'
  }
};

Извлечение

formatjs extract "src/**/*.{ts,html}" \
  --out-file lang/en.json

Компиляция переводов

formatjs compile lang/en.json \
  --out-file dist/en.json

Оптимизация production-сборки

Предкомпиляция сообщений

Без компиляции ICU-парсер работает runtime.

С предкомпиляцией:

  • меньше нагрузка;
  • быстрее рендеринг;
  • меньше parsing overhead.

SSR и Angular Universal

FormatJS полностью совместим с SSR.

Особенности:

  • локаль определяется на сервере;
  • HTML генерируется уже локализованным;
  • уменьшается layout shift.

Определение локали на сервере

const locale =
  request.headers['accept-language'];

Hydration и локализация

Критически важно:

  • сервер и клиент должны использовать одинаковую локаль;
  • одинаковые translation bundles;
  • одинаковые ICU messages.

Иначе Angular hydration завершится ошибкой.


Полифилы Intl API

Некоторые среды не поддерживают:

  • RelativeTimeFormat;
  • ListFormat;
  • DisplayNames.

Подключение полифилов

import '@formatjs/intl-pluralrules/polyfill';
import '@formatjs/intl-relativetimeformat/polyfill';

Locale Data

Для некоторых полифилов нужны locale-data.

import '@formatjs/intl-relativetimeformat/locale-data/ru';
import '@formatjs/intl-relativetimeformat/locale-data/en';

Локализация маршрутов

Пример:

/ru/dashboard
/en/dashboard
/kk/dashboard

Route-based locale detection

const locale =
  this.route.snapshot.paramMap.get('locale');

SEO и локализация

Для multilingual-приложений важны:

  • hreflang;
  • локализованные URL;
  • SSR;
  • translated meta tags.

Локализация meta-тегов

this.title.setTitle(
  this.i18n.translate('meta.home.title')
);

Ошибки локализации

Типичные проблемы:

Отсутствующие ключи

if (!message) {
  console.warn(`Missing: ${key}`);
}

Некорректные ICU expressions

Ошибка:

EXPECT_ARGUMENT_CLOSING_BRACE

Причина:

"{count, plural, one {item}"

Пропущена закрывающая фигурная скобка.


XSS и безопасность

FormatJS не экранирует HTML автоматически.

Опасно:

{
  "danger": "<script>alert(1)</script>"
}

Нельзя вставлять переводы через:

[innerHTML]

без sanitization.


Type-safe локализация

Генерация union-типов

export type TranslationKey =
  | 'app.title'
  | 'menu.home'
  | 'menu.profile';

Типизированный translate

translate(
  key: TranslationKey
): string

Enum-подход

export enum TranslationKeys {
  TITLE = 'app.title',
  HOME = 'menu.home'
}

Unit-тестирование

Тест сервиса

describe('LocalizationService', () => {

  it('should translate message', () => {

    service.loadMessages({
      hello: 'Привет'
    });

    expect(
      service.translate('hello')
    ).toBe('Привет');
  });
});

Тестирование pluralization

service.loadMessages({
  items:
    '{count, plural, one {# item} other {# items}}'
});

expect(
  service.translate('items', {
    count: 5
  })
).toBe('5 items');

E2E-тестирование локализации

Проверяются:

  • переключение языка;
  • обновление UI;
  • корректность plural forms;
  • даты;
  • валюты;
  • RTL-режимы.

RTL-поддержка

Для арабского и иврита:

<html dir="rtl">

Angular может динамически менять направление:

document.documentElement.dir = 'rtl';

Intl.ListFormat

Форматирование списков.

new Intl.ListFormat('ru', {
  style: 'long',
  type: 'conjunction'
}).format([
  'Angular',
  'React',
  'Vue'
]);

Результат:

Angular, React и Vue

Intl.DisplayNames

Локализованные имена языков и стран.

const formatter =
  new Intl.DisplayNames(['ru'], {
    type: 'language'
  });

formatter.of('en');

Результат:

английский

Intl.Segmenter

Разбиение текста на сегменты.

const segmenter =
  new Intl.Segmenter('ru', {
    granularity: 'word'
  });

Производительность FormatJS

Основные затраты:

  • ICU parsing;
  • создание formatter-объектов;
  • change detection;
  • загрузка translation bundles.

Практики оптимизации

Предкомпиляция

Снижает runtime overhead.

Memoization

Повторное использование formatter instances.

Lazy loading

Загрузка переводов по модулям.

CDN-кэширование

Translation bundles удобно хранить на CDN.


Сравнение Angular i18n и FormatJS

Возможность Angular i18n FormatJS
Runtime switching Нет Да
ICU syntax Ограничено Полноценная
Lazy translations Ограничено Да
Dynamic locale Нет Да
SSR Да Да
Pluralization Да Да
Runtime API Слабая Гибкая

Когда FormatJS особенно полезен

Наиболее подходящие сценарии:

  • SaaS-платформы;
  • мультиязычные dashboard-системы;
  • enterprise Angular-приложения;
  • приложения с runtime-переключением языка;
  • SSR-системы;
  • микрофронтенды;
  • сложная pluralization-логика;
  • динамическая загрузка переводов.