В этой статье я собрала топ-5 вещей, касающихся работы с Date, которые часто вводят JS программистов в заблуждение, недоразумение и даже ярость.
Kак мне кажется, именно по этим причинам мы продолжаем тащить в свои проекты библиотеки для работы с датами, а не пользуемся средствами нативного JS.
Есть ли свет в конце тоннеля? Есть. Новое API под названием Temporal находится на третьей стадии — экспериментальной — это значит, что вы уже можете его попробовать (хотя бы с помощью полифилов) и внести свои предложения, пока не поздно.
Так почему стандартных средств текущей версии JS недостаточно?
Создадим дату, соответствующую первому января:
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).
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}`)
Создадим объект 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
Почему даты как бы одинаковые, но это не одна и та же дата?
Потому что:
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
Объект 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, без библиотек:
Это наименьшее из зол, но когда с ним сталкиваешься и заказчик просит, чтобы были именно аббревиатуры, то это не кажется мелочью. Часто нужно отформатировать дату и показать код часового пояса — например, 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 в датах, отформатированных на фронтенде.
Какую библиотеку для работы с датами вы сейчас предпочитаете (или используете в текущем проекте) и почему?
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
Расскажите про интересные задачи или заковыристые баги, которые происходили у вас и были вызваны особенностями работы с датами.Какую библиотеку для работы с датами вы сейчас предпочитаете (или используете в текущем проекте) и почему?
Полезные ссылки
- Why you should never mutate JavaScript Date
- Date.Parse — описание того как строки конвертируются в даты в JS
- Intl.DateTimeFormat
- Fixing JavaScript Date — Getting Started
- Temporal proposal
5 причин ненавидеть то, как JavaScript работает с датами
У цій статті зібрано топ-5 речей щодо роботи з датами, які часто вводять JS-програмістів в оману, непорозуміння і навіть лють. На думку авторки, Олени Шаровар, саме з цих причин розробники тягнуть у свої проєкти бібліотеки для роботи з датами, а не корист
dou.ua