В чём состоит проблема​

Из всех последних изменений, которые будут внедрены в ECMAScript, моим любимым с большим отрывом от остальных стало предложение Temporal. Это предложение очень прогрессивное, мы уже можем воспользоваться этим API при помощи полифила, разработанного командой FullCalendar.

Этот API настолько невероятен, что я, наверно, посвящу несколько постов описанию его основных возможностей. Однако в первом посте я расскажу об одном из его главных преимуществ: у нас наконец появился нативный объект, описывающий Zoned Date Time.

Но что же такое Zoned Date Time?

Человеческие даты и даты JS​

Когда мы говорим о человеческих датах, то обычно произносим что-то типа «У меня назначено посещение врача на 4 августа 2024 года в 10:30», но не упоминаем часовой пояс. Это логично, ведь чаще всего наш собеседник знает нас и понимает, что когда я говорю о датах, то имею в виду контекст своего часового пояса (Европы/Мадрида).

К сожалению, в случае с компьютерами это не так. Когда мы работаем с объектами Date в JavaScript, мы имеем дело с обычными числами.

В официальной спецификации говорится следующее:

«Значение времени ECMAScript — это число; или конечное целое число, описывающее момент времени с точностью до миллисекунд, или NaN, описывающее отсутствие конкретного момента»

Кроме того, что даты в JavaScript представлены не в UTC, а в POSIX (это ОЧЕНЬ ВАЖНО), где полностью игнорируются секунды координации, проблема с описанием времени в виде числа заключается в потере исходной семантики данных. То есть имея человеческую дату, мы можем получить эквивалентную дату JS, но не наоборот.

Рассмотрим пример: допустим, мне нужно зафиксировать момент осуществления платежа с моей карты. У многих разработчиков возникает искушение написать что-то вроде этого:

const paymentDate = new Date('2024-07-20T10:30:00');

Так как мой браузер находится в часовом поясе CET, когда я записываю это, браузер просто «вычисляет количество миллисекунд с начала EPOX для этого момента CET».

Вот, что мы на самом деле сохраняем в дату:

paymentDate.getTime();
// 1721464200000

То есть в зависимости от того, как мы прочитаем эту информацию, мы получим разные «человеческие даты»:

Если считать их с точки зрения CET, то мы получим 10:30:

d.toLocaleString()
// '20/07/2024, 10:30:00'

а если считать с точки зрения ISO, то 8:30:

d.toISOString()
// '2024-07-20T08:30:00.000Z'

Многие считают, что работая с UTC или передавая данные в формате ISO, они обеспечивают безопасность, однако это не так, информация всё равно теряется.

Формата UTC недостаточно​

Даже при работе с датами в формате ISO с учётом смещения, когда в следующий раз мы захотим отобразить дату, мы будем знать только количество миллисекунд, прошедших с эпохи UNIX, и смещение. Но этого всё равно недостаточно, чтобы знать «человеческий» момент и часовой пояс выполнения платежа.

Строго говоря, имея метку времени t0, мы можем получить n описывающих её человекочитаемых дат...

Иными словами, функция, отвечающая за преобразование метки времени в человекочитаемую дату, не инъективна, так как каждый элемент во множестве меток времени соответствует более чем одному элементу во множестве «человеческих дат».

Ровно то же самое происходит при сохранении дат в ISO, так как метки времени и ISO — это два описания одного момента:

Это происходит и при работе со смещениями, потому что разные часовые пояса могут иметь одинаковое смещение.

Если вы всё ещё не до конца понимаете проблему, то позвольте мне проиллюстрировать её примером. Представим, что вы живёте в Мадриде и отправились в Сидней.

Несколько недель спустя вы возвращаетесь в Мадрид и видите странное списание, которое не можете вспомнить... с меня взяли 3,50 в 2 часа ночи 16 числа? Чем я занимался? Той ночью я рано лёг!.. Не понимаю.

