Vanilla JavaScript

FormatJS — набор библиотек для интернационализации JavaScript-приложений. Основная задача — локализация текста, форматирование дат, чисел, валют, относительного времени и сообщений ICU MessageFormat без привязки к React, Vue или другим фреймворкам.

В контексте Vanilla JavaScript чаще всего используются:

  • intl-messageformat
  • @formatjs/intl
  • @formatjs/intl-relativetimeformat
  • @formatjs/intl-numberformat
  • @formatjs/intl-datetimeformat

FormatJS опирается на стандартный API Intl, встроенный в современные браузеры.

Базовая установка:

npm install intl-messageformat

Либо подключение через CDN:

<script src="https://unpkg.com/intl-messageformat/dist/umd/intl-messageformat.min.js"></script>

Проблемы интернационализации без специализированных библиотек

При ручной локализации появляются типичные сложности:

  • множественные формы слов;
  • различия грамматики языков;
  • порядок слов;
  • форматирование валют;
  • локальные форматы дат;
  • склонения;
  • pluralization rules.

Пример ручного подхода:

function getMessage(count) {
    if (count === 1) {
        return `${count} file`;
    }

    return `${count} files`;
}

Такой код быстро становится неуправляемым при поддержке нескольких языков.

FormatJS решает проблему через ICU MessageFormat.


ICU MessageFormat

ICU MessageFormat — стандарт описания локализуемых строк.

Простейший пример:

const message = 'Hello, {name}!';

Подстановка значений:

import {IntlMessageFormat} from 'intl-messageformat';

const msg = new IntlMessageFormat(
    'Hello, {name}!',
    'en'
);

console.log(
    msg.format({
        name: 'John'
    })
);

Результат:

Hello, John!

Создание системы локализации

Структура переводов

Обычно переводы разделяются по языкам.

const messages = {
    en: {
        greeting: 'Hello, {name}!'
    },

    ru: {
        greeting: 'Привет, {name}!'
    }
};

Определение текущей локали

const locale = navigator.language;

Иногда локаль нормализуется:

const locale = navigator.language.split('-')[0];

Универсальная функция перевода

import {IntlMessageFormat} from 'intl-messageformat';

function translate(locale, key, values = {}) {
    const message = messages[locale][key];

    const formatter = new IntlMessageFormat(
        message,
        locale
    );

    return formatter.format(values);
}

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

translate('ru', 'greeting', {
    name: 'Алексей'
});

Форматирование чисел

Intl.NumberFormat

FormatJS активно использует стандартный API:

const formatter = new Intl.NumberFormat('ru-RU');

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

Результат:

1 234 567

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

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

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

Результат:

1 500,00 ₽

Валюты разных стран

const currencies = [
    ['en-US', 'USD'],
    ['de-DE', 'EUR'],
    ['ja-JP', 'JPY']
];

currencies.forEach(([locale, currency]) => {
    const formatter = new Intl.NumberFormat(
        locale,
        {
            style: 'currency',
            currency
        }
    );

    console.log(
        formatter.format(1000)
    );
});

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

Intl.DateTimeFormat

const formatter = new Intl.DateTimeFormat(
    'ru-RU'
);

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

Настройка отображения

const formatter = new Intl.DateTimeFormat(
    'ru-RU',
    {
        year: 'numeric',
        month: 'long',
        day: 'numeric'
    }
);

Пример результата:

15 января 2026 г.

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

const formatter = new Intl.DateTimeFormat(
    'en-US',
    {
        hour: 'numeric',
        minute: 'numeric',
        second: 'numeric'
    }
);

Relative Time Format

Для отображения относительного времени используется Intl.RelativeTimeFormat.

Установка

npm install @formatjs/intl-relativetimeformat

Пример использования

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

console.log(
    rtf.format(-1, 'day')
);

Результат:

вчера

Разные единицы измерения

console.log(rtf.format(-5, 'minute'));
console.log(rtf.format(2, 'hour'));
console.log(rtf.format(3, 'day'));

