日期时间计算:工作日、年龄计算与日历的坑

8 min2026年6月6日

为什么日期计算不简单:反直觉的日历系统

日期计算看起来应该很简单——毕竟只是数字加减法。但实际上,日历系统是人类历史上最复杂的发明之一,它试图将不规则的天文周期(地球公转 365.2422 天、月球公转 29.53 天)塞进整数天的格子里。这种不可调和的矛盾催生了闰年、不等长月份、夏令时等各种"补丁",让程序员在日期计算中处处踩坑。

一个月有多少天?28、29、30、31 都有可能。一年有多少天?365 或 366。一天有多少秒?通常 86400,但闰秒出现时是 86401。一小时一定是 60 分钟吗?夏令时切换时可能是 0 分钟或 120 分钟。这些不规则性意味着"加一个月""加一年"这样看似简单的操作,在边界情况下语义模糊。

时区更是灾难的放大器。"今天是几号"这个问题在不同时区有不同答案——当北京是 1 月 2 日凌晨时,纽约还是 1 月 1 日。UTC 时间戳虽然解决了绝对时间的歧义,但"本地日期"的计算仍然必须考虑时区。更复杂的是,时区规则本身也在变化——政府可以决定修改 DST 规则、甚至更改时区(如萨摩亚在 2011 年跳过了 12 月 30 日)。

这些复杂性的根本教训是:不要自己从零实现日期计算逻辑。使用成熟的日期库(如 date-fns、Temporal API、Day.js)是唯一可靠的方案。本文的目的不是教你重新发明轮子,而是帮你理解复杂性所在,从而在使用工具时做出正确的选择,并能在 edge case 出现时快速定位问题。

闰年规则:比你想的更复杂

大多数人知道"4 年一闰",但完整的格里高利历闰年规则有三条:①能被 4 整除的年份是闰年;②但能被 100 整除的年份不是闰年(如 1900 年);③但能被 400 整除的年份又是闰年(如 2000 年)。这三条规则的叠加使得平均一年 = 365.2425 天,与实际回归年 365.2422 天仅差 0.0003 天——大约每 3200 年偏差一天。

闰年规则在编程中的直接影响:计算两个日期之间的天数时不能简单用"年份差 × 365";2 月份的天数需要动态判断;"去年今天"如果今天是 2 月 29 日就不存在对应日期。很多日期相关的 bug 在非闰年运行正常但在闰年的 2 月 28 日/3 月 1 日附近爆发——这就是为什么 2024 年 2 月 29 日总会有一批"闰年 bug"新闻。

历史日期计算更加复杂。格里高利历在 1582 年才被引入(取代儒略历),而不同国家采纳的时间差异极大:意大利 1582 年、英国 1752 年、中国 1912 年、希腊 1923 年。如果你需要处理历史日期,必须明确使用哪种历法。JavaScript 的 Date 对象使用"预推格里高利历"(proleptic Gregorian),假设格里高利历规则一直适用——这在 1582 年之前的日期计算中会给出历史上不正确的结果。

实用建议:用函数封装闰年判断逻辑,不要在代码中到处写条件判断。所有主流日期库都正确处理了闰年,信任它们。在测试日期相关功能时,必须包含闰年的 2 月 28 日、2 月 29 日、3 月 1 日作为测试用例。特别关注跨年计算中 12 月 31 日到 1 月 1 日的边界——这里周数、季度数等都会发生变化。

工作日计算:排除周末与法定假日

计算两个日期之间的"工作日数"或"N 个工作日后的日期"是企业应用中的常见需求——项目排期、SLA 计算、发货时间估算都依赖它。看似简单的需求一旦加入"排除法定假日"就变得复杂:法定假日每年不同、可能调休(某些周末变成工作日)、不同国家/地区的假日完全不同。

中国的法定假日体系尤其复杂。每年国务院在年底或年初发布下一年的放假安排,包含"调休"机制——为了拼出长假而将某些周末改为工作日。例如国庆节放假 7 天但其中 2-3 天是调休来的,对应的某个周六周日需要上班。这意味着工作日计算不能只排除周六日和固定假日,还必须加回"调休上班日"。

