Работа с часовыми поясами в JavaScript без потери рассудка
Работайте с часовые пояса в JavaScript через UTC-инстанты, IANA-идентификаторы, Intl.DateTimeFormat, Temporal и правила для DST без ошибок.
Храните и передавайте каждую временну́ю метку как UTC-момент в формате ISO 8601 (например, 2026-05-22T08:00:00Z), держите идентификатор часового пояса IANA (например, America/New_York) в отдельном поле и выполняйте преобразование в локальное время только в момент отображения — никогда не сохраняйте «настенное» время без указания зоны. Это единственное правило предотвращает большинство ошибок, связанных с часовыми поясами в JavaScript, и оно справедливо независимо от того, используете ли вы Date, Intl, стороннюю библиотеку или новый API Temporal.
В этом руководстве рассматривается: почему встроенный объект Date делает работу с часовыми поясами болезненной; устойчивые правила, решающие проблему независимо от используемых инструментов; как правильно форматировать даты сегодня с помощью Intl.DateTimeFormat; что меняет Temporal после включения в ES2026; какую библиотеку выбрать для продакшена по состоянию на июнь 2026 года; и какие граничные случаи летнего времени порождают наиболее трудновоспроизводимые ошибки.
Ключевые выводы
- Храните и передавайте моменты времени в UTC (ISO 8601 или epoch), держите идентификатор зоны IANA в отдельном поле и выполняйте преобразование в локальное время только при отображении.
- JavaScript-объект
Dateне поддерживает именованные часовые пояса — он умеет представлять момент времени только в UTC или в зоне хост-машины. Именно это является корневой причиной ситуации «правильная дата на моей машине, неправильная — у пользователя». - Будущее событие необходимо хранить вместе с его зоной IANA, а не как фиксированный UTC-момент, чтобы оно по-прежнему разрешалось в правильное «настенное» время, даже если правила перехода на летнее время в этом регионе изменятся до наступления даты.
- По состоянию на июнь 2026 года
Temporalявляется предложением Stage 4 в ECMAScript 2026 и поставляется нативно в Firefox 139+, Chromium 144+ и Node.js 26+, но не в Safari — поэтому продакшен-код по-прежнему, как правило, требует@js-temporal/polyfillилиtemporal-polyfill. - Если вы пока не можете использовать
Temporal, применяйте Luxon 3.7.2 или date-fns 4.4.0 с@date-fns/tz— и учтите, что более старый пакетdate-fns-tzпредназначен для date-fns v3, а не v4.
Почему JavaScript-объект Date сводит с ума
Объект Date имеет три структурных недостатка, и третий из них является главным убийцей часовых поясов. Во-первых, он мутабелен: методы вроде setMonth и setFullYear изменяют исходный объект на месте, поэтому передача Date в функцию может незаметно изменить его для всех остальных вызывающих. Во-вторых, нумерация непоследовательна — месяцы отсчитываются с нуля (январь — 0, декабрь — 11), тогда как дни месяца — с единицы, — что порождает ошибки смещения на один месяц, которые выживают при код-ревью.
В-третьих, и это наиболее существенно: Date не имеет реальной поддержки часовых поясов. Он умеет представлять момент времени только в UTC или в локальной зоне хост-машины — и ничего более. Нет никакого способа создать объект Date «в зоне `America/New_York“ и работать с ним так, как вы ожидаете. В официальном тексте предложения TC39 это прямо указано: устаревший объект ECMAScript Date имеет ряд проблем, включая отсутствие иммутабельности, отсутствие поддержки часовых поясов, отсутствие поддержки сценариев, требующих только дат или только времени, а также запутанный и неудобный API.
Рендеринг в зоне хоста — вот почему один и тот же код показывает правильную дату разработчику в Берлине и неправильную — пользователю в Лос-Анджелесе. Рассмотрим воспроизводящий пример из 8 строк, который можно запустить в Node:
// repro.js — запустить командой: TZ=America/Los_Angeles node repro.js
// и затем: TZ=Europe/Berlin node repro.js
const instant = new Date("2026-03-15T23:30:00Z"); // один фиксированный UTC-момент
console.log(instant.toLocaleDateString());
// TZ=America/Los_Angeles → "3/15/2026" (16:30 по местному, ещё 15-е)
// TZ=Europe/Berlin → "3/16/2026" (00:30 по местному, уже 16-е)
Один момент — две разные календарные даты, зависящие исключительно от зоны хоста. Ошибка невидима для того, кто её написал, потому что его машина находится в одной зоне. Эта ошибка смещения даты из-за зоны хоста — классический дефект «работает на моей машине».
Устойчивые правила, решающие проблему часовых поясов (независимо от библиотек)
Discover how at OpenReplay.com.
Приведённые ниже правила предотвращают ошибки часовых поясов независимо от того, какой API или библиотеку вы используете. Они и есть настоящее лекарство; инструменты, описанные далее, — лишь разные способы их применить.
- Храните и передавайте моменты времени в UTC. Сохраняйте временны́е метки в формате ISO 8601 с суффиксом
Z(2026-05-22T08:00:00Z) или как значение epoch. UTC однозначен и никогда не смещается. - Держите идентификатор зоны IANA в отдельном поле. Зона вроде
Europe/Londonнесёт в себе правила перехода на летнее время, которые одно лишь смещение передать не может. Храните идентификатор, а не сырое смещение вроде+01:00. - Выполняйте преобразование в локальное время только на границе — в момент отображения. Держите всё в UTC на уровнях хранения, передачи и бизнес-логики; локализацию выполняйте только в слое представления.
- Различайте абсолютный момент времени и «настенное» время в конкретной зоне. Запись в логе или значение «создано в» — это момент времени. Встреча в чьём-то календаре — это «настенное» время, привязанное к зоне. Это разные типы данных, и моделировать их нужно по-разному.
- Храните будущие события как зонированные, а не как фиксированный UTC-момент. Это правило почти никто не формулирует явно. Если пользователь планирует встречу на 9:00 утра в
America/New_Yorkза два года вперёд, а этот регион впоследствии изменит правила перехода на летнее время, UTC-метка, вычисленная сегодня, разрешится в неправильное «настенное» время. Хранение зоны позволяет пересчитать момент при наступлении даты.
Последнее правило имеет первоисточниковое обоснование. Стандартная сериализация, используемая Temporal, — RFC 9557 (Internet Extended Date/Time Format, опубликован в апреле 2024 года) — существует именно потому, что, как отмечает Igalia, Temporal нуждается в стандартном способе сериализации временны́х меток с информацией о часовом поясе и календаре, тогда как широко используемые соглашения — например, добавление имён зон IANA к временны́м меткам — никогда не были частью формальных стандартов. MDN даёт операциональную формулировку правила для смещений и именованных зон: избегайте использования идентификаторов смещений, если доступна именованная зона. Даже если регион всегда использовал одно смещение, лучше применять именованный идентификатор — как защиту от возможных политических изменений смещения в будущем.
Локализованное отображение сегодня с помощью Intl.DateTimeFormat
Для корректного локализованного отображения прямо сейчас используйте Intl.DateTimeFormat с явным параметром timeZone. Это единственный встроенный инструмент, который правильно обрабатывает именованные зоны, он доступен во всех современных браузерах и Node, и прекрасно работает как с Date, так и с Temporal.
const instant = new Date("2026-03-15T23:30:00Z");
new Intl.DateTimeFormat("en-US", {
timeZone: "America/New_York",
dateStyle: "full",
timeStyle: "short",
}).format(instant);
// "Sunday, March 15, 2026 at 7:30 PM"
Явная передача timeZone делает этот код безопасным: вы больше не зависите от зоны хоста. Чтобы отобразить тот же момент для другого пользователя, достаточно изменить одну строку. Это и есть правило «преобразование на границе» в коде — держите UTC-момент повсюду, а локализацию доверьте Intl в слое представления.
Temporal: встроенное в язык решение проблемы часовых поясов в JavaScript
Temporal — давно обещанная замена Date, и в 2026 году она стала реальностью. После 9 лет работы на заседании TC39 в марте 2026 года Temporal официально достиг Stage 4, войдя в состав ECMAScript 2026. Репозиторий предложения прямо подтверждает статус: данное предложение в настоящее время находится на Stage 4. Оно будет включено в стандарты ECMA-262 и ECMA-402, а этот репозиторий будет заархивирован.
Temporal заменяет Date пространством имён из иммутабельных, специализированных типов. Три из них вы будете использовать чаще всего:
Temporal.Instant— точный момент во времени (временна́я метка с точностью до наносекунды), без календаря и зоны. Используйте его для UTC-моментов согласно правилу 1.Temporal.ZonedDateTime— момент времени плюс зона IANA плюс календарь. MDN описывает его как мост между точным временем и «настенным»: он одновременно представляет момент в истории и локальное «настенное» время. Это единственный класс Temporal, поддерживающий часовые пояса. Используйте его для зонированных будущих событий (правило 5).Temporal.PlainDate/Temporal.PlainTime— календарная дата или время без зоны, для таких вещей, как дни рождения и часы работы магазина.
Арифметика иммутабельна — каждая операция возвращает новое значение, — а преобразование момента между зонами выполняется явно:
const callAmsterdam = Temporal.ZonedDateTime.from(
"2026-04-24T15:00:00[Europe/Amsterdam]"
);
const callNewYork = callAmsterdam.withTimeZone("America/New_York");
callNewYork.toString();
// "2026-04-24T09:00:00-04:00[America/New_York]"
Temporal также устраняет одну опасную ловушку Date: операторы сравнения для объектов Temporal намеренно выбрасывают TypeError, поскольку в отсутствие valueOf() выражения с арифметическими операторами, такие как plainDate1 > plainDate2, сводились бы к эквиваленту plainDate1.toString() > plainDate2.toString(). Вместо этого используйте Temporal.compare() или .equals() — Temporal.compare() упорядочивает два зонированных значения по их базовому моменту, поэтому считает 9:30 утра в Нью-Йорке и 14:30 в Лондоне равными, тогда как .equals() сообщает о различии, поскольку также сравнивает часовой пояс и календарь. Полный список типов см. в справочнике MDN по Temporal.
Поддержка Temporal в браузерах и средах выполнения (по состоянию на июнь 2026 года)
Temporal уже поставляется, но не повсеместно. Нативная поддержка появилась в Firefox 139, который стал первым браузером, включившим Temporal по умолчанию, в мае 2025 года, а затем в Chrome 144 в январе 2026 года. Edge работает на том же движке Chromium, и Node.js тоже включил поддержку: Node.js 26, выпущенный 5 мая 2026 года с V8 14.6 и Undici 8, включил Temporal без каких-либо флагов или экспериментальных настроек. Впервые в истории JavaScript разработчики получили первоклассный API для работы с датой и временем, встроенный непосредственно в среду выполнения.
Исключение составляет Safari, который пока не включил поддержку — и именно поэтому MDN помечает Temporal как ещё не достигший статуса Baseline. Для кросс-браузерного продакшен-кода по-прежнему необходим полифил. Их два: @js-temporal/polyfill, поддерживаемый авторами предложения, и temporal-polyfill — более компактная и быстрая альтернатива от команды FullCalendar, обеспечивающая кросс-браузерную совместимость для остальных браузеров. Их официальный статус в репозитории предложения — alpha/beta, а не стабильная версия 1.0, поэтому зафиксируйте версию и проведите тестирование перед выпуском. Перед принятием решения проверьте размер сжатого бандла на Bundlephobia — опубликованные цифры существенно расходятся.
Какую библиотеку использовать в продакшене прямо сейчас
Если вы не можете полагаться на нативный Temporal во всех ваших целевых окружениях — а большинство продакшен-приложений не могут, пока Safari не добавит поддержку и вы не откажетесь от полифила, — обратитесь к одному из следующих вариантов. Рекомендации по состоянию на июнь 2026 года:
| Инструмент | Поддержка часовых поясов? | Иммутабельность? | Нативно сегодня? | Когда использовать |
|---|---|---|---|---|
Date + Intl.DateTimeFormat | Только отображение | Нет (Date мутабелен) | Да | Минимальные потребности; форматирование существующего момента |
| Luxon 3.7.2 | Да (IANA) | Да | Да | Новый код, требующий удобного иммутабельного API, близкого к Temporal |
date-fns 4.4.0 + @date-fns/tz | Да (IANA) | Да | Да | Кодовые базы с tree-shaking и импортом по одной функции |
| Day.js + плагины utc/timezone | Да (IANA) | Да | Да | Минимальный размер бандла; миграция с Moment.js |
Temporal (нативный или полифил) | Да (первоклассная) | Да | Частично | Управляемые evergreen/Node-окружения или при наличии полифила |
Наиболее важная ловушка точности — в колонке date-fns. Поддержка часовых поясов изменилась между мажорными версиями: начиная с v4, date-fns имеет первоклассную поддержку часовых поясов. Она обеспечивается через пакеты @date-fns/tz и @date-fns/utc. В подходе v4 используются класс TZDate и хелпер tz() из @date-fns/tz (v1.5.0). Более старый пакет date-fns-tz (v3.2.0) предназначен для date-fns v3 и явно это указывает — в его собственной документации сказано, что его следует использовать, если вам нужна поддержка часовых поясов для date-fns v3 и ниже. Не смешивайте их.
Граничные случаи летнего времени, которые действительно кусают
Переход на летнее время порождает два режима отказа, и оба, как правило, недостаточно тестируются в большинстве кодовых баз. Осенью (при переводе часов назад) один местный час повторяется дважды, поэтому «настенное» время вроде 01:05 оказывается неоднозначным. Весной (при переводе часов вперёд) один местный час не существует вовсе, поэтому время вроде 02:05 является недопустимым.
Temporal разрешает оба случая детерминированно. Это разрешается с использованием поведения disambiguation: "compatible": для переходов с пропуском времени используется более поздний из двух возможных моментов, а для переходов с повторением времени — более ранний. Результаты:
// Перевод часов назад: 01:05 встречается дважды в Нью-Йорке 2024-11-03
Temporal.ZonedDateTime.from("2024-11-03T01:05:00[America/New_York]").toString();
// "2024-11-03T01:05:00-04:00[America/New_York]" (по умолчанию: более ранний момент)
Temporal.ZonedDateTime.from("2024-11-03T01:05:00[America/New_York]",
{ disambiguation: "later" }).toString();
// "2024-11-03T01:05:00-05:00[America/New_York]" (второе вхождение)
// Перевод часов вперёд: 02:05 не существует в Нью-Йорке 2024-03-10
Temporal.ZonedDateTime.from("2024-03-10T02:05:00[America/New_York]").toString();
// "2024-03-10T03:05:00-04:00[America/New_York]" (по умолчанию: пропуск на час вперёд)
Для пропущенного часа можно также передать disambiguation: "reject", чтобы выбросить исключение вместо молчаливого разрешения — это полезно, когда бронирование попадает на несуществующее время и вы предпочитаете запросить подтверждение у пользователя, а не угадывать. С Date ничего из этого не обрабатывается автоматически, и ошибка проявляется только у пользователей в зонах с переходом на летнее время, в два дня в году, когда этот переход происходит.
Дефекты, связанные с часовыми поясами, трудно исправить именно потому, что они не воспроизводятся в зоне разработчика. Обратный отсчёт показывает отрицательное значение, карточка события отображает неправильный день, бронирование попадает не на ту сторону границы перехода на летнее время — но всё это происходит только у пользователя, никогда на машине, написавшей этот код. Воспроизведение сессии нередко оказывается единственным практическим способом закрыть этот разрыв: воспроизведение сессии, записанной в окружении пользователя, позволяет разработчику в Europe/Berlin увидеть ту самую неправильную дату, которую видел пользователь в America/Los_Angeles, вместо того чтобы пытаться её вообразить.
Что делать дальше
Лекарство от ошибок часовых поясов — не библиотека, а дисциплина: хранить моменты в UTC, держать рядом зону IANA, выполнять преобразование только при отображении и моделировать будущие события как зонированные. Сначала примените эти правила, затем выбирайте инструмент: нативный Temporal там, где его поддерживают ваши окружения, полифил — где нет, и Luxon или date-fns v4 с @date-fns/tz — для всего остального. Начните с аудита одного места в вашей кодовой базе, где «настенное» время хранится без зоны — именно там почти наверняка притаилась ваша следующая ошибка смещения на один день.
Часто задаваемые вопросы
Почему у некоторых пользователей дата отображается со сдвигом на день, а у меня — нет?
JavaScript-объект Date хранит только UTC-момент, а методы вроде toLocaleDateString отображают его в зоне хост-машины. Один фиксированный момент, например 2026-03-15T23:30:00Z, отображается как 15 марта в America/Los_Angeles (16:30 по местному) и как 16 марта в Europe/Berlin (00:30 по местному). Код корректен; календарная дата различается, потому что различаются зоны отображения. Именно поэтому ошибка никогда не воспроизводится в часовом поясе разработчика.
Нужно ли хранить время будущей встречи как UTC-метку?
Нет. Храните будущее событие как «настенное» время, привязанное к его зоне IANA, например 2026-05-22T09:00:00 вместе с America/New_York, а не как фиксированный UTC-момент. Если регион изменит правила перехода на летнее время между сегодняшним днём и датой события, UTC-метка, вычисленная сегодня, разрешится в неправильное «настенное» время, тогда как зонированное значение можно пересчитать. UTC-моменты правильны для логов и прошедших событий, но не для будущих встреч.
В чём разница между date-fns-tz и @date-fns/tz?
Они предназначены для разных мажорных версий и не являются взаимозаменяемыми. Более старый пакет date-fns-tz (v3.2.0) обеспечивает поддержку часовых поясов только для date-fns v3. Начиная с date-fns v4, работа с часовыми поясами перенесена в отдельный пакет @date-fns/tz (v1.5.0), который предоставляет класс TZDate и хелпер tz. Если вы используете date-fns 4.x, применяйте @date-fns/tz; смешивание пакетов с несовместимой мажорной версией — распространённый источник некорректных преобразований.
Можно ли использовать Temporal API в продакшене в 2026 году?
Частично. По состоянию на июнь 2026 года Temporal является предложением Stage 4 в ECMAScript 2026 и поставляется нативно в Firefox 139+, Chromium 144+ (Chrome и Edge) и Node.js 26+. Safari его не включил — именно поэтому MDN помечает Temporal как ещё не достигший статуса Baseline. В управляемых evergreen- или серверных окружениях его можно использовать нативно; для широкой браузерной поддержки по-прежнему необходим @js-temporal/polyfill или temporal-polyfill, оба из которых имеют статус alpha или beta, поэтому зафиксируйте версию и проведите тестирование перед выпуском.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k