Автор:
    Создание:2026-09-02Последнее обновление:2026-09-02

    Форматирование дат и чисел по локалям с помощью Intl

    Перевод строк — это лишь видимая верхушка айсберга интернационализации. Вторая половина, порождающая регулярные баг-репорты, это форматирование: когда пользователь из Германии видит 1,234.56 вместо 1.234,56, пользователь из Японии видит 08/02/2026 и читает это как август, или когда дата по-разному отображается на сервере и клиенте, ломая гидратацию React.

    Ни для чего из этого не нужны сторонние библиотеки. Стандартный Intl встроен в любую современную среду исполнения.

    Содержание

    Начните с удаления самописных хелперов для дат

    Практически в любой кодовой базе найдется функция formatDate, написанная задолго до того, как кто-то задумался о локализации. В ней захардкожен жесткий порядок, разделитель и, как правило, английские названия месяцев.

    ts
    // То, что нужно удалить:
    const formatDate = (d: Date) =>
      `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`;
    

    Intl.DateTimeFormat заменяет ее полностью и работает корректно для любой локали:

    ts
    new Intl.DateTimeFormat("de-DE", { dateStyle: "long" }).format(date);
    // "2. August 2026"
    new Intl.DateTimeFormat("ja-JP", { dateStyle: "long" }).format(date);
    // "2026年8月2日"
    

    То же самое касается чисел. Метод toFixed(2) повсеместно выдает 1234.56, что является ошибкой для большей части Европы.

    Что охватывает Intl

    API Для чего использовать
    Intl.DateTimeFormat Даты и время с пресетами dateStyle / timeStyle
    Intl.NumberFormat Дроби, валюты, проценты, единицы измерения, компактный вид
    Intl.RelativeTimeFormat "3 дня назад", "через 2 часа"
    Intl.ListFormat "a, b и c" против "a, b, and c"
    Intl.PluralRules Определение грамматической категории плюрализации чисел
    Intl.Collator Корректная сортировка строк с учетом правил языка

    Intl.Collator часто несправедливо забывают. Обычный array.sort() на строках сортирует по кодовым точкам Unicode, из-за чего буквы с диакритикой улетают в конец за букву z, а шведская ö оказывается не на своем месте. Если вы сортируете видимые пользователю списки, делайте это через коллатор.

    ts
    ["zebra", "édouard", "apple"].sort(new Intl.Collator("ru").compare);
    // ["apple", "édouard", "zebra"]
    

    Предпочитайте пресеты ручной сборке параметров

    dateStyle и timeStyle позволяют самой локали определить правильный порядок полей и разделители. Перечисление year, month и day по отдельности дает избыточный контроль, который чаще вредит, так как принятый порядок элементов различается в зависимости от страны, и вы рискуете перезаписать правила CLDR своими ошибочными догадками.

    ts
    // Локаль сама определяет структуру:
    new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(d);
    
    // Вы навязали структуру вручную и ошиблись для других стран:
    new Intl.DateTimeFormat(locale, {
      year: "numeric",
      month: "2-digit",
      day: "2-digit",
    }).format(d);
    

    Указывайте отдельные поля явным образом только тогда, когда строгая фиксированная ширина продиктована дизайном, например в узкой колонке таблицы.

    Создание форматтеров ресурсоемко

    Это ключевой нюанс производительности. Инициализация объекта Intl.NumberFormat загружает увесистые локализационные данные, и эта операция значительно дороже последующего вызова .format(). Вызов конструктора внутри цикла рендеринга на тысячу строк приводит к ощутимым задержкам.

    ts
    // Пересоздает форматтер на каждой строке таблицы:
    rows.map((r) => new Intl.NumberFormat(locale).format(r.total));
    
    // Создается один раз и переиспользуется:
    const nf = new Intl.NumberFormat(locale);
    rows.map((r) => nf.format(r.total));
    

    Методы toLocaleDateString() и toLocaleString() страдают той же скрытой проблемой: каждый их вызов под капотом конструирует новый форматтер. Это незаметно для единичного значения, но недопустимо для списков.

    Кэшируйте форматтеры по комбинации локали и параметров:

    ts
    const cache = new Map<string, Intl.NumberFormat>();
    
    const getNumberFormat = (
      locale: string,
      options: Intl.NumberFormatOptions = {}
    ) => {
      const key = `${locale}:${JSON.stringify(options)}`;
      let formatter = cache.get(key);
      if (!formatter) {
        formatter = new Intl.NumberFormat(locale, options);
        cache.set(key, formatter);
      }
      return formatter;
    };
    

    Баг с часовым поясом, возникающий только в продакшене

    Этот сценарий отнял у разработчиков множество рабочих часов. Сервер рендерит дату при SSR, браузер выполняет гидратацию на клиенте, и React выбрасывает ошибку hydration mismatch, так как на сервере и в браузере получились разные текстовые строки.

    Причина в том, что Intl.DateTimeFormat использует текущий часовой пояс операционной системы, если он не указан явно. Сервер продакшена работает по UTC, а ноутбук разработчика — в домашнем часовом поясе. В результате баг невозможно воспроизвести локально, и он стреляет только в продакшене.

    ts
    // Сервер в UTC и браузер в UTC+9 сгенерируют разный текст. Ошибка гидратации.
    new Intl.DateTimeFormat(locale, { dateStyle: "short" }).format(d);
    
    // Полная согласованность:
    new Intl.DateTimeFormat(locale, { dateStyle: "short", timeZone: "UTC" }).format(
      d
    );
    

    Три рабочих подхода:

    • Зафиксировать часовой пояс на сервере и передавать его явно. Надежно и детерминированно, но все видят время по UTC.
    • Рендерить только на клиенте, оставляя нейтральный плейсхолдер во время серверного прохода. Точно для каждого пользователя, но дает небольшой визуальный сдвиг.
    • Сохранять часовой пояс пользователя и прокидывать его на сервер и клиент. Идеальный результат, требующий чуть больше подготовительной работы.

    Какой бы путь вы ни выбрали, всегда передавайте timeZone явно для любых дат, участвующих в SSR и гидратации. Дата без указания часового пояса — это дата с двумя несовпадающими значениями.

    Валюте нужна валюта, а не локаль

    Локаль и валюта независимы друг от друга. fr-FR не означает евро по умолчанию: французский пользователь вполне может просматривать инвойс в долларах США.

    ts
    new Intl.NumberFormat("fr-FR", { style: "currency", currency: "USD" }).format(
      1234.5
    );
    // "1 234,50 $US"
    

    Локаль управляет разделителями, группировкой разрядов и положением символа. Валюта берется из ваших прикладных данных. Попытка вывести одно из другого неизбежно приведет к ошибкам в бухгалтерии.

    Обратите внимание на параметр currencyDisplay. В интерфейсах, где сосуществуют разные валюты со знаком доллара, значение "code" исключает путаницу между долларами США, Канады и Австралии.

    Относительное время воспринимается лучше абсолютного

    Для недавних событий текст "2 часа назад" воспринимается гораздо легче точной временной метки, и Intl.RelativeTimeFormat берет это на себя.

    ts
    new Intl.RelativeTimeFormat("ru", { numeric: "auto" }).format(-1, "day");
    // "вчера"
    

    Параметр numeric: "auto" как раз и возвращает "вчера" вместо сухого "1 день назад". Без него вы получите механический числовой формат.

    Что добавляет Intlayer

    Intlayer оборачивает эти возможности в легковесные хелперы с автоматическим кэшированием, избавляя вас от ручного поддержания Map, и автоматически подставляет активную локаль из контекста.

    ts
    import {
      number,
      currency,
      date,
      relativeTime,
      units,
      compact,
      list,
    } from "intlayer";
    
    number(1234.5); // "1 234,5"
    currency(1234.5, { currency: "EUR" }); // "1 234,50 €"
    date(new Date(), "short");
    relativeTime(now, twoHoursAgo, { unit: "hour", numeric: "auto" }); // "2 часа назад"
    units(5, { unit: "kilometer", unitDisplay: "long" }); // "5 километров"
    compact(1200); // "1,2 тыс."
    list(["яблоко", "банан", "апельсин"]); // "яблоко, банан и апельсин"
    

    Функция date() также поддерживает пресеты ("short", "long", "dateOnly", "timeOnly", "full"). Для React и Vue доступны готовые хуки и composables, получающие активный язык непосредственно из контекста приложения.

    Это слой кэширования и подстановки локали по умолчанию над стандартным API платформы. Сама логика форматирования полностью опирается на нативный Intl. Все сигнатуры описаны в документации форматтеров.

    Частые ошибки

    • toLocaleDateString() без указания локали. Берет локаль операционной системы, которая на сервере зависит от настроек контейнера.
    • Форматирование в цикле без кэширования. Создание форматтера забирает львиную долю процессорного времени.
    • Отсутствие timeZone на изоморфных датах. Гарантирует ошибки гидратации, которые не воспроизводятся локально.
    • Определение валюты по локали. fr-FR не гарантирует расчеты в евро.
    • Обычный sort() для видимых списков. Всегда применяйте Intl.Collator.
    • Ручной хардкод названий месяцев или дней. Они уже содержатся в базе CLDR для всех языков.
    • Отказ от numeric: "auto" для относительного времени. Приводит к фразам вроде "1 день назад" там, где есть слово вчера.

    Полезные материалы

    Комментарии

    Пока нет комментариев. Будьте первым, кто поделится своими мыслями.

    Похожие сообщения

    Последние сообщения