月日期计算器

按月维度算两个日期的差:先给出「相差几个月零几天」,再给出把起始日加或减 N 个月后的结果日期。全程用 Date.UTC,并按日历月做月末钳位——1 月 31 日加 1 个月是 2 月 29 日(平年 2 月 28 日),不会滚到 3 月。

整月差(结束 − 起始)—
余天数—
总天数—
起止先后—
加 N 月后的日期—
该月天数—
月末钳位—
结果日星期—

算法:月索引 = 年 × 12 + (月 − 1),整月差取索引之差,若「结束日」小于「起始日」再减 1;加 N 月时保留起始日的「日」,若目标月没有这一天则钳到该月最后一天。日期支持 YYYY-MM-DD,年份 1000 ~ 9999。

以起始日 2024-01-31 逐月加 N 的月末钳位对照(默认 12 行)
加月数结果日期该月天数月末钳位星期
+1 月2024-02-2929 天钳位 31 → 29 日周四
+2 月2024-03-3131 天无钳位周日
+3 月2024-04-3030 天钳位 31 → 30 日周二
+4 月2024-05-3131 天无钳位周五
+5 月2024-06-3030 天钳位 31 → 30 日周日
+6 月2024-07-3131 天无钳位周三
+7 月2024-08-3131 天无钳位周六
+8 月2024-09-3030 天钳位 31 → 30 日周一
+9 月2024-10-3131 天无钳位周四
+10 月2024-11-3030 天钳位 31 → 30 日周六
+11 月2024-12-3131 天无钳位周二
+12 月2025-01-3131 天无钳位周五

使用方法

  1. 在「起始日期」填第一个日期,默认示例 2024-01-31;这个日期刻意选在月末,因为它是触发月末钳位概率最高的一天。
  2. 在「结束日期」填第二个日期,默认示例 2024-12-31。两个日期谁先谁后都可以,倒序会给出负的整月差并单独标注。
  3. 在「加 / 减月数」填一个整数,默认 1。正数往后推、负数往前推,范围 −1200 ~ 1200,即最多前后各 100 年。
  4. 点「计算月数差」或直接按 Enter。结果区 8 格给出整月差、余天数、总天数、起止先后、加 N 月后的日期、该月天数、是否发生月末钳位与结果日星期。
  5. 下方对照表按当前起始日逐月给出 +1 ~ +20 月的结果,默认显示 12 行,「加载更多」补足后 8 行;「导出 CSV」带 UTF-8 BOM,Excel 打开不乱码。

这个月日期计算器能解决什么问题

一是算合同、账期、订阅这类「按月」的间隔。租房押一付三、服务器包月续费、分期付款第 6 期是哪天,这类问题问的都是「几个月」,不是「几天」。用天数差除以 30 会得出一个似是而非的数字,而月日期计算器给的是日历月口径:2024-01-31 到 2024-12-31 恰好 11 个月零 0 天,总天数则是 335 天。

二是算「某天往前/往后推 N 个月」落在哪一天,尤其是月末。2024-01-31 加 1 个月是 2024-02-29,加 3 个月是 2024-04-30——这两天都没有 31 号,结果必须钳到该月最后一天。把同样的输入丢给 Date.setUTCMonth(),浏览器的答案是 2024-03-02 和 2024-05-01,直接滚到了下个月,这正是本页要修掉的行为。

三是给孕期、租期、试用期这类「第 N 个月」做日期核对。很多业务的「第 3 个月」指的是自然月而不是 90 天,两者在短月(2 月)上会差出两三天。本页把整月差与余天数拆开显示,就是让你一眼看清「11 个月」背后到底还挂着几天零头。

四是批量核对月末边界。月末钳位在 1 月 31 日、3 月 31 日、5 月 31 日这些起点上最密集,本页的对照表把 +1 ~ +20 月逐行列出,哪个月会钳位、钳到几号一目了然。

本页不做这些:不做「两个日期相差多少天」(那是日期间隔天数计算器)、不做按年 / 月 / 日三段拆解的差值(那是日期差值计算器)、不做加天数(那是日期加天数计算器)、也不做年龄(那是年龄计算器)。月日期计算器只锚定在「月」这一个维度上。

原理与变量:月日期计算器怎么算整月差、又怎么处理月末

