Итак, настал тот moment, когда вам нужно работать с датами в разных часовых поясах, а ваш архитектор/начальник не разрешает использовать Moment Timezone или Luxon, потому что они увеличат размер вашей сборки, а для вашего проекта важно, чтобы UI грузился быстро. Или потому что вы делаете небольшую задачу, и непонятно пока, будет ли расширение работы с часовыми поясами.
А тем временем, ваш пользователь Пётр, который живёт в Омске, летал в Москву в командировку. И припарковался там возле офиса в 09:00. Затем он вернулся в Омск (где время отличается на 3 часа) и решил проверить, все ли командировочные ему выплатили. Он открывает свою историю парковок и видит там: «парковка днём в 12:00». Вместо ожидаемого «парковка утром в 09:00» (ведь на чеке у него указано 09:00).
Здесь разработчик сталкивается с двумя проблемами:
Как вывести дату и время парковки в нужном часовом поясе?
Как определить, что в том часовом поясе парковка была утром, а не днём?
Что же делать? Ведь все говорят, что без библиотек работать с часовыми поясами нельзя.
Здесь я дам небольшую функцию, которая позволяет переводить время между таймзонами с помощью Intl, который уже хорошо поддерживается браузерами. И приведу несколько примеров.
Я не буду проводить обзор библиотек, так как у каждого есть своя любимая, или уже подключённая к проекту. Моя цель не в том, чтобы убедить вас не использовать библиотек, а в том, чтобы предложить, как можно обойтись по минимуму, если вам критичен размер сборки UI, но с часовыми поясами работать надо.
Поэтому и основной сценарий будет тоже UI: когда с бэкенда приходит таймстэмп, (см. определение ниже) и нужно работать с ним в разных часовых поясах. Обратное преобразование, когда по местному времени и часовому поясу, наоборот, нужно получить таймстэмп, я рассмотрю очень коротко.
Проблемы часовых поясов
Сначала немного о том, какую проблему мы решаем. Ведь, кажется, что нужно просто добавить к дате нужное смещение часового пояса (например, 3 часа), и дело в шляпе.
Есть видео на английском, где хорошо объясняется, почему нельзя использовать арифметику при работе с часовыми поясами:
Есть русские субтитры. А ещё, для просмотра удобно использовать Яндекс.Браузер с включенным голосовым переводом.
Также, на Хабре был перевод (даже дважды) хорошей статьи «Работа с часовыми поясами в JavaScript».
Основная мысль оттуда
Нельзя использовать арифметику при работе с часовыми поясами, так как они постоянно меняются.
Например, РФ убрала переход на летнее время в 2014 г. Теперь мы не переводим стрелки дважды в год. Также и в других странах постоянно меняются правила работы со временем по разным политическим или экономическим причинам.
Чтобы определить местное время в каком-то региона, вам нужно не просто знать его смещение относительно UTC на сегодняшний день (например, GMT+3). Вам необходимы две вещи:
Таймстэмп того момента времени, которое вас интересует.
IANA-идентификатор региона, время на часах которого вас интересует на момент времени из п.1.
Почему необходим таймстэмп?
Таймстэмпом я здесь называю некоторое обозначение, однозначно определяющее некоторый абсолютный момент времени. Такое, по которому и в Нью-Йорке, и в Москве человек однозначно определит, во сколько нужно включить телевизор, чтобы увидеть прямую трансляцию очередного запуска ракеты в космос.
Есть две популярных формы таймстэмпа:
ISO-8601 — обычно, строка вида «2023-12-14T11:46:30+00:00», где удобно указан год-месяц-день, «T» (просто разделитель), часы:минуты:секунды и смещение относительно UTC.
Epoch Time — это кол-во миллисекунд, прошедших от 1 января 1970 г. 00:00:00 в часовом поясе UTC. Например, «2023-12-14T11:46:30+00:00» будет составлять 1702554390000.
Почему таймстэмп необходим? Раз правила часовых поясов во всём мире постоянно меняются, то нам нужно знать, на какой абсолютный момент времени версию этих правил мы берём, верно?
Числовые смещения (например, «+03:00») сами по себе использовать некорректно по той же причине — потому что в июне 2010 г. в РФ смещение было +7:00, а в июне 2012 г оно составляло +6:00. Смещение имеет смысл только вместе с указанными датой-временем, т.е. в составе таймстэмпа.
Обычно, с сервера таймстэмп приходит как строка: «2023-12-14T11:46:30+00:00». Или в числовом формате Epoch. Какой формат лучше / удобнее — дело вкуса. А какой больше нравится вам?
Почему необходим IANA-идентификатор?
IANA-идентификатор необходим, т.к. он однозначно определяет часовой пояс региона, местное время которого мы хотим получить. Например, «Asia/Omsk» или «Europe/Moscow».
Здесь, опять же, не подойдёт просто смещение или даже такие идентификаторы, как PST (Pacific Standard Time, равно UTC-08:00). Потому что нам нужно, чтобы этот идентификатор не менялся от года к году, а смещение конкретного региона меняется.
Общий вывод: не используйте арифметику при работе с часовыми поясами. Для получения местного времени вы должны знать таймстэмп и IANA-идентификатор данного региона.
Чем плох обычный Date?
Объект класса Date удобно представлять как таймстэмп — он содержит внутри себя Epoch. У него много удобных методов для получения компонентов даты и времени.
Но проблема в том, что эти методы возвращают дату и время либо в часовом поясе пользователя, либо в UTC (группа методов getUTC*). А передать произвольный IANA-идентификатор и получить время соответствующего часового пояса — нельзя.
Поэтому и появилась эта статья.
Используем Intl.DateTimeFormat
Что же делать, если нежелательно использовать дополнительные библиотеки в своём проекте?
На выручку приходит встроенный в браузер Intl.DateTimeFormat. С его помощью мы можем написать небольшую функцию, которая нам даст возможность узнать день, час, минуту местного времени в любом часовом поясе.
У Intl.DateTimeFormat есть функция formatToParts, которая переводит вашу дату в нужный часовой пояс и возвращает все компоненты, которые вы попросите, в любой локали (локаль — это, грубо говоря, язык региона). Например:
new Intl.DateTimeFormat('en-US', // Формат даты в США
{
timeZone: 'Asia/Omsk',// часовой пояс
year: 'numeric', // год в формате ‘2023’
month: 'numeric', // месяц в формате ‘5’
day: 'numeric', // число
hour: 'numeric', // час
minute: 'numeric', // минуты
second: 'numeric', // секунды
hour12: false // использовать 24-часовой формат
}).formatToParts(new Date('2023-12-31T17:59+00:00')); // дата
Вернёт:
[
{type: 'month', value: '12'}, // месяц — декабрь
{type: 'literal', value: '/'}, // разделитель
{type: 'day', value: '31'}, // день — 31 число
{type: 'literal', value: '/'}, // разделитель
{type: 'year', value: '2023'}, // 2023 год
{type: 'literal', value: ', '},// разделитель
{type: 'hour', value: '23'}, // 23 часа
{type: 'literal', value: ':'}, // разделитель
{type: 'minute', value: '59'}, // 59 минут
{type: 'literal', value: ':'}, // разделитель
{type: 'second', value: '00'} // 0 секунд
]
Ссылка на Typescript Playground.
Т.е. formatToParts форматирует дату и возвращает её «по частям».
В объекте с типом «hour» видим «value» 23 — т.о. узнаём, что в Омске в этот момент (таймстэмп) было 23 часа. Идея в том, чтобы отформатировать таймстэмп (например, 2023-12-31T17:59+00:00) в нужной таймзоне (например, в омской), попросив Intl дать нам все составные части местного времени, и дальше преобразовать их обратно в числа, чтобы дальше их можно было сравнивать и делать разную арифметику.
Здесь не всё так просто, потому что Intl.DateTimeFormat обязательно нужно знать в какой локали форматировать таймстэмп. Но локали тоже постоянно меняются! В частности, меняются правила записи дат в виде строки (сегодня в США дата записывается как месяц/день/год, а завтра может записаться, как день/месяц/год). И, даже, не исключено, что регион может перейти на свои собственные числа вместо арабских (см. числа инупиатов с 1994 г.)!
А такой локали, которая была бы неизменяемой, вроде «ISO-8601», Intl, к сожалению, не поддерживает. Поэтому, придётся выбрать более-менее подходящую локаль и предпринять что-то на случай, если она внезапно изменится.
Но и не всё так плохо, потому что, согласно MDN, Intl.DateTimeFormat в любой реализации должен поддерживать формат, включающий в себя год, месяц, день, часы, минуты, секунды.
Получается, что мы всегда можем попросить все эти компоненты у formatToParts. А значит, нам подойдёт любая локаль, в которой строковые значения лет, месяца, дня, часа, минут, секунд являются арабскими числами, чтобы их можно преобразовать в Number. Например, zh-Hans-CN-u-nu-hanidec, т.е. китайские числа, не подойдут :)
interface LocalDate {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
millisecond: number;
}
function dateToParts(date: Date,
timeZone: string
): Intl.DateTimeFormatPart[] {
const formatter = new Intl.DateTimeFormat("sv-SE", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3 // Миллисекунды
});
return formatter.formatToParts(date);}
// fractionalSecond - это миллисекунды
const DATE_UNITS = ['year', 'month', 'day',
'hour', 'minute', 'second',
'fractionalSecond'] as const;
const isDateUnit = (unit: Intl.DateTimeFormatPart['type']): boolean =>
DATE_UNITS.includes(unit as typeof DATE_UNITS[number]);
const partToEntry = (unit: Intl.DateTimeFormatPart) =>
[
unit.type === 'fractionalSecond' ? 'millisecond' : unit.type,
Number(unit.value)
] as const;
function dateToLocal(date: Date,
timeZone: string
): LocalDate | undefined {
const parts = dateToParts(date, timeZone);
// Оставим только части, означающие год, месяц и т.д.
// И преобразуем value в число
const timeEntries = parts
.filter(part => isDateUnit(part.type))
.map(partToEntry);
// Если formatToParts undefined, и формат sv-SE поменялся,
// то некоторые свойства могут быть NaN.
if (timeEntries.map(([, value]) => value).some(isNaN)) {
return;
}
// Преобразуем массив токенов в объект со свойствами
// year, month, day и т.д.
return Object.fromEntries(timeEntries) as unknown as LocalDate;
}
Ссылка на codesandbox. С типизацией можно аккуратнее, но я не стал здесь усложнять код.
Шведская локаль sv-SE выбрана за то, что она форматируется в похожий на ISO-8601 формат. Дальше нам это пригодится.
Например:
dateToLocal(
new Date('2023-01-01T22:00:00.123+00:00'),
'Europe/Moscow'
);
Вернёт объект
{
year: 2023,
month: 1,
day: 2,
hour: 1,
minute: 0,
second: 0,
millisecond: 123
}
Теперь, мы знаем местное время — на тот момент в Москве было 01:00 ночи 2 января.
В-принципе, всё, на этом уже можно остановиться - цель достигнута. Но есть ещё кое-что интересное.
Как теперь получить Date?
Вообще, переводить LocalDate в Date не рекомендуется, так как эта операция не всегда будет корректной. Например, в таймзонах с переходом на дневное время (DST, Daylight Saving Time) один час полностью выпадает из календаря! Например, даты «2023-03-26 02:30» в таймзоне CET (CEST) просто не существует, так как в этот момент стрелка часов прыгает на один час с 02:00 сразу на 03:00.
И если вы:
Находитесь в таймзоне CST.
У вас сейчас как раз такой момент перехода.
Получите из dateToLocal местное время в другом регионе, где перехода на летнее время нет.
То результат вызова конструктора Date с таким местным временем — непредсказуем.
Но, если код не критичный, то можно так:
/**
* Возвращает дату из указанного часового пояса,
* как если бы она была в местном часовом поясе.
* @param date дата в UTC.
* @param timeZone IANA timezone.
* @returns undefined, если не получается распарсить
* переведённую дату.
*/
function dateToLocalTimestamp(date: Date,
timeZone: string
): Date | undefined {
const dateTime = dateToLocal(date, timeZone);
// Конструктор Date создаёт дату в местном часовом поясе
return (
dateTime &&
new Date(
dateTime.year,
dateTime.month - 1,
dateTime.day,
dateTime.hour,
dateTime.minute,
dateTime.second,
dateTime.millisecond
)
);
}
Работу функции проще всего представлять как состоящую из двух шагов:
Вычисляется дата/время в нужном часовом поясе.
К этим дате и времени «прикрепляется» смещение местного часового пояса. При этом дата и время не меняются.
Следуя вышеприведённым шагам, это можно представить, как то что функция:
Сначала перевела «2023-01-01T22:00+00:00» в «2023-01-02T01:00+03:00» (у Москвы на эту дату смещение составляло GMT+03:00).
А затем заменила смещение на +06:00, сохранив время (у Омска было GMT+06:00). В итоге получилось «2023-01-02T01:00+06:00».
И не нужно использовать toString() и getTimezoneOffset(), потому что они вам сообщат, что у этой даты GMT+6:00, что, конечно же, не верно.
Форматирование даты
Если вам нужно вывести дату в другом часовом поясе, то вместо Date.toString надо использовать Intl.DateTimeFormat.format. Бонусом, кроме перевода в нужную таймзону, вы получаете более широкие возможности по форматированию даты (см. конструктор DateTimeFormat):
new Intl.DateTimeFormat('ru-RU', {
timeZone: 'Europe/Rome',
weekday: 'short',
era: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
dayPeriod: 'long',
hour: 'numeric',
minute: 'numeric',
timeZoneName: 'long'
}).format(new Date('2023-01-01T22:00+00:00'));
Ссылка на Typesript Playground.
Вернёт: «вс, 1 января 2023 г. от Рождества Христова в 23:00 Центральная Европа, стандартное время».
Для русского языка параметр dayPeriod учитывается не всегда и не работает в старых браузерах.
Новогодние примеры
Например, мы хотим умную функцию форматирования. Такую, что если дата парковки была в этом году, то она вернёт только месяц и число, например, «31 декабря». А если не в этом году — то хотим «31 декабря 2022 г.». Соответственно, нам нужно проверить, относятся ли обе даты к одному и тому же году, будучи переведёнными в некоторую таймзону:
/**
* Принадлежат ли обе даты к одному дню в указанной таймзоне?
* @param date1
* @param date2
* @param timeZone таймзона, в которой проверяется, что оба
* параметра принадлежат к одному и тому же дню.
* Например: "Europe/Moscow".
* @returns true - принадлежат. false - не принадлежат.
* undefined - не удалось определить.
*/
function isSameDayInTimezone(date1: Date,
date2: Date,
timeZone: string
): boolean | undefined {
const day1 = dateToLocal(date1, timeZone);
const day2 = dateToLocal(date2, timeZone);
return day1 && day2 &&
day1.year === day2.year &&
day1.month === day2.month &&
day1.day === day2.day;
}
Проверим:
isSameDayInTimezone(
new Date("2023-12-31T20:59+03:00"),
new Date("2023-12-31T21:59+03:00"),
"Europe/Moscow"
);
Возвращает true. А если указать таймзону «Asia/Omsk» при тех же параметрах, то будет false, потому что в Омском часовом поясе это будет 31 декабря 23:59 и 1 января 00:59, соответственно.
Но немного хитрости, и для таких простых проверок может хватить и Intl.DateTimeFormat.format, который хорошо поддерживается. Причём, получится даже лучше:
/**
* Принадлежат ли обе даты к одному году / месяцу / дню
* в указанной таймзоне?
* @param unit год, месяц или день сравнивать?
* @param timeZone таймзона, в которой проверяется,
* что оба параметра принадлежат к одному и тому же дню.
* Например: "Europe/Moscow".
*/
function isSameInTimezone(unit: 'year' | 'month' | 'day',
date1: Date,
date2: Date,
timeZone: string
): boolean {
const formatter = new Intl.DateTimeFormat('ru-RU', {
timeZone, [unit]: 'numeric'
});
return formatter.format(date1) === formatter.format(date2);
}
Ссылка на Typescript Playground.
Логика здесь простая — мы просим у Intl отформатировать нам только год, месяц или день для обеих дат. И дальше сравниваем, получилась ли одна и та же строка. Локаль здесь не важна, но я для безопасности указал конкретную.
Другой пример. Вам нужно показывать пользователю день недели в дате (параметр «weekday» у конструктора Intl.DateTimeFormat), если прошло не больше 6 дней с указанной даты:
/**
* Относится ли дата в указанной таймзоне к последним
* 7 дням в текущей таймзоне? Т.е. она сначала переводит
* дату в указанную таймзону, а потом проверяет
* сколько прошло дней.
* @param date
* @param timeZone
*/
function isLast7Days(date: Date,
timeZone: string
): boolean | undefined {
const today = new Date();
const shiftedDate = dateToLocalTimestamp(date, timeZone);
if (!shiftedDate) {
return;
}
const difference = differenceInCalendarDays(today, shiftedDate);
return difference >= 0 && difference < 7;
}
Здесь я для краткости использовал функцию differenceInCalendarDays из библиотеки date-fns. Которая, добавляет к бандлу совсем немного, но пока не имеет встроенной поддержки работы с таймзонами (date-fns-timezone не поддерживается уже несколько месяцев).
Например, если сейчас «2023-12-23T00:00+06:00», то:
isLast7Days(new Date('2023-12-16T19:00+01:00'), 'Europe/Rome');
Вернёт true.
Потому что:
Переводим «2023-12-16T19:00+01:00» в пояс +06:00 -> будет «2023-12-17T00:00+06:00».
Между 2023-12-17 и 2023-12-23 меньше 7 дней.
А если передать на минуту раньше:
isLast7Days(
new Date('2023-12-16T18:59+01:00'),
'Europe/Rome'
);
Вернёт false, потому что:
Переводим «2023-12-16T18:59+01:00» в пояс +06:00 -> будет «2023-12-16T23:59+06:00».
Между 2023-12-16 и 2023-12-23 уже 7 дней.
Расширим поддержку браузерами
formatToParts поддерживается большинством браузеров, выпущенных после 2017 г.
Но можно пойти дальше и расширить этот список браузеров, если вместе с formatToParts использовать функцию format, которая поддерживается значительно лучше.
Вот что у нас получится:
function dateToParts(date: Date,
timeZone: string
): Intl.DateTimeFormatPart[] {
const formatter = new Intl.DateTimeFormat("sv-SE", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3 // Миллисекунды
});
if (formatter.formatToParts) {
return formatter.formatToParts(date);
}
const formatted = formatter.format(date);
// Например: "2022-12-31 12:34:56,789"
const SVSE_DATE_COMPONENTS =
/^(\d{4})-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d),(\d{3})$/;
// Извлекаем компоненты даты
const parts = formatted.match(SVSE_DATE_COMPONENTS) || [];
// Преобразуем их в тот же формат, что возвращает formatToParts,
// за исключением "literal".
return DATE_UNITS.map(
(unit, index) => ({type: unit, value: parts[index + 1]})
);
}
Если formatToParts не существует в данном браузере, то вручную разбираем вывод format(). Здесь нам пригодился формат локали sv-SE, который удобно парсить. Хотя, en-US или ru-RU тоже можно парсить.
fractionalSecondDigits и fractionalSecond тоже стали поддерживаться не так давно:
Поэтому, если нужная хорошая поддержка браузерами, то можно убрать их из функций выше и устанавливать millisecond равной date.getMilliseconds(). Часовых поясов, в которых миллисекунды отличались бы от UTC, я не нашёл.
Улучшим производительность
Создание форматтера — дорогая операция. Поэтому, закэшируем его:
const DATE_FORMATTERS = new Map<string, Intl.DateTimeFormat>();
const getCachedFormatter = (timeZone: string) => {
const cachedFormatter = DATE_FORMATTERS.get(timeZone);
if (cachedFormatter) {
return cachedFormatter;
}
const formatter = new Intl.DateTimeFormat("sv-SE", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3 // Миллисекунды
});
DATE_FORMATTERS.set(timeZone, formatter);
return formatter;
}
function dateToParts(date: Date,
timeZone: string
): Intl.DateTimeFormatPart[] {
const formatter = getCachedFormatter(timeZone);
...
Такой простой кэш даёт ~400 тыс. операций в секунду против ~7 тыс. операций в секунду (см. простенький бенчмарк):
Также, полезно написать хотя бы один юнит-тест, чтобы отловить момент, если sv-SE поменяется и перестанет разбираться:
it('возвращает части, даже если старый браузер', () => {
const formatToParts =
Intl.DateTimeFormat.prototype.formatToParts;
// @ts-ignore
Intl.DateTimeFormat.prototype.formatToParts = undefined;
expect(dateToParts(
new Date('2023-12-31T23:59:58.123+03:00'),
'Europe/Moscow'
)).toEqual([
{type: 'year', value: '2023'},
{type: 'month', value: '12'},
{type: 'day', value: '31'},
{type: 'hour', value: '23'},
{type: 'minute', value: '59'},
{type: 'second', value: '58'},
{type: 'fractionalSecond', value: '123'}
]);
Intl.DateTimeFormat.prototype.formatToParts = formatToParts;
});
А, может быть, даже, вставить подобную проверку куда-то в инициализацию приложения, чтобы заранее узнать, что переводить даты между таймзонами не получится, и предпринять что-то по этому поводу. Например, сообщить пользователю, что все даты будут в его поясе и оперировать просто Date.
Обратное преобразование
Цель статьи — уменьшить код UI. А на UI, обычно, дата приходит в виде таймстэмпа и часового пояса. Но есть ситуации, когда вам может понадобиться сделать обратное преобразование: когда есть местное время в каком-то регионе и его IANA-идентификатор, и необходимо получить соответствующий таймстэмп.
Если нужно это сделать, то можно рассмотреть следующие способы:
Использовать параметр timeZoneName: ‘longOffset’, что позволит получить смещение в формате «GMT+8:30», распарсить его и передать в конструктор Date в формате ISO. Но это значение параметра поддерживается браузерами только с 2017 г.
Вычислить смещение в миллисекундах между UTC и временем в таймзоне на момент указанного времени. И дальше добавить это смещение к таймстэмпу местного времени, как если бы оно было в поясе UTC. Но этот способ не точный, т.к. исходит из предположения, что в районе суток от данного времени в данной таймзоне смещение не менялось.
Напишите, пожалуйста, в комментариях, если вам известны ещё варианты, и стоит ли это подробно разбирать в другой статье?
Выводы
Если вы пишете UI и вам нужно работать с часовыми поясами, обойдясь минимумом кода, то нехитрые манипуляции с Intl.DateTimeFormat позволят вам узнать дату и время в нужном часовом поясе. Тогда решается основная проблема — как узнать смещение местного времени некоторого региона на заданную дату.
При этом вам не нужно подключать никаких библиотек. А получив результат вызова функции, подобной dateToLocal, дальше уже не сложно сравнивать даты в разных часовых поясах между собой, или высчитывать что-то с помощью того же date-fns.Ну и не забываем про простой Intl.DateTimeFormat.format, который позволит не только перевести дату в нужный часовой пояс, но и вывести её в нужной локали с множеством разных компонентов, таких как день недели, время дня, или в 12-часовом и других форматах.
sir_puding
Интересная статья, спасибо!
Может быть, вы бы могли дополнить статью парой примеров эджкейсов в, например, US central:
как обрабатывать начало 5 Nov 2023, в котором есть два первых часа ночи
как обрабатывать начало 10 Mar 2024, в котором нет второго часа ночи
anton_nix Автор
Спасибо за примеры. Кажется, CT работает аналогично CET. В CET аналогично, осенью час повторяется, а весной пропадает.