Немного поволновавшись, вы понимаете, что это оплата кофе, выпитого вами на следующее утро, поскольку прочитав статью, вы уже осознаёте, что ваш банк хранит все транзакции в UTC, а приложение преобразует их в часовой пояс телефона.

Это может оказаться невинной историей, но что, если ваш банк позволяет бесплатно снимать наличные один раз в день? Когда начинается и завершается день? ПО UTC? По Австралии?... Всё становится сложнее, поверьте мне...

Надеюсь, теперь вы уже поняли, что работа исключительно с метками времени представляет собой проблему; к счастью, у неё есть решение.

ZonedDateTime​

Кроме всего прочего, в новом Temporal API внедряется концепция объекта Temporal.ZonedDateTime , специально предназначенного для описания дат и времени в соответствующем часовом поясе. Разработчики также предложили расширение RFC 3339 для стандартизации сериализации и десериализации строк, описывающих данные:

Вот пример:

   1996-12-19T16:39:57-08:00[America/Los_Angeles]

Эта строка описывает 39 минут и 57 секунд после 16-го часа 19 декабря 1996 года со смещением -08:00 от UTC и дополнительно определяет связанный с датой часовой пояс («Pacific Time»), чтобы его могли использовать приложения, учитывающие часовой пояс.

Кроме того, этот API позволяет работать с различными календарями, и в том числе:

  • буддистским

  • китайским

  • коптским

  • корейским

  • эфиопским

  • григорианским

  • еврейским

  • индийским

  • исламским

  • исламским-umalqura

  • исламским-tbla

  • исламским-civil

  • исламским-rgsa

  • японским

  • персидским

  • календарём Миньго

Среди них всех самым популярным будет iso8601 (стандартная адаптация григорианского календаря), с которым вы будете работать чаще всего.

Основные операции​

Создание дат

Temporal API даёт большое преимущество при создании дат, особенно при помощи объекта Temporal.ZonedDateTime. Одна из его выдающихся особенностей — возможность беспроблемной работы с часовыми поясами, в том числе со сложными ситуациями, касающимися летнего времени (Daylight Saving Time, DST). Например, при создании объекта Temporal.ZonedDateTime следующим образом:

const zonedDateTime = Temporal.ZonedDateTime.from({
  year: 2024,
  month: 8,
  day: 16,
  hour: 12,
  minute: 30,
  second: 0,
  timeZone: 'Europe/Madrid'
});

вы не не просто задаёте дату и время; вы обеспечиваете точное описание даты в указанном часовом поясе. Благодаря такой точности вне зависимости от изменений DST и любых других изменений локального времени ваша дата всегда будет отражать корректный момент во времени.

Эта функция особенно полезна при планировании событий или логировании действий, согласованных между несколькими регионами. Встроив часовой пояс непосредственного в процесс создания даты, Temporal устраняет часто возникающие проблемы традиционных объектов Date, например, неожиданные сдвиги времени из-за DST или разницы в часовых поясах. Поэтому Temporal — это не просто способ облегчить себе жизнь, а необходимость в современной веб-разработке, где критически важна глобальная согласованность времени.

Если вам любопытно, чем же так хорош этот API, прочитайте статью с объяснением того, как работать с изменениями в определениях часовых поясов.

Сравнение дат​

У ZonedDateTime есть статический метод compare, который получает два ZonedDateTime и возвращает:

  • −1, если первое меньше второго

  • 0, если оба описывают ровно один и тот же момент без учёта часового пояса и календаря

  • 1, если первое больше второго.

Можно легко сравнивать даты в необычных случаях, например, при повторе часа после завершения DST более поздние значения могут быть в часовом времени раньше, и наоборот:

const one = Temporal.ZonedDateTime.from('2020-11-01T01:45-07:00[America/Los_Angeles]');
const two = Temporal.ZonedDateTime.from('2020-11-01T01:15-08:00[America/Los_Angeles]');

