DateAdd и DateDiff на JavaScript: привычные функции VB в браузере

РазделJavaScript и CSSУровеньСредний Чтение8 минОпубликовано ТегиJavaScriptДата и времяVB6

В VB дату сдвигают одной строкой DateAdd, а разницу считают DateDiff. В JavaScript таких функций нет, зато их несложно написать самому и не наступить на типичные грабли объекта Date.

Главное. Объект Date в JavaScript позволяет повторить DateAdd и DateDiff из VB, но с двумя оговорками: месяцы нужно сдвигать с ограничением по концу месяца, а разницу в днях считать по календарным компонентам, а не делением миллисекунд.

Зачем VB-разработчику свой DateAdd в браузере

Почти любая веб-форма работает с датами: срок действия договора, период отчёта, дата доставки «не раньше чем через три дня». Часть проверок разумно выполнять прямо в браузере, чтобы пользователь сразу видел ошибку, а не ждал ответа сервера.

Разработчик, пришедший из Visual Basic или VBA, привык к трём функциям: DateAdd сдвигает дату на заданный интервал, DateDiff считает, сколько интервалов между двумя датами, DatePart вынимает компонент. В JavaScript есть только объект Date с набором методов get и set. Он умеет всё то же самое, но совсем другим языком, и перенос логики «в лоб» легко порождает ошибки на стыке месяцев и часовых поясов.

Ниже мы соберём две функции с привычной VB-сигнатурой, разберём, где поведение JavaScript отличается от VB, и покажем, как использовать их для проверки полей формы.

Что нужно знать об объекте Date

Объект Date хранит одно число: количество миллисекунд от 1 января 1970 года по UTC. Все методы getMonth, getDate, getHours пересчитывают это число в локальное время компьютера пользователя. Методы с префиксом UTC делают то же самое без учёта пояса.

Несколько особенностей, которые чаще всего удивляют VB-разработчика:

  • месяцы нумеруются с нуля: январь это 0, декабрь это 11, а вот дни месяца начинаются с единицы;
  • методы set изменяют сам объект, а не возвращают новый, поэтому функция, получившая дату параметром, может незаметно испортить её у вызывающего кода;
  • переполнение нормализуется автоматически: setDate(32) для января даст 1 февраля, а setDate(0) вернёт последний день предыдущего месяца;
  • вычитание двух дат даёт разницу в миллисекундах, потому что объект при арифметике превращается в число.
Нормализацию переполнения удобно использовать сознательно: new Date(год, месяц + 1, 0) возвращает последний день нужного месяца без всяких таблиц.

DateAdd: сдвиг даты на интервал

Сохраним порядок аргументов как в VB: DateAdd(interval, number, date), и те же коды интервалов: yyyy, q, m, d, ww, h, n, s. Так код, перенесённый из VB-модуля, читается без перевода в голове.

Первым делом функция копирует дату. Это защищает исходный объект: если передать в неё значение, которое ещё нужно форме, оно не изменится. Для месяцев, кварталов и лет вызывается отдельная функция addMonths, о ней в следующем разделе.

dateadd.jsJavaScript
// Аналог VB DateAdd(interval, number, date)
function DateAdd(interval, number, date) {
  var d = new Date(date.getTime()); // работаем с копией
  switch (interval) {
    case 'yyyy': return addMonths(d, 12 * number);
    case 'q':    return addMonths(d, 3 * number);
    case 'm':    return addMonths(d, number);
    case 'd':    d.setDate(d.getDate() + number); break;
    case 'ww':   d.setDate(d.getDate() + 7 * number); break;
    case 'h':    d.setHours(d.getHours() + number); break;
    case 'n':    d.setMinutes(d.getMinutes() + number); break;
    case 's':    d.setSeconds(d.getSeconds() + number); break;
    default: throw new Error('Неизвестный интервал: ' + interval);
  }
  return d;
}

Ловушка конца месяца

Если сдвигать месяц простым setMonth(getMonth() + 1), то 31 января превратится в 3 марта (или 2 марта в високосный год). JavaScript честно ставит 31 февраля, а потом нормализует переполнение. VB в той же ситуации возвращает последний день февраля, и пользователи ждут именно такого результата: «через месяц после 31 января» в любом договоре означает конец февраля.

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

addmonths.jsJavaScript
// Сдвиг на месяцы с поведением как в VB: 31.01 + 1 мес = 28(29).02
function addMonths(d, months) {
  var day = d.getDate();
  d.setDate(1);                           // уходим от переполнения
  d.setMonth(d.getMonth() + months);
  var last = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
  d.setDate(Math.min(day, last));         // не дальше конца месяца
  return d;
}

DateDiff: считаем границы, а не полные интервалы

