
DateAdd и DateDiff на JavaScript: привычные функции VB в браузере
В VB дату сдвигают одной строкой DateAdd, а разницу считают DateDiff. В JavaScript таких функций нет, зато их несложно написать самому и не наступить на типичные грабли объекта Date.
Зачем 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) вернёт последний день предыдущего месяца;
- вычитание двух дат даёт разницу в миллисекундах, потому что объект при арифметике превращается в число.
DateAdd: сдвиг даты на интервал
Сохраним порядок аргументов как в VB: DateAdd(interval, number, date), и те же коды интервалов: yyyy, q, m, d, ww, h, n, s. Так код, перенесённый из VB-модуля, читается без перевода в голове.
Первым делом функция копирует дату. Это защищает исходный объект: если передать в неё значение, которое ещё нужно форме, оно не изменится. Для месяцев, кварталов и лет вызывается отдельная функция addMonths, о ней в следующем разделе.
// Аналог 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 января» в любом договоре означает конец февраля.
Решение простое: перед сдвигом переходим на первое число, меняем месяц, а затем ставим исходный день, но не больше длины целевого месяца. Длину берём через тот самый трюк с нулевым днём следующего месяца.
// Сдвиг на месяцы с поведением как в 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 от календарных компонентов: так каждые сутки ровно одинаковой длины, и время суток не влияет на результат.
// Номер календарного дня без учёта времени и летнего времени
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: после нормализации у них меняется месяц.
// Разбор даты в формате дд.мм.гггг; 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 '';
}Пограничные случаи, которые стоит протестировать
Даже аккуратно написанные функции стоит прогнать по набору дат, на которых обычно ломается календарная арифметика. Удобно держать их в маленьком тестовом файле и запускать после каждой правки:
- 31 января плюс один месяц, 29 февраля високосного года плюс один год;
- сдвиг на отрицательное число: DateAdd('m', -1, ...) от 31 марта должен дать конец февраля;
- ночь перехода на летнее время: сложение часов через setHours корректно учитывает пропущенный час, а сложение дней сохраняет время суток;
- даты до 1970 года: внутреннее число становится отрицательным, но методы работают правильно;
- сравнение дат через ==: оно сравнивает ссылки на объекты, поэтому для равенства используйте getTime().
Как это делают сегодня
Вопросы и ответы
Почему new Date("2009-07-16") показывает 15 июля?
ISO-дата без времени по стандарту означает полночь UTC. В часовом поясе западнее Гринвича это ещё предыдущий день. Собирайте дату через new Date(2009, 6, 16) или добавляйте время явно.
Можно ли просто вычесть даты и разделить на 86400000?
Для грубой оценки можно, но в ночь перехода на летнее время сутки короче или длиннее, и результат получится дробным. Для календарных дней используйте Date.UTC от компонентов даты.
Почему DateDiff('m') между 31 января и 1 февраля равен 1?
Так работает VB: функция считает пересечённые границы месяцев. Если нужны полные месяцы, сравните дополнительно дни и уменьшите результат на единицу, когда день второй даты меньше дня первой.