Plural Rules

Разные языки имеют разные правила множественного числа.

Проблема

Английский:

1 file
2 files

Русский:

1 файл
2 файла
5 файлов

Решение через ICU

const message = `
{count, plural,
    one {# файл}
    few {# файла}
    many {# файлов}
    other {# файла}
}
`;

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

const msg = new IntlMessageFormat(
    message,
    'ru'
);

console.log(
    msg.format({
        count: 1
    })
);

console.log(
    msg.format({
        count: 3
    })
);

console.log(
    msg.format({
        count: 10
    })
);

Sel ect Format

select используется для выбора текста по условию.

Пример пола пользователя

const message = `
{gender, select,
    male {Он}
    female {Она}
    other {Они}
} вошёл в систему
`;

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

const msg = new IntlMessageFormat(
    message,
    'ru'
);

console.log(
    msg.format({
        gender: 'male'
    })
);

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

ICU позволяет комбинировать plural и sel ect.

Сложный пример

const message = `
{gender, select,
    male {
        {count, plural,
            one {Он загрузил # файл}
            few {Он загрузил # файла}
            many {Он загрузил # файлов}
            other {Он загрузил # файла}
        }
    }

    female {
        {count, plural,
            one {Она загрузила # файл}
            few {Она загрузила # файла}
            many {Она загрузила # файлов}
            other {Она загрузила # файла}
        }
    }

    other {
        Загружено файлов: {count}
    }
}
`;

Работа с HTML

Экранирование

FormatJS не предназначен для прямой генерации HTML.

Небезопасный пример:

element.innerHTML = translate(
    'ru',
    'welcome',
    {
        name: userInput
    }
);

Если userInput содержит HTML, возможна XSS-атака.


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

element.textContent = translate(
    'ru',
    'welcome',
    {
        name: userInput
    }
);

Кэширование форматтеров

Создание экземпляров IntlMessageFormat — дорогостоящая операция.

Неэффективный подход

function t(locale, key, values) {
    const formatter = new IntlMessageFormat(
        messages[locale][key],
        locale
    );

    return formatter.format(values);
}

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

const cache = new Map();

function getFormatter(locale, key) {
    const cacheKey = `${locale}:${key}`;

    if (!cache.has(cacheKey)) {
        cache.set(
            cacheKey,
            new IntlMessageFormat(
                messages[locale][key],
                locale
            )
        );
    }

    return cache.get(cacheKey);
}

function t(locale, key, values) {
    return getFormatter(
        locale,
        key
    ).format(values);
}

Lazy Loading переводов

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

Динамический импорт

async function loadLocale(locale) {
    const module = await import(
        `./locales/${locale}.js`
    );

    return module.default;
}

Переключение языка

let currentMessages = {};

async function setLocale(locale) {
    currentMessages = await loadLocale(locale);
}

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

Intl.ListFormat

const formatter = new Intl.ListFormat(
    'ru',
    {
        style: 'long',
        type: 'conjunction'
    }
);

console.log(
    formatter.format([
        'JavaScript',
        'TypeScript',
        'Python'
    ])
);

Результат:

JavaScript, TypeScript и Python

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

Number Range

const formatter = new Intl.NumberFormat(
    'en',
    {
        style: 'currency',
        currency: 'USD'
    }
);

console.log(
    formatter.formatRange(10, 20)
);

Работа с часовыми поясами

const formatter = new Intl.DateTimeFormat(
    'en-US',
    {
        timeZone: 'Asia/Almaty',
        timeStyle: 'full'
    }
);

Поддержка старых браузеров

Некоторые возможности Intl отсутствуют в старых браузерах.

Для этого используются polyfill-пакеты FormatJS.

Пример

npm install @formatjs/intl-pluralrules

Подключение:

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

Проверка поддержки API

if (!Intl.RelativeTimeFormat) {
    await import(
        '@formatjs/intl-relativetimeformat/polyfill'
    );
}