实现策略有两种:黑名单法(列出所有非工作日)和白名单法(列出所有工作日)。对于中国场景,更实用的是维护一个"假日配置表",包含三类数据:①固定假日(元旦 1.1、劳动节 5.1 等的核心天);②年度放假安排(每年更新);③调休工作日(原本是周末但需要上班的日期)。计算工作日时:如果是调休工作日则计为工作日,如果是放假日或普通周末则不计。

跨国企业面临更大挑战:美国的 Memorial Day 是 5 月最后一个周一(浮动日期)、复活节每年日期不同(需要月相计算)、伊斯兰教节日基于阴历(日期每年偏移约 11 天)。没有任何库能涵盖全球所有假日——通常需要对接专业的假日 API(如 Nager.Date、Holiday API)或维护本地配置。我们的 date-difference-calculator 工具支持基本的工作日计算。

年龄计算:看似简单的复杂逻辑

"计算年龄"看起来是最简单的日期运算——当前年份减去出生年份。但稍一深入就会发现边界问题:今天是 3 月 15 日,一个 3 月 16 日出生的人今天还没过生日,应该减一岁。一个 2 月 29 日出生的人在非闰年的"今年生日"是 2 月 28 日还是 3 月 1 日?不同国家的法律对此有不同定义。

周岁计算的标准算法:age = 当前年 - 出生年;如果当前月日早于出生月日则 age -= 1。但"当前月日早于出生月日"的判断对 2 月 29 日出生者需要特殊处理。中国和日本的法律规定,2 月 29 日出生的人在非闰年的"生日"视为 2 月 28 日(即 2 月 28 日当天周岁 +1)。英国法律则认为是 3 月 1 日。你的代码需要根据业务场景选择正确的解释。

虚岁(中国传统年龄计算)则是完全不同的体系:出生即一岁,每过一个农历新年加一岁(不是生日加一岁)。这意味着一个农历腊月出生的婴儿可能出生几天后就"两岁"了。如果你的应用需要显示虚岁(如某些传统文化相关的应用),需要引入农历计算——这本身又是一个复杂度量级。

精确到天/月的年龄表示更需要小心。"3 岁 2 个月 15 天"这种表示中,"月"的长度不固定(28-31 天),所以从不同日期开始计算可能得到不同结果。通用做法是:先算完整年数,然后在剩余月份中算完整月数,最后剩余天数。我们的 age-calculator 工具采用这种逻辑,并正确处理了闰年 2 月 29 日的边界情况。

// === 日期计算实用函数 ===

// 闰年判断
function isLeapYear(year) {
  return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}

// 精确年龄计算(处理 2 月 29 日边界)
function calculateAge(birthDate, today = new Date()) {
  const birth = new Date(birthDate);
  let age = today.getFullYear() - birth.getFullYear();
  const monthDiff = today.getMonth() - birth.getMonth();
  const dayDiff = today.getDate() - birth.getDate();

  // 还没过今年生日则减一岁
  if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) {
    age--;
  }
  return age;
}

// 工作日计算(排除周末和中国法定假日)
function addWorkdays(startDate, days, holidays = [], workWeekends = []) {
  const result = new Date(startDate);
  let remaining = days;

  while (remaining > 0) {
    result.setDate(result.getDate() + 1);
    const dateStr = result.toISOString().slice(0, 10);
    const dayOfWeek = result.getDay(); // 0=周日, 6=周六

    // 调休工作日(周末但需上班)→ 计为工作日
    if (workWeekends.includes(dateStr)) {
      remaining--;
      continue;
    }
    // 法定假日或周末 → 跳过
    if (holidays.includes(dateStr) || dayOfWeek === 0 || dayOfWeek === 6) {
      continue;
    }
    remaining--;
  }
  return result;
}