Temporal.ZonedDateTime.compare(one, two);
  // => -1
  // (потому что `one` в реальном мире происходит раньше)

Отличные встроенные возможности​

У ZonedDateTime есть заранее вычисленные атрибуты, упрощающие вам жизнь, например:

hoursInDay

Свойство только для чтения hoursInDay возвращает количество реальных часов между началом текущего дня (обычно полуночью) в zonedDateTime.timeZone до начала следующего календарного дня в том же часовом поясе.

Temporal.ZonedDateTime.from('2020-01-01T12:00-08:00[America/Los_Angeles]').hoursInDay;
  // => 24
  // (обычныый день)
Temporal.ZonedDateTime.from('2020-03-08T12:00-07:00[America/Los_Angeles]').hoursInDay;
  // => 23
  // (в этот день начинается DST)
Temporal.ZonedDateTime.from('2020-11-01T12:00-08:00[America/Los_Angeles]').hoursInDay;
  // => 25
  // (в этот день завершается DST)

Также у ZonedDateTime есть отличные атрибуты daysInYearinLeapYear

Преобразование часовых поясов​

У ZonedDateTimes есть метод .withTimeZone , позволяющий по необходимости менять ZonedDateTime:

zdt = Temporal.ZonedDateTime.from('1995-12-07T03:24:30+09:00[Asia/Tokyo]');
zdt.toString(); // => '1995-12-07T03:24:30+09:00[Asia/Tokyo]'
zdt.withTimeZone('Africa/Accra').toString(); // => '1995-12-06T18:24:30+00:00[Africa/Accra]'

Арифметика​

Можно использовать метод .add для прибавления части даты временного интервала при помощи календарной арифметики. Результат автоматически учитывает Daylight Saving Time на основе правил поля timeZone этого экземпляра.

Замечательно в этом то, что поддерживается возможность выполнять арифметические действия как с календарной арифметикой, так и простыми длительностями.

  • Прибавление или вычитание дней должно согласовывать часовое время при переходах DST. Например, если у вас назначена встреча в субботу в 13:00, и вы хотите перенести её на один день вперёд, то будете ожидать, что встреча снова будет назначена на 13:00, даже если ночью произошёл переход на летнее время.

  • Прибавление или вычитание части времени длительности должно игнорировать переходы DST. Например, если вы договорились с другом встретиться через два часа, то он расстроится, если вы придёте через час или три часа.

  • Должен существовать согласованный и достаточно ожидаемый порядок операций. Если результаты попадают на переход DST или рядом с ним, то неопределённость должна устраняться автоматически (без сбоев) и детерминированно.

zdt = Temporal.ZonedDateTime.from('2020-03-08T00:00-08:00[America/Los_Angeles]');
// Прибавляем день, чтобы получить полночь в день после дня начала DST
laterDay = zdt.add({ days: 1 });
  // => 2020-03-09T00:00:00-07:00[America/Los_Angeles]
  // Обратите внимание, что новое смещение отличается, это показывает, что результат учитывает DST.
laterDay.since(zdt, { largestUnit: 'hour' }).hours;
  // => 23
  // потому что один час потерялся из-за DST

laterHours = zdt.add({ hours: 24 });
  // => 2020-03-09T01:00:00-07:00[America/Los_Angeles]
  // Прибавление единиц времени не учитывает DST. Результат равен 1:00: спустя 24 часов
  // реального времени, потому что один час был пропущен из-за DST.
laterHours.since(zdt, { largestUnit: 'hour' }).hours; // => 24

Вычисление разностей между датами

У Temporal есть метод .until , который вычисляет разность между двумя моментами времени, представленными в zonedDateTime, опционально округляет её и возвращает в виде объекта Temporal.Duration. Если второе время было раньше, чем zonedDateTime, то получившаяся длительность будет отрицательной. Если использовать опции по умолчанию, то при сложении возвращаемого Temporal.Duration с zonedDateTime получится второе значение.

