Intl.RelativeTimeFormat полифилл

API Intl.RelativeTimeFormat предназначен для локализованного форматирования относительного времени:

  • «5 минут назад»
  • «через 2 дня»
  • «в прошлом месяце»
  • «завтра»

Поддержка встроенного Intl.RelativeTimeFormat присутствует не во всех средах исполнения. Особенно это касается:

  • старых браузеров;
  • устаревших мобильных WebView;
  • некоторых версий Node.js;
  • embedded-окружений.

Для решения проблемы используется полифилл из экосистемы FormatJS.


Пакет @formatjs/intl-relativetimeformat

Основной полифилл поставляется в пакете:

npm install @formatjs/intl-relativetimeformat

Дополнительно требуются данные локализации:

npm install @formatjs/intl-relativetimeformat

После установки становятся доступны:

  • реализация Intl.RelativeTimeFormat;
  • polyfill detection;
  • locale data;
  • вспомогательные методы.

Базовое подключение полифилла

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

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

После импорта API становится доступным глобально:

const rtf = new Intl.RelativeTimeFormat('ru');

console.log(rtf.format(-5, 'minute'));

Результат:

5 минут назад

Проверка необходимости полифилла

FormatJS предоставляет безопасную проверку:

import {shouldPolyfill} from '@formatjs/intl-relativetimeformat/should-polyfill';

async function setupRelativeTime(locale) {
    const unsupportedLocale = shouldPolyfill(locale);

    if (!unsupportedLocale) {
        return;
    }

    await import('@formatjs/intl-relativetimeformat/polyfill-force');
    await import(
        `@formatjs/intl-relativetimeformat/locale-data/${locale}`
    );
}

Такой подход:

  • уменьшает размер бандла;
  • не загружает лишний код;
  • позволяет делать lazy-loading.

Отличие polyfill и polyfill-force

polyfill

Подключает реализацию только при отсутствии поддержки:

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

Используется чаще всего.


polyfill-force

Принудительно заменяет реализацию:

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

Полезно:

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

Подключение locale data

Без locale data форматирование работать не будет.


Подключение одной локали

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

Подключение нескольких локалей

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

Динамическая загрузка локалей

async function loadLocale(locale) {
    await import(
        `@formatjs/intl-relativetimeformat/locale-data/${locale}`
    );
}

Создание экземпляра форматтера

Базовый пример

const formatter = new Intl.RelativeTimeFormat('ru');

console.log(formatter.format(-1, 'day'));
console.log(formatter.format(3, 'month'));

Результат:

вчера
через 3 месяца

Поддерживаемые единицы времени

Допустимые значения:

'year'
'quarter'
'month'
'week'
'day'
'hour'
'minute'
'second'

Пример:

formatter.format(-2, 'week');

Результат:

2 недели назад

Параметры конструктора

Синтаксис

new Intl.RelativeTimeFormat(locale, options)

Опция numeric

Управляет тем, использовать ли специальные слова:

  • «вчера»
  • «завтра»
  • «позавчера»

или строго числовой формат.


numeric: 'auto'

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

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

Результат:

вчера

numeric: 'always'

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

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

Результат:

1 день назад

Опция style

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

  • long
  • short
  • narrow

long

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

console.log(formatter.format(-3, 'month'));

Результат:

3 месяца назад

short

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

console.log(formatter.format(-3, 'month'));

Результат:

3 мес. назад

narrow

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

console.log(formatter.format(-3, 'month'));

Результат:

-3 мес.

Метод format

Основной метод форматирования.


Синтаксис

formatter.format(value, unit)

Примеры

formatter.format(-10, 'second');
formatter.format(5, 'minute');
formatter.format(2, 'year');

Результаты:

10 секунд назад
через 5 минут
через 2 года

Метод formatToParts

Позволяет разбить результат на токены.


Пример

const formatter = new Intl.RelativeTimeFormat('ru');

console.log(
    formatter.formatToParts(-5, 'day')
);

Результат:

[
    { type: 'integer', value: '5', unit: 'day' },
    { type: 'literal', value: ' дней назад' }
]

Практическое применение formatToParts

Метод полезен:

  • при кастомном рендеринге;
  • для стилизации числа;
  • в React/Vue-компонентах;
  • при построении UI-библиотек.

Пример кастомного HTML

const formatter = new Intl.RelativeTimeFormat('ru');

const parts = formatter.formatToParts(-5, 'minute');

const html = parts.map(part => {
    if (part.type === 'integer') {
        return `<strong>${part.value}</strong>`;
    }

    return part.value;
}).join('');

console.log(html);

Результат:

<strong>5</strong> минут назад

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

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

import React from 'react';

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

    return (
        <span>
            {formatter.format(-minutes, 'minute')}
        </span>
    );
}

Использование вместе с React Intl

FormatJS тесно интегрируется с react-intl.


Компонент FormattedRelativeTime

import {FormattedRelativeTime} from 'react-intl';

function App() {
    return (
        <FormattedRelativeTime
            value={-5}
            unit="minute"
        />
    );
}

Результат:

5 minutes ago

Автоматическое обновление времени

FormattedRelativeTime умеет автоматически обновляться.


Пример

<FormattedRelativeTime
    value={-30}
    unit="second"
    updateIntervalInSeconds={1}
/>

Компонент будет пересчитывать время каждую секунду.


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

Подключение

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

Пример

const formatter = new Intl.RelativeTimeFormat('ru');

console.log(
    formatter.format(-7, 'day')
);

Lazy loading локалей

При большом количестве языков рекомендуется загружать локали динамически.


Пример архитектуры