第一步,把日期压成一个月索引。核心公式只有一行:月索引 = 年 × 12 + (月 − 1)。于是 2024-01 的索引是 2024×12+0=24288,2024-12 的索引是 2024×12+11=24299,两者相减就是 11——整月差的第一近似值。这一步之所以不用「总天数 ÷ 30.44」,是因为 30.4369 天(365.2425 ÷ 12)只是公历的统计平均月长,不是任何真实月份的长度:真实月份有 28、29、30、31 四种天数。实测 2015-01-01 到 2015-03-01 是 59 天,除以 30.4369 得 1.939、向下取整是 1 个月,而日历月口径的正确答案是 2 个月——均摊法在这一例上少算一整月。

第二步,用「结束日是否够数」修正 1 个月。索引差只是上界。2024-01-31 到 2024-02-29,索引差是 1,但 2 月没有 31 日,2 月 29 日其实还没到「下一个 31 日」,所以整月差要退回 0,剩下的 29 天记进余天数。判据就是比较两个日期的日部分:结束日 < 起始日 时整月差减 1。这条规则让余天数的取值范围被锁死在 [0, 30]——本页对 2000-01 到 2031-12、跨度 0 ~ 39 个月的 1378 万组组合做过全量枚举,余天数最小 0、最大 30,从不出负数。

第三步,加 N 月时保留「日」并做月末钳位。算法是:先把起始日换算成月索引并加上 N;再取目标月的天数 该月最后一天 = new Date(Date.UTC(年, 月, 0)).getUTCDate();最后把起始日的「日」与它取小值 Math.min(起始日, 该月天数)。所以 2024-01-31 加 1 月 → 目标是 2024-02,2 月共 29 天,Math.min(31, 29) = 29,结果 2024-02-29;换成平年 2026,2 月共 28 天,Math.min(31, 28) = 28,结果 2026-02-28。关键点是以起始日的原始「日」为基准,而不是逐月级联:2024-01-31 加 2 月直接算成 3 月 31 日,而不是先把 31 钳成 29、再从 29 推出 3 月 29 日。

为什么不能用 Date.setUTCMonth()。原生方法在日期溢出时会进位到下个月,而不是钳位。实测 new Date(Date.UTC(2024,0,31)) 之后 setUTCMonth(getUTCMonth()+1) 得到的是 2024-03-02(2 月只有 29 天,31−29=2 天溢出到 3 月 2 日),平年 2026-01-31 则得到 2026-03-03。这不是 bug,是 Date 对象按「时间偏移」定义月份加减的结果。要做日历意义上的「加一个月」,必须自己钳位,这也是 ECMAScript 明确不保证、需要应用层实现的语义。

为什么全程用 Date.UTC 与 getUTCxxx。如果用本地时区构造日期,在有夏令时的地区,午夜到午夜可能只有 23 小时或 25 小时。实测美东 2024-03-10 00:00 到 03-11 00:00 只有 23 小时,23 ÷ 24 = 0.958,向下取整得 0 天;09-03 那一侧则反过来是 25 小时。用 Date.UTC 归一之后这三组都是整 24 小时的整数倍,任何时区的访问者得到同一结果。闰年规则按公历标准:(年 % 4 === 0 && 年 % 100 !== 0) || 年 % 400 === 0——所以 2024 闰、1900 平、2000 闰、2100 平。

完整示例(数值仅作演示)

示例条件与页面打开时的默认值完全一致:起始 2024-01-31、结束 2024-12-31、加月数 +1。这一组数字在下表首行也能逐位对上。

  • 整月差 11:月索引 24288 → 24299,差 11;结束日 31 等于起始日 31,不触发减 1。
  • 余天数 0,总天数 335:2024 是闰年(366 天),从 1 月 31 日到 12 月 31 日跨过 335 天。
  • 加 1 月 = 2024-02-29(星期四):2024 年 2 月共 29 天,31 > 29,钳到 29 日。同一输入换成平年 2026 就是 2026-02-28。
  • 加 −1 月 = 2023-12-31,加 −13 月 = 2022-12-31:负数方向同样先钳位再取日期,2 月无 31 日的月份照样退到月末。
  • 对照表前 12 行里有 5 行发生钳位:+1 月(2 月)、+3 月(4 月)、+5 月(6 月)、+8 月(9 月)、+10 月(11 月)——正好是全年那 5 个没有 31 日的月份;+13 月的 2025-02-28 是第 6 次钳位。