Это может показаться тривиальной операцией, но я советую прочитать полную спецификацию, чтобы понять её нюансы.

Заключение​

Temporal API — это революционное изменение в обработке времени в JavaScript, благодаря чему он становится одним из немногих языков, где эта проблема решена исчерпывающе. В этой статье я рассмотрел тему лишь поверхностно, рассказав о разнице между человекочитаемыми датами (или временем на часах) и датами UTC, а также о том, как объект Temporal.ZonedDateTime можно использовать для точного описания первого.

В будущих статьях мы рассмотрим другие замечательные объекты, например, Instant, PlainDate и Duration.

Комментарии (21)


  1. Metotron0
    25.08.2024 11:39
    +3

    Пример с кофе имеет и обратную сторону. Я тоже пил кофе в области с временем на час больше моего, потом вернулся и опять попил кофе, в итоге в банковском приложении получилось, что второй кофе я попил раньше первого.

    Если бы там обозначался часовой пояс, то, может, и было бы понятнее, но визуального мусора прибавилось бы.

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


    1. Farongy
      25.08.2024 11:39

      Ещё бывает, что в поезде ставишь будильник и не знаешь, успеет ли телефон перейти на другой часовой пояс к тому моменту.

      Авиарежим?


    1. aamonster
      25.08.2024 11:39

      Ещё бывает, что в поезде ставишь будильник и не знаешь, успеет ли телефон перейти на другой часовой пояс к тому моменту.

      Вроде в календаре события к UTC привязаны, можно для таких случаев там напоминалку ставить.


  1. plFlok
    25.08.2024 11:39
    +5

    Буквально на днях завирусилась пара постов на тему WTF с датами в js, где как раз возникал вопрос, какого фига это до сих пор в таком виде существует

    Сами посты


    1. ImagineTables
      25.08.2024 11:39

      А что не нравится Кристине? Оба конструктора, из числа и строки, работают вполне ожидаемо. Ну, может быть, стоило бы сделать конструктор из строки построже, чтобы год требовал хотя бы двух разрядов, т.е. ведущего нуля, а голый год в дате — всех четырёх.

      Настоящая проблема в том, что в ES до сих пор нет режима запрета неявных типопреобразований (и можно нечаянно передать '0' вместо 0).

      Как паллиатив, я бы сделал фабрику дат с кучей статических методов .fromWhat().


  1. Olegun
    25.08.2024 11:39
    +1

    Можно было бы расширить Zoned Date Time. И сразу прилепить GPS координаты оставив еще поле под расширение для полетов за кофе на Марс.


  1. ilyamodder
    25.08.2024 11:39
    +4

    Многие считают, что работая с UTC или передавая данные в формате ISO, они обеспечивают безопасность, однако это не так, информация всё равно теряется.

    Не очень понял аргументацию. Как она может теряться, если дата-время в UTC гарантированно обозначают ровно одну точку во времени? Про ISO-формат согласен, но в чем проблема хранить в UTC, а потом преобразовывать в таймзону клиента при отображении?


    1. inkelyad
      25.08.2024 11:39
      +3

      Судя по всему, проблема в том, что Date - страшно неправильное название. Должно быть Instant. Стандартная ошибка всех старых языков.

      И тип использовали для того, для чего не надо бы (ввиду отсутствия альтернативы). Дата, которая выбирается в каком-нибудь виджете календаря или пишется на документе - это на самом деле интервал между двумя точками во времени. И когда ты вместо этого интервала получаешь Date (с установленными в 0 часом/минутой) - то ты, действительно, теряешь информацию - понять, какие именно точки были началом и концами этого интервала, нельзя.


      1. ImagineTables
        25.08.2024 11:39

        Дата, которая выбирается в каком-нибудь виджете календаря или пишется на документе - это на самом деле интервал между двумя точками во времени.

        This is ground control to major Tom… )))))

        Крайне сомневаюсь, что такая концепция даты зашла бы массовому программеру.


        1. inkelyad
          25.08.2024 11:39

          Крайне сомневаюсь, что такая концепция даты зашла бы массовому программеру.

          Тем не менее, 'дата' именно это и означает, если подумать и если работаешь над/в системе, размазанной по таймзонам. И потом человек, сидящий в Москве, хочет посмотреть список событий за 'вчера', произошедших в Владивостоке. ("За чье 'вчера'? И когда оно началось?")

          А так да, не очень заходит. Скажем в xml/xsd это сформулировали практически правильно. (потому что его придумывали как средство переноса данных между системами):

          [Definition:] date represents top-open intervals of exactly one day in length on the timelines of dateTime, beginning on the beginning moment of each day, up to but not including the beginning moment of the next day). For non-timezoned values, the top-open intervals disjointly cover the non-timezoned timeline, one per day. For timezoned values, the intervals begin at every minute and therefore overlap.

          Но когда всякие парсеры XML эти даты читают - ооочень редко когда оно превращается именно в интервал.


    1. zelenin
      25.08.2024 11:39

      бизнес-процессы могут быть привязаны к местному времени. Сохраняя дату в UTC, мы теряем информацию о местном времени. Другими словами из 13:00 UTC мы не можем восстановить 16:00 MSK


  1. Bigata
    25.08.2024 11:39

    Согласен с коллегами выше, хранить и пользоваться лучше временной меткой UTC. Только показать в нужном формате.

    Разницы поудобнее наверное юзать.


  1. nin-jin
    25.08.2024 11:39

    Я просто оставлю это здесь: https://mol.hyoo.ru/#!section=docs/=giikl8_xe40dd

    4кб и вы получаете простой и универсальный апи уже сейчас с поддержкой не только времени, но и интервалов.


    1. zoto_ff
      25.08.2024 11:39

      /del


  1. amishaa
    25.08.2024 11:39

    И в оригинале, и в переводе что-то не так со сниппетом про сравнение времени ( one/two) - выглядит так, что либо должна быть разная таймзона в разных строчках, либо как-то ещё указано, что в two время наступило второй раз.


  1. Cherezzabo
    25.08.2024 11:39
    +1

    А это точно хорошее решение привязывать таймзону к строковым литералам "Europe/Madrid" или "Asia/Tokio"? Понятно, что для удобства разработчика сделано, но что делать если вдруг гео-политическая реальность вмешается в действительность (не дай бог, конечно)?


    1. vanxant
      25.08.2024 11:39

      Это единственное хорошее решение. Из даты с частью Z+3:00 вы не можете восстановить местное время события, потому что DST, да и просто зоны иногда меняются властями. Так что да, таймзону придётся хранить вечно, tzdata обязательна и будет только расти и пухнуть


      1. bBars
        25.08.2024 11:39

        Вот именно. И dst, и сами таймзоны меняются периодически. Поэтому для полного понимания нужно после наименования зоны указывать ещё и текущий таймстамп, чтобы обозначить время применения смещения. И вдобавок иметь под рукой справочник: таак, вот в 12 ночи такого-то числа 2013 года отменили dst; потом тогда-то вернули.

        А для указания времени применения тоже ведь нужно часовой пояс указать. Так, погодите-ка...


  1. VoodooCat
    25.08.2024 11:39

    Откровенно говоря, zoned time не нужен вообще и UTC даты более чем достаточно. Вы мля приводите примеры с платежами где клиентское время в браузере вообще не может участвовать. И прочую дичь. Улучшения безусловно нужны, но примеры - высосаны из пальца и к жизни не имеют отношения.


  1. muxa_ru
    25.08.2024 11:39

    мы будем знать только количество миллисекунд, прошедших с эпохи UNIX, и смещение

    Если коротко, то "нет", если полнее то "вот вообще нет".


  1. jt3k
    25.08.2024 11:39

    tl;dr: читайте спеку и описание сами.

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