const loadedLocales = new Set();

async function ensureLocale(locale) {
    if (loadedLocales.has(locale)) {
        return;
    }

    await import(
        `@formatjs/intl-relativetimeformat/locale-data/${locale}`
    );

    loadedLocales.add(locale);
}

Работа с TypeScript

Полифилл полностью поддерживает TypeScript.


Пример

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

const result: string =
    formatter.format(-2, 'hour');

Поддержка plural rules

Intl.RelativeTimeFormat зависит от Intl.PluralRules.

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

npm install @formatjs/intl-pluralrules

Подключение

import '@formatjs/intl-pluralrules/polyfill';
import '@formatjs/intl-pluralrules/locale-data/ru';

После этого подключается RelativeTimeFormat:

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

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

Корректная последовательность:

import '@formatjs/intl-pluralrules/polyfill';
import '@formatjs/intl-pluralrules/locale-data/ru';

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

Обработка ошибок

Ошибка отсутствия locale data

Типичная ошибка:

Missing locale data for locale: "ru"

Причина:

  • не подключён locale-data;
  • неверное имя локали;
  • локаль не успела загрузиться.

Решение

await import(
    '@formatjs/intl-relativetimeformat/locale-data/ru'
);

Поддержка ICU

Качество форматирования зависит от ICU-данных среды выполнения.

Особенно важно для:

  • Node.js;
  • Electron;
  • серверного рендеринга.

SSR и гидратация

При SSR необходимо:

  • использовать одинаковую локаль на сервере и клиенте;
  • загружать locale data до рендера;
  • избегать различий между server/client output.

Пример SSR-подготовки

await Promise.all([
    import('@formatjs/intl-pluralrules/polyfill'),
    import('@formatjs/intl-pluralrules/locale-data/ru'),

    import('@formatjs/intl-relativetimeformat/polyfill'),
    import('@formatjs/intl-relativetimeformat/locale-data/ru')
]);

Поддержка браузеров

Полифилл особенно актуален для:

  • Internet Explorer;
  • старых Safari;
  • Android Browser;
  • устаревших Chromium;
  • embedded WebView.

Оптимизация размера бандла

Подключение только нужных локалей

Плохо:

import '@formatjs/intl-relativetimeformat/locale-data/*';

Хорошо:

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

Code splitting

Пример с Webpack

async function loadI18n(locale) {
    await Promise.all([
        import(
            `@formatjs/intl-relativetimeformat/locale-data/${locale}`
        )
    ]);
}

Сравнение с ручным форматированием

Ручной подход:

function format(minutes) {
    return `${minutes} минут назад`;
}

Проблемы:

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

Преимущества Intl.RelativeTimeFormat

Локализация

new Intl.RelativeTimeFormat('fr')
il y a 5 minutes

Корректные plural forms

1 минута
2 минуты
5 минут

Унификация API

Одинаковое поведение:

  • браузер;
  • Node.js;
  • React Native;
  • Electron.

Пример полноценного helper

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

const cache = new Map();

function getFormatter(locale) {
    if (!cache.has(locale)) {
        cache.set(
            locale,
            new Intl.RelativeTimeFormat(locale, {
                numeric: 'auto',
                style: 'long'
            })
        );
    }

    return cache.get(locale);
}

export function formatRelativeDate(value, unit, locale = 'ru') {
    return getFormatter(locale)
        .format(value, unit);
}

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

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

Рекомендуется:

  • переиспользовать экземпляры;
  • хранить formatter cache;
  • не создавать объекты внутри render-функций.

Нежелательный подход

function render() {
    const formatter =
        new Intl.RelativeTimeFormat('ru');

    return formatter.format(-5, 'minute');
}

Предпочтительный подход

const formatter =
    new Intl.RelativeTimeFormat('ru');

function render() {
    return formatter.format(-5, 'minute');
}

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

Проверка результата

expect(
    formatter.format(-1, 'day')
).toBe('вчера');

Тестирование локалей

const locales = ['ru', 'en', 'fr'];

locales.forEach(locale => {
    const formatter =
        new Intl.RelativeTimeFormat(locale);

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

Ограничения полифилла

Полифилл:

  • не исправляет ошибки ICU среды;
  • не заменяет полноценную i18n-систему;
  • требует locale data;
  • увеличивает размер бандла.

Архитектурные рекомендации

Централизованная инициализация

Хорошая практика — выносить подключение полифиллов в отдельный модуль:

// intl-setup.js
import '@formatjs/intl-pluralrules/polyfill';
import '@formatjs/intl-pluralrules/locale-data/ru';

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

Универсальный formatter service

class RelativeTimeService {
    constructor(locale) {
        this.formatter =
            new Intl.RelativeTimeFormat(locale, {
                numeric: 'auto'
            });
    }

    minutes(value) {
        return this.formatter.format(
            value,
            'minute'
        );
    }

    hours(value) {
        return this.formatter.format(
            value,
            'hour'
        );
    }

    days(value) {
        return this.formatter.format(
            value,
            'day'
        );
    }
}

Совместимость с другими polyfill-пакетами FormatJS

Часто используется вместе с:

  • @formatjs/intl-numberformat
  • @formatjs/intl-datetimeformat
  • @formatjs/intl-pluralrules
  • @formatjs/intl-listformat
  • @formatjs/intl-displaynames

Комплексная i18n-инициализация

import '@formatjs/intl-pluralrules/polyfill';
import '@formatjs/intl-pluralrules/locale-data/ru';

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

import '@formatjs/intl-numberformat/polyfill';
import '@formatjs/intl-numberformat/locale-data/ru';