Форматирование телефонных номеров

В экосистеме FormatJS форматирование телефонных номеров не является встроенной задачей ядра библиотеки, поскольку основная цель FormatJS — интернационализация текстов, сообщений и числовых значений через ICU MessageFormat и API уровня Intl. Поэтому работа с телефонными номерами строится на сочетании FormatJS-подхода и специализированных библиотек, чаще всего — libphonenumber-js, с последующей интеграцией в форматирование UI через компоненты и сообщения.

Телефонные номера не относятся к числовым значениям в смысле Intl.NumberFormat. Их структура зависит от страны, правил набора, наличия кода страны, длины национального номера и локальных особенностей отображения.

Ключевая особенность:

  • один и тот же номер имеет разные визуальные представления в разных регионах;
  • хранение должно быть независимым от локали;
  • отображение — строго локализованным;
  • валидация — основана на национальных планах нумерации.

Поэтому в приложениях с FormatJS телефонные номера рассматриваются как отдельный тип данных, который форматируется до передачи в UI.

Единый формат хранения: E.164

Базовая модель хранения — международный стандарт E.164:

  • всегда начинается с +
  • содержит код страны
  • не содержит пробелов и разделителей
  • используется как источник истины

Пример:

+447911123456
+77011234567
+14155552671

Любое форматирование в UI должно быть производным от этого значения.

Использование libphonenumber-js в связке с FormatJS

Библиотека libphonenumber-js выполняет три ключевые функции:

  • парсинг номера
  • форматирование под регион
  • валидация

Базовый сценарий форматирования:

import { parsePhoneNumberFromString } from 'libphonenumber-js';

const phone = parsePhoneNumberFromString('+447911123456');

const formatted = phone.formatInternational();
// +44 7911 123456

Для локального формата:

phone.formatNational();
// 07911 123456

Интеграция с UI-слоем FormatJS

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

Пример компонента:

import { useIntl } from 'react-intl';
import { parsePhoneNumberFromString } from 'libphonenumber-js';

function PhoneNumber({ value }) {
  const intl = useIntl();

  const phone = parsePhoneNumberFromString(value);

  if (!phone) return null;

  const formatted =
    intl.locale === 'en-GB'
      ? phone.formatNational()
      : phone.formatInternational();

  return formatted;
}

Здесь FormatJS отвечает за определение локали, а библиотека телефонных номеров — за форматирование.

Локализация отображения

Телефонные номера не имеют ICU-формата, поэтому их локализация строится вручную через правила:

Стратегия 1: международный формат

Используется для интерфейсов с глобальной аудиторией:

  • всегда с кодом страны
  • единый стиль
+1 415 555 2671
+44 7911 123456

Стратегия 2: национальный формат

Используется для локализованных интерфейсов:

  • без кода страны
  • привычные разделители
07911 123456
(415) 555-2671

Стратегия 3: адаптивный формат

Выбор формата зависит от локали FormatJS:

function formatPhone(phoneNumber, locale) {
  const phone = parsePhoneNumberFromString(phoneNumber);

  if (!phone) return phoneNumber;

  const country = locale === 'en-GB' ? 'GB' : 'US';

  return phone.formatNational({ countryCallingCode: country })
    || phone.formatInternational();
}

Интеграция с ICU MessageFormat

FormatJS активно использует ICU MessageFormat, однако телефонные номера не поддерживаются напрямую как тип форматирования. Вместо этого применяется подстановка строк.

Пример шаблона:

import { defineMessages, useIntl } from 'react-intl';

const messages = defineMessages({
  contact: {
    id: 'user.contact',
    defaultMessage: 'Контактный номер: {phone}',
  },
});

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

const intl = useIntl();

intl.formatMessage(messages.contact, {
  phone: formattedPhone,
});

Форматирование происходит заранее, до передачи в ICU.

Нормализация ввода

Перед сохранением номера необходимо привести его к E.164:

const phone = parsePhoneNumberFromString(inputValue);