// 两个日期之间的工作日数量
function countWorkdays(start, end, holidays = [], workWeekends = []) {
  let count = 0;
  const current = new Date(start);
  const endDate = new Date(end);

  while (current < endDate) {
    current.setDate(current.getDate() + 1);
    const dateStr = current.toISOString().slice(0, 10);
    const day = current.getDay();

    if (workWeekends.includes(dateStr)) { count++; continue; }
    if (holidays.includes(dateStr) || day === 0 || day === 6) continue;
    count++;
  }
  return count;
}

// 示例:2024 年国庆节期间计算
const holidays2024 = ['2024-10-01','2024-10-02','2024-10-03',
  '2024-10-04','2024-10-05','2024-10-06','2024-10-07'];
const workWeekends2024 = ['2024-09-29', '2024-10-12']; // 调休上班

console.log(countWorkdays('2024-09-28', '2024-10-14', holidays2024, workWeekends2024));

月末溢出问题:"加一个月"的语义歧义

"1 月 31 日加一个月是几号?"这个问题没有标准答案。2 月没有 31 日,那结果应该是 2 月 28 日(截断到月末)、3 月 1 日(溢出到下月)、还是 3 月 2 日/3 日(严格加 28/30 天)?不同的日期库选择了不同的策略,这是日期计算中最容易产生隐蔽 bug 的地方。

JavaScript 的 Date 对象采用"溢出"策略:new Date(2024, 0, 31) 设置月份为 1(二月)后变成 2024-03-02(因为 2024 年 2 月有 29 天,31-29=2 天溢出到 3 月)。这种行为虽然数学上自洽,但通常不符合业务预期。如果用户选择了"每月 31 日扣款",二月份你不会想在 3 月 2 日扣款——应该在 2 月 29 日(闰年)或 2 月 28 日(平年)扣。

date-fns 的 addMonths 采用"截断到月末"策略:1 月 31 日加一个月 = 2 月 29 日(闰年)或 2 月 28 日(平年)。这符合大多数业务场景的预期。Day.js 和 Moment.js 也是相同行为。如果你使用原生 Date 对象做月份加减,必须在操作后手动检查是否发生了溢出并修正。

实际业务中的处理建议:对于"每月固定日期"的重复事件(账单日、纪念日),应该存储"目标日"(如 31 日)而非计算后的日期,每次渲染时根据当月天数动态调整。对于"N 个月后"的一次性计算(合同到期日、还款日),明确定义截断规则并文档化。对于精确的时间间隔(如"满 30 天后"),使用天数而非月数来表达——避免月份长度差异带来的歧义。

日期差算法:天数、月数与年数

计算两个日期之间的"差"有多种表达方式:纯天数(384 天)、年月日组合(1 年 0 个月 19 天)、工作日数(274 个工作日)。每种表达方式都有其适用场景,也都有各自的计算陷阱。纯天数最简单——两个 UTC 午夜时间戳相减再除以 86400000 即可(但注意夏令时可能导致某一天只有 23 或 25 小时)。

年月日差值的计算则没有统一标准。从 2024-01-31 到 2024-03-01 是"1 个月 1 天"还是"1 个月 0 天"?取决于你如何定义"一个月"。常见算法:先算完整年数(较晚日期的年-较早日期的年,如果月日还没到则减 1),再算剩余完整月数(同理处理日期溢出),最后剩余天数。但这种算法在月末边界会产生反直觉的结果。

时间戳差值计算的常见陷阱:JavaScript 中两个 Date 相减得到毫秒差,除以 86400000 得到天数——但如果两个日期跨越了夏令时切换,结果可能是 0.958 天而非整数。解决方案是将两个日期都设为 UTC 午夜(setUTCHours(0,0,0,0))后再计算差值,或使用 date-fns 的 differenceInDays 等函数(已处理 DST)。

