5 причин ненавидеть то, как JavaScript работает с датами

Kate

Administrator
Команда форума
В этой статье я собрала топ-5 вещей, касающихся работы с Date, которые часто вводят JS программистов в заблуждение, недоразумение и даже ярость.

Kак мне кажется, именно по этим причинам мы продолжаем тащить в свои проекты библиотеки для работы с датами, а не пользуемся средствами нативного JS.

Есть ли свет в конце тоннеля? Есть. Новое API под названием Temporal находится на третьей стадии — экспериментальной — это значит, что вы уже можете его попробовать (хотя бы с помощью полифилов) и внести свои предложения, пока не поздно.

Так почему стандартных средств текущей версии JS недостаточно?

Причина 1. Мутабельность обьектов Date

Самая известная причина проблем с Date — мутабельность обьектов Date, а также вот такое странное поведение при попытках изменить дату:

Создадим дату, соответствующую первому января:

const d1 = new Date(2022, 0, 1) // "Sat Jan 01 2022 00:00:00 GMT+0200"
А теперь изменим месяц на 10й. И получаем второе декабря:

d1.setUTCMonth(10) // "Thu Dec 02 2021 00:00:00 GMT+0200"
И, что интересно, если мы еще раз изменим месяц на 10й — то получим второе ноября:

d1.setUTCMonth(10) // "Tue Nov 02 2021 00:00:00 GMT+0200"
Один и тот же метод, вызванный дважды, дал разный результат (facepalm).

Причина 2. Конструктор класса Date принимает на вход даже полную ерунду:

Создадим две даты:

const d1 = new Date('hello');
const d2 = new Date('world');
Все произошло без каких-либо ошибок или warning-ов. Давайте как-то этими датами поманипулируем, как будто мы собрались в коде что-то делать в зависимости от того, какая дата раньше:

if (d1 <= d2) console.log('less');
if (d1 >= d2) console.log('more');
— и снова никаких сообщений об ошибках, и в консоль ничего не вывелось — ни ’less’ ни ’more’.

А все почему? Потому что вот:

console.log(d1.valueOf()) // NaN
console.log(d2.valueOf()) // NaN
console.log(d1.getTime()) // NaN
console.log(d2.getTime()) // NaN
Эти даты — d1 и d2 — не являются валидными, но JS об этом намертво молчит. У нас происходила неприятная ситуация, когда из стороннего API начали приходить невалидные даты, а на нашей стороне обьекты Date создавались из поступающих на вход строк без дополнительной валидации, и дальше при работе с датами происходила полная несуразица.

Вывод: всегда валидируйте строки с датами, которые получаете из сторонних источников (http запросы, API, файлы), прежде чем продолжать с ними какие-либо действия или манипуляции. Иначе вы будете в коде, сами того не зная, работать с NaN и даже не заметите, что это происходит. Просто все логические условия будут срабатывать как false.

Быстро нативными средствами провалидировать можно, например, так:

const d3 = new Date(startDate);
if (isNaN(d3)) throw new Error(`Invalid date: ${startDate}`)


Причина 3. Некоторые строки трактуются как UTC, некоторые — как локальное время, и это для многих неочевидно:

Создадим объект Date двумя разными способами:

const d1 = new Date('2021-07-14');
const d2 = new Date('2021/07/14');
Казалось бы — и в первом, и во втором случае мы имеем в виду 14 июля 2021 года, но посмотрите — d1 не равно d2:

console.log(d1 > d2) // true
console.log(d1 < d2) // false
console.log(d1 - d2) // 10800000
Почему даты как бы одинаковые, но это не одна и та же дата?

Потому что:

  • если в строке поступает дата, представленная в формате YYYY-MM-DD (или в полном формате ISO 8601 YYYY-MM-DDThh:mm:ss.sssZ), то она трактуется как дата в UTC таймзоне (считается, что время в строке указано по Гринвичу);
  • во всех остальных случаях строка трактуется как дата в текущей таймзоне пользователя (Киев, Нью-Йорк и т.п.) и считается, что время в строке указано в локальной таймзоне;
  • как вы понимаете, полночь 14 июля по Киеву и полночь 14 июля по Гринвичу — это два разных момента времени с разницей в 3 часа, поэтому разница между d1 и d2 составляет 10800000 миллисекунд.