Важная деталь VB DateDiff, о которой многие забывают: функция считает, сколько раз была пересечена граница интервала, а не сколько полных интервалов прошло. DateDiff("d", 23:59, 00:01 следующего дня) вернёт 1, хотя прошло две минуты. DateDiff("m", 31 января, 1 февраля) тоже вернёт 1.

Наивное деление разницы миллисекунд на 86 400 000 даёт другой результат и вдобавок ломается при переходе на летнее время, когда сутки длятся 23 или 25 часов. Поэтому дни считаем через Date.UTC от календарных компонентов: так каждые сутки ровно одинаковой длины, и время суток не влияет на результат.

datediff.jsJavaScript
// Номер календарного дня без учёта времени и летнего времени
function dayNumber(d) {
  return Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 86400000;
}

// Аналог VB DateDiff(interval, date1, date2)
function DateDiff(interval, date1, date2) {
  var y = date2.getFullYear() - date1.getFullYear();
  var days = dayNumber(date2) - dayNumber(date1);
  var hours = days * 24 + date2.getHours() - date1.getHours();
  var mins = hours * 60 + date2.getMinutes() - date1.getMinutes();
  switch (interval) {
    case 'yyyy': return y;
    case 'q': return y * 4 + Math.floor(date2.getMonth() / 3)
                           - Math.floor(date1.getMonth() / 3);
    case 'm': return y * 12 + date2.getMonth() - date1.getMonth();
    case 'd': return days;
    case 'w': return (days / 7) | 0;      // полные недели, как "w" в VB
    case 'h': return hours;
    case 'n': return mins;
    case 's': return mins * 60 + date2.getSeconds() - date1.getSeconds();
    default: throw new Error('Неизвестный интервал: ' + interval);
  }
}

Проверка дат в форме

Самое частое применение пары функций: проверить, что пользователь ввёл корректный период. Строку из поля нельзя отдавать в new Date напрямую. Формат «16.07.2009» браузеры понимают по-разному, а ISO-строку «2009-07-16» без времени стандарт трактует как полночь по UTC, и у пользователя западнее Гринвича дата съезжает на день назад.

Надёжнее разобрать строку регулярным выражением и собрать дату конструктором new Date(год, месяц, день), который работает в локальном времени. Заодно отсекаем несуществующие даты вроде 31.02: после нормализации у них меняется месяц.

validate.jsJavaScript
// Разбор даты в формате дд.мм.гггг; null, если дата некорректна
function parseRuDate(s) {
  var m = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(s);
  if (!m) return null;
  var d = new Date(+m[3], +m[2] - 1, +m[1]);
  return d.getMonth() === +m[2] - 1 ? d : null; // 31.02 не пройдёт
}

function checkPeriod(fromValue, toValue) {
  var from = parseRuDate(fromValue), to = parseRuDate(toValue);
  if (!from || !to) return 'Введите даты в формате дд.мм.гггг';
  if (DateDiff('d', from, to) < 0) return 'Конец периода раньше начала';
  if (DateDiff('m', from, to) > 12) return 'Период не длиннее года';
  return '';
}
Клиентская проверка только помогает пользователю. Тот же период обязательно проверяйте на сервере: JavaScript можно отключить или обойти.

Пограничные случаи, которые стоит протестировать

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

  • 31 января плюс один месяц, 29 февраля високосного года плюс один год;
  • сдвиг на отрицательное число: DateAdd('m', -1, ...) от 31 марта должен дать конец февраля;
  • ночь перехода на летнее время: сложение часов через setHours корректно учитывает пропущенный час, а сложение дней сохраняет время суток;
  • даты до 1970 года: внутреннее число становится отрицательным, но методы работают правильно;
  • сравнение дат через ==: оно сравнивает ссылки на объекты, поэтому для равенства используйте getTime().

Как это делают сегодня

Сегодня в браузерах появился API Temporal: Temporal.PlainDate.add({ months: 1 }) сам ограничивает день концом месяца, а until() считает разницу в нужных единицах без ловушек часовых поясов. Для старых браузеров есть полифил. Если Temporal недоступен, проверенный путь это библиотека date-fns (addMonths, differenceInCalendarDays), а для вывода дат пользователю удобен встроенный Intl.DateTimeFormat.

Вопросы и ответы

Почему new Date("2009-07-16") показывает 15 июля?

ISO-дата без времени по стандарту означает полночь UTC. В часовом поясе западнее Гринвича это ещё предыдущий день. Собирайте дату через new Date(2009, 6, 16) или добавляйте время явно.

Можно ли просто вычесть даты и разделить на 86400000?

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

Почему DateDiff('m') между 31 января и 1 февраля равен 1?

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

Читайте также