反向核对:把「总天数 335」除以 30.4369 得 11.006,向下取整也是 11,与整月差一致——但这是巧合,不是方法;同样做法的 2015-01-01 → 2015-03-01 就会少算 1 个月。

输入范围与对照表

下表是月末钳位的 7 组代表性实测,全部来自同一套日历月算法:

起始 → 结束整月差余天数为什么是这个结果
2024-01-31 → 2024-12-3111 个月0 天两端日号都是 31,不触发修正(页面默认示例)
2024-01-15 → 2024-03-202 个月5 天锚点是 2024-03-15,再往后数 5 天
2024-01-31 → 2024-02-290 个月29 天2 月无 31 日,结束日 29 小于起始日 31,整月退回 0
2023-01-31 → 2023-02-280 个月28 天平年 2 月只有 28 天,同上
2024-02-29 → 2025-02-2811 个月30 天锚点钳到 2025-01-29,余 30 天;闰日周年不是整 12 个月
2024-01-01 → 2025-01-0112 个月0 天整一年;2024 闰年对应总天数 366
2024-05-15 → 2023-05-15−12 个月0 天倒序输入给出负值,页面单独标注「结束早于起始」

下方字段范围表列的是每个输入允许的区间与越界动作:

字段允许范围默认值越界或填错时的动作
起始日期1000-01-01 ~ 9999-12-312024-01-31拒绝:提示格式应为 YYYY-MM-DD,或提示该月没有这一天
结束日期1000-01-01 ~ 9999-12-312024-12-31同上;不做静默进位(2 月 30 日会被明确拒绝)
加 / 减月数−1200 ~ 1200 的整数1拒绝:提示必须是 −1200 到 1200 之间的整数
日期分隔符仅 -(YYYY-MM-DD)-拒绝:2024/01/31、2024.01.31 都要求改成分隔符
对照表行数固定 20 行(默认显示 12)12不涉及输入,点「加载更多」补足

为什么年份下限是 1000 而不是 1。Date.UTC(99, 0, 1) 会把 99 映射到 1999,返回 1999-01-01,而不是 0099-01-01;小于 100 的年份每一步都要额外调 setUTCFullYear() 修正,得不偿失。上限 9999 是因为 ISO 8601 的扩展表示就是 4 位数年份。

边界与精度

  • 余天数恒在 [0, 30],不会出现负数。上界 30 来自「结束月最多 31 天」减去「锚点最小 1 天」。这条性质是本页 1378 万组全量枚举验证过的,也是判断实现对不对的第一道尺子——如果你的实现能算出「1 个月零 35 天」,那它一定漏掉了整月差的活。
  • 「一个月」的实际长度是 28/29/30/31 天四种之一。同样写「加 1 个月」,1 月 15 日到 2 月 15 日是 31 天,3 月 15 日到 4 月 15 日是 31 天,而 1 月 15 日到 2 月 14 日只有 30 天。所以「月」不能换算成一个固定天数——本页的月数差与总天数是两个独立口径,页面上并列显示,不要互相换算。
  • 月末钳位只发生在目标月天数少于起始日时。起始日 ≤ 28 的日期在任意月份都安全(2 月最短 28 天);起始日是 29 时只有平年 2 月会钳;30 时 2 月会钳;31 时 2/4/6/9/11 五个月都会钳。这就是把默认示例选成 1 月 31 日的原因——一行就能看到钳位。
  • 钳位不级联。2024-01-31 加 2 月 = 2024-03-31,而不是「先钳成 2 月 29 日、再推出 3 月 29 日」。判据是每次都基于起始日的原始日号重算,中间结果不参与下一次计算。两种做法在跨多个短月时结果不同,本页固定用前一种(保留原始日号),因为它可逆性更好。
  • 闰日(2 月 29 日)加 12 个月只有 11 个月零 30 天。2024-02-29 到 2025-02-28 的月索引差是 12,但 2025-02-28 的日号 28 小于 29,整月退回 11;余天数从锚点 2025-01-29 数到 2025-02-28 正好 30 天。这类「整年不是整月」的坑在生日、纪念日场景里最常见。
  • 年份 0 ~ 99 无法直接表达。Date.UTC() 对两位年份有 1900 偏移的映射规则(实测 99 → 1999),所以下限设 1000。1000-01-01 到 9999-12-31 共 107999 个月、3287181 天,远超一般业务所需。
  • 最大值校验靠往返比对,不靠 Date。Date 不做日历校验:Date.UTC(2024, 1, 30) 会静默进位成 2024-03-01。页面先把年月日构造成日期,再格式化回字符串与输入比对,不一致就报「2024 年 2 月没有 30 日」这类具体错误,绝不静默进位。