if (phone) {
  const normalized = phone.number; // E.164
}

Типичная схема:

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

Валидация номеров

Валидация выполняется через isValid():

const phone = parsePhoneNumberFromString(value);

const isValid = phone?.isValid();

Дополнительно:

  • проверка страны
  • проверка длины
  • проверка типа (mobile, fixed-line)
phone.getType();
// 'MOBILE' | 'FIXED_LINE' | ...

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

Частая проблема — конфликт между отображением и вводом.

Рекомендуемая схема:

  • в поле ввода хранится сырое значение
  • форматирование применяется только при потере фокуса
const [value, setValue] = useState('');

const onB lur = () => {
  const phone = parsePhoneNumberFromString(value);
  if (phone) {
    setValue(phone.formatInternational());
  }
};

При фокусе часто возвращают “сырой” ввод:

const onFo cus = () => {
  const phone = parsePhoneNumberFromString(value);
  if (phone) {
    setValue(phone.number);
  }
};

Особенности работы с React-Intl

В контексте FormatJS важно учитывать, что react-intl не управляет форматированием данных, а только их отображением.

Поэтому телефонные номера:

  • не передаются как числа
  • не обрабатываются ICU
  • всегда предварительно форматируются

Это отличает их от дат и чисел, которые могут обрабатываться через <FormattedNumber> или <FormattedDate>.

Маскирование и UX-форматирование

Для улучшения UX часто применяется маска ввода:

+7 (___) ___-__-__

Но маска не должна считаться источником истины. Она используется только для визуального ввода.

Пример с простым форматированием:

function applyMask(value) {
  return value
    .replace(/[^\d+]/g, '')
    .replace(/(\d{1,3})(\d{3})(\d{3})(\d{2})(\d{2})/,
      '+$1 ($2) $3-$4-$5');
}

Однако при наличии libphonenumber-js маски обычно заменяются полноценным парсингом.

Обработка ошибок форматирования

Типичные сценарии ошибок:

  • неполный номер
  • неизвестная страна
  • некорректный код
const phone = parsePhoneNumberFromString(value);

if (!phone) {
  // fallback: raw input
}

if (!phone.isValid()) {
  // отображение ошибки валидации
}

Интернациональные особенности отображения

Разные регионы влияют на:

  • группировку цифр
  • наличие скобок
  • использование пробелов или дефисов
  • позицию кода страны

Пример:

  • США: (415) 555-2671
  • Великобритания: 07911 123456
  • Франция: 06 12 34 56 78

Эти различия полностью управляются метаданными libphonenumber-js, а не FormatJS.

Использование в сложных интерфейсах

В многоязычных системах с FormatJS телефонный номер обычно проходит через несколько слоёв:

  1. Хранение (E.164)
  2. API (без изменений)
  3. UI форматирование (по локали)
  4. ICU MessageFormat вставка
  5. Финальный рендер

Пример полной цепочки:

const raw = '+14155552671';

const phone = parsePhoneNumberFromString(raw);

const display = phone.formatInternational();

intl.formatMessage(
  { id: 'contact' },
  { phone: display }
);

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

При массовом отображении списков пользователей форматирование может быть дорогим.

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

const cache = new Map();

function getFormatted(phone) {
  if (cache.has(phone)) return cache.get(phone);

  const parsed = parsePhoneNumberFromString(phone);
  const formatted = parsed?.formatInternational() || phone;

  cache.set(phone, formatted);
  return formatted;
}

Граничные случаи

  • номера без кода страны
  • VoIP номера
  • короткие сервисные номера (SMS, 4–6 цифр)
  • внутренние корпоративные форматы

Для таких случаев FormatJS-слой не применяется, используется raw rendering.

Связь с остальными форматами Intl

В отличие от чисел и валют:

  • телефонные номера не стандартизированы в Intl
  • не имеют locale-sensitive formatting API
  • требуют внешних библиотек

Это делает их отдельной категорией данных в архитектуре интернационализации на базе FormatJS.