Организация переводов

Плоская структура

{
    greeting: 'Hello',
    logout: 'Logout'
}

Вложенная структура

{
    auth: {
        login: 'Login',
        logout: 'Logout'
    }
}

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

{
    common: {
        save: 'Save'
    },

    profile: {
        edit: 'Edit profile'
    }
}

Интерполяция значений

Несколько параметров

const message = `
{name} has {count} new messages
`;

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

msg.format({
    name: 'John',
    count: 5
});

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

ICU поддерживает встроенное форматирование.

Числа

const message = `
Balance: {value, number}
`;

Валюты

const message = `
Price: {price, number, ::currency/USD}
`;

Проценты

const message = `
Completed: {percent, number, percent}
`;

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

const message = `
Today: {today, date, long}
`;

Время

const message = `
Current time: {now, time, short}
`;

Компиляция сообщений

В крупных проектах сообщения компилируются заранее.

Причины

  • ускорение рендеринга;
  • уменьшение нагрузки;
  • оптимизация bundle size;
  • раннее обнаружение ошибок.

Babel Plugin

npm install babel-plugin-formatjs

Конфигурация

{
    "plugins": [
        [
            "formatjs",
            {
                "idInterpolationPattern": "[sha512:contenthash:base64:6]"
            }
        ]
    ]
}

Извлечение переводов

FormatJS поддерживает автоматический extraction сообщений.

Пример команды

formatjs extract "src/**/*.js"

Проверка ошибок локализации

Валидация ICU

formatjs compile-folder locales compiled

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

Не только текст интерфейса требует перевода.

Placeholder

input.placeholder = t(
    'ru',
    'search_placeholder'
);

Title

button.title = t(
    'ru',
    'save_button_title'
);

Форматирование единиц измерения

const formatter = new Intl.NumberFormat(
    'ru',
    {
        style: 'unit',
        unit: 'kilometer'
    }
);

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

Compact Notation

const formatter = new Intl.NumberFormat(
    'en',
    {
        notation: 'compact'
    }
);

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

Результат:

1.5M

Локализация ошибок

Пример

const messages = {
    ru: {
        required: 'Поле обязательно',
        invalid_email: 'Некорректный email'
    }
};

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

throw new Error(
    t('ru', 'invalid_email')
);

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

Обновление интерфейса

async function changeLanguage(locale) {
    currentLocale = locale;

    await setLocale(locale);

    render();
}

Синхронизация с HTML

<html lang="ru">

Обновление:

document.documentElement.lang = locale;

Fallback Locale

Если перевод отсутствует:

function t(locale, key, values) {
    const message =
        messages[locale]?.[key]
        || messages.en[key];

    const formatter = new IntlMessageFormat(
        message,
        locale
    );

    return formatter.format(values);
}

Обработка отсутствующих ключей

Диагностика

function t(locale, key, values) {
    const message = messages[locale]?.[key];

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

        return key;
    }

    return new IntlMessageFormat(
        message,
        locale
    ).format(values);
}

Производительность локализации

Наиболее затратные операции:

  • создание formatter-объектов;
  • парсинг ICU;
  • загрузка переводов;
  • runtime-компиляция.

Основные методы оптимизации:

  • кэширование;
  • precompile;
  • lazy loading;
  • разделение namespace;
  • memoization.

Типичные ошибки

Хардкод текста

Плохой пример:

button.textContent = 'Save';

Правильный вариант:

button.textContent = t(
    currentLocale,
    'save'
);

Склейка строк

Ошибка:

'Hello ' + userName

Правильный вариант:

'Hello, {name}'

Игнорирование plural rules

Ошибка:

`${count} items`

Правильный вариант:

{count, plural,
    one {# item}
    other {# items}
}

Минимальная архитектура i18n-движка

translations.js

export default {
    en: {
        hello: 'Hello'
    },

    ru: {
        hello: 'Привет'
    }
};

i18n.js

import {IntlMessageFormat} fr om 'intl-messageformat';
import messages fr om './translations.js';

const cache = new Map();

let locale = 'en';

export function setLocale(newLocale) {
    locale = newLocale;
}

function getFormatter(key) {
    const cacheKey = `${locale}:${key}`;

    if (!cache.has(cacheKey)) {
        cache.set(
            cacheKey,
            new IntlMessageFormat(
                messages[locale][key],
                locale
            )
        );
    }

    return cache.get(cacheKey);
}

export function t(key, values = {}) {
    return getFormatter(key)
        .format(values);
}

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

import {t} fr om './i18n.js';

title.textContent = t('hello');

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

Атрибут data-i18n

HTML:

<h1 data-i18n="title"></h1>
<button data-i18n="save"></button>

Автоматический рендер

function renderTranslations() {
    const elements = document.querySelectorAll(
        '[data-i18n]'
    );

    elements.forEach(element => {
        const key = element.dataset.i18n;

        element.textContent = t(key);
    });
}

Локализация динамического контента

function createNotification(count) {
    const text = t(
        'notifications',
        {count}
    );

    const div = document.createElement('div');

    div.textContent = text;

    return div;
}

Поддержка RTL-языков

Для арабского и иврита требуется изменение направления текста.

document.documentElement.dir = 'rtl';

Для обычных языков:

document.documentElement.dir = 'ltr';

Intl.Locale

Современный API для работы с локалями.

Пример

const locale = new Intl.Locale('ru-RU');

console.log(locale.language);
console.log(locale.region);

Segmenter API

Разделение текста по словам и предложениям.

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

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

const formatter = new Intl.NumberFormat(
    'ru',
    {
        style: 'percent'
    }
);

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

Scientific Notation

const formatter = new Intl.NumberFormat(
    'en',
    {
        notation: 'scientific'
    }
);

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

Engineering Notation

const formatter = new Intl.NumberFormat(
    'en',
    {
        notation: 'engineering'
    }
);

Locale Matcher

const supportedLocales = ['en', 'ru'];

const locale = Intl.NumberFormat.supportedLocalesOf(
    navigator.languages
)[0] || 'en';

Частичная локализация

Некоторые приложения переводят только интерфейс:

{
    ru: {
        save: 'Сохранить'
    }
}

Другие локализуют:

  • ошибки;
  • уведомления;
  • email;
  • PDF;
  • отчёты;
  • логи;
  • серверные сообщения.

Server-Side локализация

FormatJS может использоваться не только в браузере.

Node.js

import {IntlMessageFormat} from 'intl-messageformat';

const msg = new IntlMessageFormat(
    'Hello, {name}',
    'en'
);

console.log(
    msg.format({
        name: 'Admin'
    })
);

Миграция с самописной i18n-системы

Обычно процесс включает:

  1. перенос строк в словари;
  2. замену хардкода;
  3. внедрение ICU;
  4. добавление plural rules;
  5. lazy loading;
  6. precompile.

Практический пример локализованного интерфейса

translations.js

export default {
    en: {
        title: 'Dashboard',
        files: `
            {count, plural,
                one {# file}
                other {# files}
            }
        `
    },

    ru: {
        title: 'Панель управления',
        files: `
            {count, plural,
                one {# файл}
                few {# файла}
                many {# файлов}
                other {# файла}
            }
        `
    }
};

app.js

import {IntlMessageFormat} from 'intl-messageformat';
import translations from './translations.js';

let locale = 'ru';

const cache = new Map();

function t(key, values = {}) {
    const cacheKey = `${locale}:${key}`;

    if (!cache.has(cacheKey)) {
        cache.set(
            cacheKey,
            new IntlMessageFormat(
                translations[locale][key],
                locale
            )
        );
    }

    return cache.get(cacheKey)
        .format(values);
}

document.querySelector('#title')
    .textContent = t('title');

document.querySelector('#files')
    .textContent = t('files', {
        count: 21
    });