Почему даты и числа нуждаются в локализации

TABLE OF CONTENTS
Если вы осуществляете глобальные поставки, одна и та же строка может означать разные вещи для разных пользователей: 12/01/2025
, 1.234
, 1,234
, 12:00
, 00:00
. Эти небольшие различия не являются косметическими — они влияют на доверие, понимание и даже на соблюдение законодательства. Это руководство объясняет, почему даты и числа должны быть локализованы, что на самом деле меняется в зависимости от региона и как правильно реализовать это в вашем продукте.
Что меняется в зависимости от региона
- Даты: порядок (MDY vs DMY vs YMD), разделители, названия месяцев/дней; некоторые рынки используют некалендарные календари.
- Время: 12-часовые vs 24-часовые часы, маркеры AM/PM, часовые пояса, правила летнего времени, начало недели (воскресенье vs понедельник).
- Числа: десятичные разделители (
.
vs,
), стили группировки (1,234
vs1.234
vs1 234
), индийская группировка (1,23,456
), неразрывные пробелы, стили минус/плюс (дефис vs настоящий минус−
). - Валюта: символ vs ISO код, размещение символа (префикс/суффикс), узкие vs широкие пробелы, десятичная точность (0, 2 или 3+), учетные отрицательные значения (например,
(1 234,56 €)
). - Проценты и единицы: размещение пробелов и знаков (
50%
vs50 %
), названия единиц в зависимости от региона, метрическая vs имперская система.
Влияние на бизнес
- Доверие и конверсия: цены, сборы и даты, которые “выглядят неправильно”, уменьшают покупки и увеличивают отток.
- Операционные риски: неправильно прочитанные даты могут сдвинуть бронирования или сроки; ошибки разбора ломают счета, экспорт и аналитику.
- Соблюдение: финансовые документы с неправильными форматами могут нарушать правила выставления счетов, налогообложения или отчетности.
- Нагрузка на поддержку: пользователи открывают заявки, чтобы уточнить время, валюту и ввод чисел, которые не соответствуют ожиданиям.
Реальный инцидент
Европейская платформа бронирования путешествий отображала даты отправления как 01/03/2024
без учета региона. Клиенты из США интерпретировали это как “3 января”, в то время как клиенты из Великобритании и Европы читали это как “1 марта”.
Влияние:
- 12% международных бронирований были сделаны на неправильные даты
- Служба поддержки получила более 3400 запросов на разъяснение за одну неделю
- $2.3M на возвраты и сборы за перебронирование
- 8% пострадавших клиентов перешли к конкурентам
Коренная причина: Жестко заданный формат DD/MM/YYYY
в подтверждениях по электронной почте, в то время как на сайте использовался формат MM/DD/YYYY
для пользователей из США. Исправление потребовало всего 3 часа времени инженеров — двусмысленность стоила в 800 раз больше, чем предотвращение.
Урок: Даты без контекста — это бомба замедленного действия для любого глобального продукта.
Общие ошибки
- Жестко заданные форматы:
MM/DD/YYYY
или1,234.56
, встроенные в интерфейс, электронные письма, PDF или экспорт CSV. - Хранение локализованных строк: Сохранение “Dec 1, 2025” вместо ISO времени вызывает ошибки парсинга и дрейф часовых поясов.
- Наивный парсинг: Использование локальных настроек сервера по умолчанию; предположение разделителей или 12/24-часовых часов.
- Конкатенация строк: Создание “сумма + валюта” как
"$" + 1234.56
вместо форматирования с учетом локали. - Неопределенность часового пояса: Отображение местного времени без явных зон для действий, чувствительных ко времени.
- Несогласованность браузеров: Safari, Firefox и Chrome могут форматировать даты/числа по-разному; всегда тестируйте на всех целевых браузерах.
- Пробелы в серверной визуализации: Node.js без полного набора данных ICU (
node --with-intl=full-icu
) приводит к неполному или неправильному форматированию. - Ловушки производительности: Создание новых экземпляров
Intl.*
в циклах или циклах рендеринга вместо кэширования форматировщиков.
Лучшие практики
- Храните канонические значения: Даты/время в формате ISO‑8601 в UTC; деньги в минорных единицах (центы) или с высокой точностью в десятичных дробях.
- Отображение в соответствии с локалью пользователя: Используйте API, учитывающие локаль (например,
Intl.DateTimeFormat
,Intl.NumberFormat
) для отображения. - Валидация и разбор по локали: Принимайте ввод локали, где это уместно; показывайте заполнители/примеры для руководства пользователей.
- Будьте точны со временем: Показывайте аббревиатуры часовых поясов или смещения; позволяйте пользователю выбирать, где результаты зависят от времени.
- Ясность валюты: Предпочитайте ISO коды, где существует неоднозначность; уважайте размещение символов и пробелов в зависимости от локали.
- Централизуйте форматирование: Один уровень утилит для всех поверхностей (UI, электронные письма, PDF, экспорты) для обеспечения согласованности.
- Кэшируйте форматтеры: Вызовы конструктора
Intl.*
дороги; создавайте один раз и переиспользуйте экземпляры для каждой локали.
Когда НЕ следует локализовать
Не все следует локализовать. Вот сценарии, где лучше использовать согласованные, машиночитаемые форматы:
- Машиночитаемые экспорты: CSV файлы, используемые конвейерами данных или аналитическими инструментами, должны использовать фиксированный формат (например, даты в формате ISO-8601, точка как десятичный разделитель). Документируйте этот формат четко в заголовках экспорта или файлах README.
- Ответы API: REST/GraphQL API должны возвращать даты в виде строк ISO-8601 и числа в стандартном формате JSON. Пусть клиенты обрабатывают локализацию на основе предпочтений своих пользователей.
- Внутренние логи и метрики: Логи, панели мониторинга и запросы к базе данных выигрывают от согласованных форматов для разбора, агрегации и оповещения. Используйте ISO-8601 и стандартные числовые форматы.
- Канонические идентификаторы: Идентификаторы транзакций, номера заказов или ссылки, которые пользователи могут нуждаться в передаче между локалями, должны избегать форматирования, специфичного для локали.
Правило большого пальца: Локализуйте для людей, читающих контент; используйте стандартные форматы для машин, обрабатывающих данные.
План реализации
- Аудит поверхностей: инвентаризация дат, времени, чисел, валюты в UI, электронных письмах, PDF, экспортах CSV/Excel, аналитике, логах.
- Определение локалей: перечислите поддерживаемые локали и стандартные резервные варианты; укажите политику 12/24‑часового формата для каждой локали.
- Создание утилит: оберните
Intl.*
API (или библиотеку) с помощью общих вспомогательных функций и тестов; реализуйте кэширование форматировщиков. - Стратегия ввода: обновите формы для принятия и проверки форматов локалей; храните канонические значения вместе с исходным вводом.
- Правила контента: документируйте стиль для коротких/длинных дат, относительных дат, отображения валюты и форматирования процентов.
- Внедрение: сначала конвертируйте поверхности с наибольшим трафиком и наибольшим риском; при необходимости выпускайте за флагом функции.
Стратегия тестирования
Помимо ручного QA, внедрите автоматизированные тесты для предотвращения регрессий:
- Тесты снимков: Заморозьте отформатированные выходные данные для каждой поддерживаемой локали. Изменения вызывают проверку, чтобы убедиться, что они намеренные.
- Тесты обратного преобразования: Убедитесь, что
format → parse → format
дает стабильные результаты. Обнаруживает потерю точности или неоднозначные форматы. - Крайние случаи: Тестируйте отрицательные числа, ноль, очень большие числа (миллиарды/триллионы), переходы на летнее время, високосные годы и граничные даты (1 января, 31 декабря).
- Кросс-браузерная проверка: Автоматизированные тесты на Chrome, Firefox, Safari и Edge для выявления различий в рендеринге.
- Резервная локаль: Убедитесь в плавной деградации при запросе неподдерживаемых локалей.
- Эталонные тесты производительности: Измерьте стоимость создания форматировщика; убедитесь, что кэширование работает в производственной среде.
Советы по производительности
Intl.*
API мощные, но могут быть медленными при неправильном использовании. Следуйте этим рекомендациям:
// ❌ ПЛОХО: Создает новый форматировщик при каждом вызове
function formatPrice(amount, locale) {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'USD'
}).format(amount);
}
// ✅ ХОРОШО: Кэширование форматировщиков для каждой локали const formatters = new Map(); function getFormatter(locale) { if (!formatters.has(locale)) { formatters.set(locale, new Intl.NumberFormat(locale, { style: ‘currency’, currency: ‘USD’ })); } return formatters.get(locale); }
function formatPrice(amount, locale) { return getFormatter(locale).format(amount); }
- **Кэширование экземпляров форматировщиков**: Создание `Intl.*` дорогостоящее (~1–5мс); форматирование с использованием существующего экземпляра быстрое (~0.01мс).
- **Избегайте циклов**: Никогда не создавайте форматировщики внутри `map()`, `forEach()` или циклов рендеринга.
- **Ленивая загрузка данных локали**: Для веб-приложений рассмотрите возможность разделения кода для загрузки только необходимых локалей, чтобы уменьшить размер пакета.
- **На стороне сервера**: Используйте один форматировщик на цикл запроса/ответа; избегайте создания на каждый элемент в больших наборах данных.
## Контрольный список QA
- Даты однозначны в `en-US`, `en-GB`, `de-DE`, `fr-FR`, `hi-IN`, `ja-JP`, `ar-EG`.
- Время использует правильный 12/24‑часовой формат; маркеры AM/PM появляются там, где ожидается; часовые пояса отображаются для критически важных временных потоков.
- Числа используют правильные десятичные и групповые разделители; неразрывные пробелы там, где это стандартно (например, `fr-FR`).
- Валюта показывает правильный символ/код, расположение и десятичную точность для каждой локали; отрицательные значения отображаются правильно.
- Вводы принимают и разбирают данные локали пользователя (или четко применяют поддерживаемый формат); сообщения о проверке учитывают локаль.
- Экспорт (CSV/PDF) соответствует ожиданиям локали или четко документирует фиксированные форматы для машинного потребления.
- Кросс-браузерность: Даты, время и числа отображаются одинаково в Chrome, Firefox, Safari и Edge.
- Производительность: Нет создания форматировщиков в циклах; кэшированные экземпляры используются повторно в рендерах.
## Примеры кода
### JavaScript
```js
// Даты
const date = new Date('2025-01-12T00:00:00Z');
console.log(new Intl.DateTimeFormat('en-US', { dateStyle: 'short', timeZone: 'UTC' }).format(date));
// → 1/12/25
console.log(new Intl.DateTimeFormat('en-GB', { dateStyle: 'short', timeZone: 'UTC' }).format(date));
// → 12/01/2025 (день/месяц/год)
console.log(new Intl.DateTimeFormat('ja-JP', { dateStyle: 'medium', timeZone: 'UTC' }).format(date));
// → 2025/01/12
// Время с отображением часового пояса
const time = new Date('2025-01-12T18:05:00Z');
console.log(new Intl.DateTimeFormat('en-US', {
dateStyle: 'short',
timeStyle: 'short',
timeZone: 'America/New_York',
timeZoneName: 'short'
}).format(time));
// → 1/12/25, 1:05 PM EST
console.log(new Intl.DateTimeFormat('de-DE', {
timeStyle: 'short',
timeZone: 'Europe/Berlin',
hour12: false
}).format(time));
// → 19:05
// Числа
const n = 1234.56;
console.log(new Intl.NumberFormat('en-US').format(n)); // → 1,234.56
console.log(new Intl.NumberFormat('de-DE').format(n)); // → 1.234,56
console.log(new Intl.NumberFormat('fr-FR').format(n)); // → 1 234,56 (NBSP в качестве разделителя групп)
// Индийская система счисления
console.log(new Intl.NumberFormat('hi-IN').format(1234567.89)); // → 12,34,567.89
// Валюта
console.log(new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n));
// → $1,234.56
console.log(new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n));
// → 1.234,56 €
console.log(new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(1234));
// → ¥1,234 (без десятичных)
– Проценты и единицы
console.log(new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 0 }).format(0.5));
// → 50%
console.log(new Intl.NumberFormat('fr-FR', { style: 'percent', maximumFractionDigits: 0 }).format(0.5));
// → 50 %
// Пример кеширования форматировщика
class LocaleFormatter {
constructor() {
this.cache = new Map();
}
getDateFormatter(locale, options) {
const key = `date:${locale}:${JSON.stringify(options)}`;
if (!this.cache.has(key)) {
this.cache.set(key, new Intl.DateTimeFormat(locale, options));
}
return this.cache.get(key);
}
formatDate(date, locale, options = { dateStyle: 'medium' }) {
return this.getDateFormatter(locale, options).format(date);
}
}
const formatter = new LocaleFormatter();
console.log(formatter.formatDate(new Date(), 'en-US')); // → Jan 12, 2025
console.log(formatter.formatDate(new Date(), 'fr-FR')); // → 12 janv. 2025
Python
from babel.dates import format_date, format_time, format_datetime
from babel.numbers import format_number, format_currency, format_percent
from datetime import datetime
import pytz
# Даты
date = datetime(2025, 1, 12)
print(format_date(date, format='short', locale='en_US')) # → 1/12/25
print(format_date(date, format='short', locale='en_GB')) # → 12/01/2025
print(format_date(date, format='medium', locale='ja_JP')) # → 2025/01/12
# Время с часовым поясом
tz_ny = pytz.timezone('America/New_York')
tz_berlin = pytz.timezone('Europe/Berlin')
time = datetime(2025, 1, 12, 18, 5, tzinfo=pytz.UTC)
print(format_datetime(time.astimezone(tz_ny), 'short', tzinfo=tz_ny, locale='en_US'))
# → 1/12/25, 1:05 PM
print(format_time(time.astimezone(tz_berlin), format='short', tzinfo=tz_berlin, locale='de_DE'))
# → 19:05
# Числа
n = 1234.56
print(format_number(n, locale='en_US')) # → 1,234.56
print(format_number(n, locale='de_DE')) # → 1.234,56
print(format_number(n, locale='fr_FR')) # → 1 234,56
# Индийская система счисления
print(format_number(1234567.89, locale='hi_IN')) # → 12,34,567.89
# Валюта
print(format_currency(n, 'USD', locale='en_US')) # → $1,234.56
print(format_currency(n, 'EUR', locale='de_DE')) # → 1.234,56 €
print(format_currency(1234, 'JPY', locale='ja_JP')) # → ¥1,234
# Проценты
print(format_percent(0.5, locale='en_US')) # → 50%
print(format_percent(0.5, locale='fr_FR')) # → 50 %
Java
import java.text.NumberFormat;
import java.time.ZonedDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.Locale;
import java.util.Currency;
// Даты
ZonedDateTime date = ZonedDateTime.of(2025, 1, 12, 0, 0, 0, 0, ZoneId.of("UTC"));
DateTimeFormatter usFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
.withLocale(Locale.US);
System.out.println(usFormatter.format(date)); // → 1/12/25
DateTimeFormatter gbFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
.withLocale(Locale.UK);
System.out.println(gbFormatter.format(date)); // → 12/01/2025
DateTimeFormatter jpFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(Locale.JAPAN);
System.out.println(jpFormatter.format(date)); // → 2025/01/12
// Время
ZonedDateTime time = ZonedDateTime.of(2025, 1, 12, 18, 5, 0, 0, ZoneId.of("UTC"));
ZonedDateTime timeNY = time.withZoneSameInstant(ZoneId.of("America/New_York"));
DateTimeFormatter usTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
.withLocale(Locale.US);
System.out.println(usTimeFormatter.format(timeNY)); // → 1:05 PM
// Числа
double n = 1234.56;
NumberFormat usFormat = NumberFormat.getInstance(Locale.US);
System.out.println(usFormat.format(n)); // → 1,234.56
NumberFormat deFormat = NumberFormat.getInstance(Locale.GERMANY);
System.out.println(deFormat.format(n)); // → 1.234,56
NumberFormat frFormat = NumberFormat.getInstance(Locale.FRANCE);
System.out.println(frFormat.format(n)); // → 1 234,56
// Валюта
NumberFormat usCurrency = NumberFormat.getCurrencyInstance(Locale.US);
usCurrency.setCurrency(Currency.getInstance("USD"));
System.out.println(usCurrency.format(n)); // → $1,234.56
NumberFormat deCurrency = NumberFormat.getCurrencyInstance(Locale.GERMANY);
deCurrency.setCurrency(Currency.getInstance("EUR"));
System.out.println(deCurrency.format(n)); // → 1.234,56 €
NumberFormat jpCurrency = NumberFormat.getCurrencyInstance(Locale.JAPAN); jpCurrency.setCurrency(Currency.getInstance(“JPY”)); System.out.println(jpCurrency.format(1234)); // → ¥1,234
### Go
```go
package main
import (
"fmt"
"time"
"golang.org/x/text/language"
"golang.org/x/text/message"
"golang.org/x/text/number"
)
func main() {
// Numbers
n := 1234.56
pUS := message.NewPrinter(language.AmericanEnglish)
fmt.Println(pUS.Sprintf("%.2f", n)) // → 1,234.56
pDE := message.NewPrinter(language.German)
fmt.Println(pDE.Sprintf("%.2f", n)) // → 1.234,56
pFR := message.NewPrinter(language.French)
fmt.Println(pFR.Sprintf("%.2f", n)) // → 1 234,56
// Currency (using number package)
fmt.Println(pUS.Sprint(number.Decimal(n, number.Scale(2)))) // → 1,234.56
// Dates - Go's time package uses layouts instead of locale formatting
// For full i18n date formatting, use github.com/goodsign/monday or similar
date := time.Date(2025, 1, 12, 0, 0, 0, 0, time.UTC)
fmt.Println(date.Format("01/02/2006")) // US: 01/12/2025
fmt.Println(date.Format("02/01/2006")) // EU: 12/01/2025
fmt.Println(date.Format("2006/01/02")) // ISO: 2025/01/12
}
Примечание: Стандартная библиотека Go имеет ограниченную поддержку локалей. Для использования в производстве рассмотрите:
golang.org/x/text
для форматирования чиселgithub.com/goodsign/monday
для локализованного форматирования даты/времениgithub.com/bojanz/currency
для работы с валютами
Полезные ресурсы
- Стандарты: Unicode CLDR (стандарт локализации данных), IETF BCP 47 (идентификаторы локалей)
- Документация: MDN Intl Reference
- Библиотеки:
- Luxon (современная библиотека даты/времени с поддержкой i18n)
- date-fns с модулями локалей
- Globalize.js (всеобъемлющая i18n на основе CLDR)
- Format.js (ориентированная на React i18n с синтаксисом сообщений ICU)
- Тестирование: Intl polyfill для старых браузеров
Заключение
Правильное отображение дат и чисел — это небольшое усилие, которое приносит значительные результаты: больше доверия, меньше ошибок и более плавный глобальный опыт. Централизуйте форматирование, храните канонические значения, используйте API, учитывающие локаль, везде, где ваши пользователи читают или вводят время, деньги или числа — и знайте, когда пропустить локализацию для машиночитаемых данных. С правильным кэшированием и тестированием вы создадите надежную систему, которая масштабируется по локалям без ущерба для производительности.