常见问题

为什么 1 月 31 日加 1 个月不是 3 月 2 日或 3 月 3 日?

因为本页做的是日历月钳位,不是原生 Date 的时间偏移。原生的 setUTCMonth() 把「加一个月」当成「把月份数字 +1」,2 月放不下 31 号时就顺延到下个月:2024 年 2 月 29 天,31−29=2,于是变成 3 月 2 日;平年 2 月 28 天,31−28=3,变成 3 月 3 日。业务上没人想要这个结果,所以本页先把日期钳到目标月最后一天:2024 年得 2 月 29 日、2026 年得 2 月 28 日。这不是四舍五入,而是「该月没有这一天就取该月最后一天」的明确规则。

「相差 11 个月」为什么不写「相差 335 天」?两者能互算吗?

不能互算,它们是两个口径。11 个月是日历月口径,335 天是绝对天数口径;同一个「11 个月」在不同起点上对应的天数可能是 334、335 或 336。反过来用天数除以 30 或 30.44 得到的「月数」也会偏小——实测 2015-01-01 → 2015-03-01 明明跨了 2 个月,除以 30.4369 只有 1.939,向下取整成 1 个月。所以结果区把整月差、余天数、总天数三个数并列显示,按业务需要各取所需,不要拿一个去推另一个。

起始日填 29、30 或 31 会有什么差别?

差别只在「目标月够不够这一天」。起始日 ≤ 28 时,任何月份都有这一天,永不钳位;起始日 29 时只有平年 2 月会钳到 28;起始日 30 时 2 月会钳到 28/29;起始日 31 时 2、4、6、9、11 这五个月都会钳到 30 或 28/29。所以「月末日期的月份推进」是本页最容易出错的地方,也是默认示例刻意选 2024-01-31 的原因。

为什么 2024-02-29 到 2025-02-28 不是整 12 个月?

因为 2025-02-28 的日号 28 小于起始日 29,触发了「结束日 < 起始日则整月减 1」的修正,整月差从 12 退回 11。再从锚点 2025-01-29 数到终点 2025-02-28,还剩 30 天。这就是闰日周年的特殊之处:从闰日出发加满一年,落点因为平年 2 月只有 28 天,反而不是「整年」。生日、纪念日、合同满年这类场景都会撞上。

加月数填 0 或者留空会怎样?

填 0 是合法的,代表「加 0 个月」,结果日期等于起始日期,该月天数照常给出;留空则会被拒绝并提示「加 / 减月数不能为空,请填 −1200 到 1200 之间的整数」。页面不把空值当成 0 静默处理,因为「没填」和「填了 0」在业务上是两件事,静默归零会让用户以为工具读懂了输入。

输入 2024/01/31 这种斜杠格式为什么被拒绝?

因为本页只接受 YYYY-MM-DD 一种写法,用 - 作分隔符。斜杠与点号形式在不同地区含义不同(01/02/2024 在美式是 1 月 2 日、在欧洲是 2 月 1 日),一个只认单一口径的工具反而不容易产生误解。日期输入框本身也是按 YYYY-MM-DD 提交的,手打别的分隔符会被拦下并提示改成横杠。需要多格式互转请用日期格式化转换器。

结果会因为我所在的时区而变吗?

不会。构造日期用 Date.UTC,读取用 getUTCFullYear/getUTCMonth/getUTCDate,全程不碰本地时区。这是必须的:在有夏令时的地区,本地午夜到本地午夜可能只有 23 小时或 25 小时(实测美东 2024-03-10 → 03-11 只有 23 小时),一旦用它算天数就会差出一天。用 UTC 归一后,东八区、东一区、西五区的访问者算出的月数差与结果日期完全一致。

这个页面会上传我填的日期吗?

不会。解析用正则、计算用 Date.UTC 与 getUTCxxx,模板里没有任何 fetch 或 XMLHttpRequest。可以在开发者工具的 Network 面板核实:页面加载后除自身 CSS 外不再发出任何请求,刷新即清空。日期属于敏感信息时也可以放心使用。

相关工具

月日期计算器只锚定「月」这一个维度,下面这些页面处理其它时间口径,跨月、跨年、跨闰年的场景常需要交叉核对。