[Перевод] Даты в Javascript наконец-то пофиксят

ba95cbf5ab4e18ffa75e2a4adba7e57d.png

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

Из всех последних изменений, которые будут внедрены в 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 описывающих её человекочитаемых дат…

50e384c166c69a0234f71a74888c459e.png

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

5409e991ea973efe89a9bbcd2e1ccdf6.png

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

65b3cd0df2434c292c9be995f6cd1047.png

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

04e754a431b36bea765772a07cc491e4.png

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

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

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

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

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

ZonedDateTime​

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

65473c459a6bd7c6c20215463386d5d9.png

Вот пример:

   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 есть отличные атрибуты daysInYear,  inLeapYear

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

У 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.

© Habrahabr.ru