对于"倒计时"和"已过天数"类的需求,注意"包含头尾"的问题:从 1 月 1 日到 1 月 3 日是"2 天"(差值)还是"3 天"(含头含尾)?生活中我们说"3 天假期"是含头含尾的计数方式,但程序计算的差值是 2。我们的 date-difference-calculator 工具默认使用差值语义,但提供"包含结束日"的选项来适配不同的业务需求。

工具与库:选择正确的日期处理方案

JavaScript 原生 Date 对象问题很多:可变性(setMonth 会修改原对象)、月份从 0 开始(0=一月)、解析行为不一致("2024-01-01" 在不同环境可能解释为 UTC 或本地时区)、缺乏时区支持、API 设计过时。对于简单场景(显示当前时间、时间戳转换)它够用,但任何涉及计算或格式化的场景都建议使用第三方库。

date-fns 是当前 JavaScript 生态的首选日期库:函数式 API(纯函数,不修改输入)、支持 tree-shaking(只打包用到的函数)、TypeScript 原生支持、全面的国际化。它提供了 addMonths、differenceInBusinessDays、format、parse 等上百个函数,覆盖了几乎所有日期操作需求。缺点是不支持时区(需要配合 date-fns-tz)。

TC39 的 Temporal 提案是 JavaScript 日期处理的未来标准。它引入了不可变的日期时间类型(PlainDate、PlainTime、ZonedDateTime、Duration 等),内置时区和日历系统支持,明确区分"无时区日期"和"有时区时刻"——从根本上解决了 Date 对象的设计缺陷。Temporal 目前在 Stage 3,预计未来几年会进入浏览器。polyfill(@js-temporal/polyfill)现在就可以使用。

选择建议:新项目优先评估 Temporal polyfill(面向未来的标准 API);如果包体积敏感则用 date-fns(tree-shakable);如果需要强大的时区支持则用 Luxon。Day.js 是 Moment.js 的轻量替代品,API 相似但体积只有 2KB(但链式 API 意味着不能 tree-shake)。无论选择哪个库,关键是在项目中统一——混用多个日期库是维护噩梦。

// === 日期计算最佳实践 ===

// ❌ 原生 Date 的陷阱
const d = new Date('2024-01-31');
d.setMonth(d.getMonth() + 1);
console.log(d); // 2024-03-02!不是 2 月 28/29 日

// ✅ date-fns 的正确行为
// import { addMonths, differenceInDays, format } from 'date-fns';
// addMonths(new Date(2024, 0, 31), 1) → 2024-02-29(截断到月末)

// ✅ Temporal API(未来标准)
// const date = Temporal.PlainDate.from('2024-01-31');
// date.add({ months: 1 }) → 2024-02-29

// 安全的日期差计算(避免 DST 问题)
function daysBetween(date1, date2) {
  // 将两个日期都归一化到 UTC 午夜
  const utc1 = Date.UTC(date1.getFullYear(), date1.getMonth(), date1.getDate());
  const utc2 = Date.UTC(date2.getFullYear(), date2.getMonth(), date2.getDate());
  return Math.round((utc2 - utc1) / 86400000);
}

// 月份差计算(返回 {years, months, days})
function dateDifference(start, end) {
  let years = end.getFullYear() - start.getFullYear();
  let months = end.getMonth() - start.getMonth();
  let days = end.getDate() - start.getDate();

  if (days < 0) {
    months--;
    // 取上个月的天数作为补偿
    const prevMonth = new Date(end.getFullYear(), end.getMonth(), 0);
    days += prevMonth.getDate();
  }
  if (months < 0) {
    years--;
    months += 12;
  }
  return { years, months, days };
}

// 示例
console.log(dateDifference(
  new Date(2023, 0, 15), // 2023-01-15
  new Date(2024, 2, 10)  // 2024-03-10
)); // { years: 1, months: 1, days: 24 }

// 获取某月的天数(处理闰年)
function daysInMonth(year, month) {
  // month: 1-12(人类习惯)
  return new Date(year, month, 0).getDate();
}
console.log(daysInMonth(2024, 2)); // 29(闰年)
console.log(daysInMonth(2023, 2)); // 28(平年)