Пример строкиТрактуется как
«2021-01-01»UTC, 00:00
«2021/01/01»Europe/Kiev, 00:00
«2021.01.01»Europe/Kiev, 00:00
«20210101»NaN
«2021-01-01Z»UTC, 00:00
«2021/01/01Z»UTC, 00:00
«2021-01-01T17:45:00.000Z»UTC, 17:45
«2021-01-01T17:45:00»Europe/Kiev, 17:45
«2021-01-01 17:45Z»UTC, 17:45
«2021/01/01 17:45»Europe/Kiev, 17:45

Причина 4. Объект Date «не помнит» из какой строки он был создан, и не дает возможности достучаться к исходной строке:​

Создадим два объекта Date разными способами:

const d1 = new Date('2022-02-08T12:00:00+03:00');
const d2 = new Date('2022-02-08T13:00:00-04:00');

Дальше начинающие джаваскриптеры часто спрашивают:

1. Каким методом объектов d1 и d2 я могу получить, например, вот те части +03:00 и -04:00 из исходных строк?

Ответ: никаким!

2. Каким методом объектов d1 и d2 мне можно получить смещение в минутах от Гринвича для этих дат (180 и −240)?

Ответ: никаким!

console.log(d1.getTimezoneOffset()) // -120
console.log(d2.getTimezoneOffset()) // -120

3. Ладно, последний вопрос — а почему .getHours() мне возвращает не 12 и 13, а 11 и 19?

Ответ: а потому!

Ладно, если серьезно — потому что метод .getHours() возвращает не часы, которые были в исходной строке, а часы в твоем локальном времени, соответствующие тому времени, что было там в строке.

const d1 = new Date('2022-02-08T12:00:00+03:00');
const d2 = new Date('2022-02-08T13:00:00-04:00');
console.log(d1.getHours()) // 11
console.log(d2.getHours()) // 19


Причина 5. Intl.DateTimeFormat — хорош, но все еще ограничен в возможностях:

Объект Intl, который дает доступ к различным классам и методам для интернационализации приложений, начали разрабатывать с 2012 года, а с 2015 — внедрять в v8.

Для работы с датами и временем нас особенно интересует конструктор Intl.DateTimeFormat который дал нам две долгожданные возможности в нативном JS.

С помощью Intl.DateTimeFormat:

1. Мы можем узнать таймзону пользователя:

const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
console.log(userTimeZone); // Europe/Kiev
2. Мы можем отформатировать дату под любую таймзону:

Сделаем вот такой helper для фоматирования даты, чтобы не дублировать код:

const formatDate = function (date, timeZone) {
const format_options = {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
timeZone,
timeZoneName: 'short'
}
return new Intl.DateTimeFormat('en-US', format_options).format(date);
}
И посмотрим, как мы теперь можем форматировать дату и время:

formatDate(new Date('2017-07-14'), 'America/Los_Angeles') // 7/13/2017, 5:00:00 PM PDT
formatDate(new Date('2017-07-14'), 'America/New_York') // 7/13/2017, 8:00:00 PM EDT
formatDate(new Date('2017-07-14'), 'Europe/Kiev') // 7/13/2017, 3:00:00 AM GMT+3
formatDate(new Date('2017-07-14'), 'Asia/Shanghai') // 7/14/2017, 8:00:00 AM GMT+8
formatDate(new Date('2017-07-14'), 'Australia/Sydney') // 7/14/2017, 10:00:00 AM GMT+10
Прекрасненько!

Но у нас по-прежнему, несмотря на введение Intl.DateTimeFormat, нет возможности средствами нативного js, без библиотек:

  • Получить список всех имеющихся таймзон (для выпадающего списка при регистрации пользователя, например)
  • Получить смещение какой-либо таймзоны по ее названию (к примеру −07:00 или 420 минут для ’America/Los_Angeles’)
  • Без танцев с бубнами получить аббревиатуру часового пояса (опишу ниже)
Проблема с короткими аббревиатурами часовых поясов:

Это наименьшее из зол, но когда с ним сталкиваешься и заказчик просит, чтобы были именно аббревиатуры, то это не кажется мелочью. Часто нужно отформатировать дату и показать код часового пояса — например, EEST, EST, IST, PST — так как люди привыкли к этим аббревиатурам и хотят видеть именно их, а не смещения −08:00 или +03:00

У класса Date совсем нет методов получить эти аббревиатуры, а конструктор Intl.DateTimeFormat дает нам возможность их получить, но не всегда работает, как хотелось бы.

В примере ниже вы можете заметить, что для первых двух таймзон вернулся код (PDT, EDT), а для остальных вернулось смещение в формате GMT+N

formatDate(date, 'America/Los_Angeles') // 7/13/2017, 5:00:00 PM PDT
formatDate(date, 'America/New_York') // 7/13/2017, 8:00:00 PM EDT
formatDate(date, 'Europe/Kiev') // 7/13/2017, 3:00:00 AM GMT+3
formatDate(date, 'Asia/Shanghai') // 7/14/2017, 8:00:00 AM GMT+8
formatDate(date, 'Australia/Sydney') // 7/14/2017, 10:00:00 AM GMT+10
Часовые пояса вернулись в виде кодов только для таймзон из США, потому что мы использовали локаль ’en-US’ вот здесь, когда создавали объект DateTimeFormat в функции-хелпере formatDate:

return new Intl.DateTimeFormat('en-US', format_options).format(date);
если использовать локаль ua-UA то код часового пояса возвращается только для Europe/Kiev:

formatDate(date, 'America/Los_Angeles') // 7/13/2017, 17:00:00 GMT-7
formatDate(date, 'America/New_York') // 7/13/2017, 20:00:00 GMT-4
formatDate(date, 'Europe/Kiev') // 7/13/2017, 3:00:00 EEST
formatDate(date, 'Asia/Shanghai') // 7/14/2017, 8:00:00 GMT+8
formatDate(date, 'Australia/Sydney') // 7/14/2017, 10:00:00 GMT+10
если использовать локаль en-AU то код часового пояса возвращается только для Australia/Sydney:

formatDate(date, 'America/Los_Angeles') // 7/13/2017, 17:00:00 pm GMT-7
formatDate(date, 'America/New_York') // 7/13/2017, 20:00:00 pm GMT-4
formatDate(date, 'Europe/Kiev') // 7/13/2017, 3:00:00 am GMT+3
formatDate(date, 'Asia/Shanghai') // 7/14/2017, 8:00:00 am GMT+8
formatDate(date, 'Australia/Sydney') // 7/14/2017, 10:00:00 am AEST
если использовать локаль default то код часового пояса возвращается только для Europe/Kiev:

formatDate(date, 'America/Los_Angeles') // 7/13/2017, 17:00:00 GMT-7
formatDate(date, 'America/New_York') // 7/13/2017, 20:00:00 GMT-4
formatDate(date, 'Europe/Kiev') // 7/13/2017, 3:00:00 EEST
formatDate(date, 'Asia/Shanghai') // 7/14/2017, 8:00:00 GMT+8
formatDate(date, 'Australia/Sydney') // 7/14/2017, 10:00:00 GMT+10
В большом количестве задач нам нужен именно код часового пояса (а не смещение), но как это сделать средствами нативного JS так, чтобы это работало для всех таймзон, а не только для текущей, я не вижу. Коды возвращаются корректно, только если вы подставляете правильно и локаль, и часовой пояс пользователя.

Я считаю это непродуманным (или, как минимум, неудобным), так как пользователь может использовать локаль ua_UA (украинский язык интерфейса), находясь в командировке в США (в часовом поясе 'America/New_York’) и из-за несовпадения локали и часового пояса он будет видеть GMT-4, а не EDT в датах, отформатированных на фронтенде.

Summary​

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

Какую библиотеку для работы с датами вы сейчас предпочитаете (или используете в текущем проекте) и почему?

Полезные ссылки

  1. Why you should never mutate JavaScript Date
  2. Date.Parse — описание того как строки конвертируются в даты в JS
  3. Intl.DateTimeFormat
  4. Fixing JavaScript Date — Getting Started
  5. Temporal proposal
 
Сверху