DEBUG.WIKI
野草云;香港 200Mbps 峰值;不限月流量 · BGP VPS;查看套餐 →
雨云;给项目一台云服务器
RackNerd;$21.99/年 · 1GB KVM

date-fns 传入小数月份,减半个月却退到上个月

addMonths 或 subMonths 收到小数月份后,负向移动可能多退一个月。核对 date-fns 4.4.0 的取整顺序,按业务约定拒绝小数或先向零截断,并保留月末规则。

addMonths(date, -0.5) 没有留在原来的月份,反而退到了上个月;subMonths(date, 0.5) 也可能出现同样结果。如果传入的确实是 number,应检查小数月份进入日历计算的位置,而不只是排查表单字符串。

正式 date-fns 4.4.0 的 addMonths 没有先把 amount 向零截断。它先把当前月、amount 和 1 相加,再交给 Date.setMonth() 计算目标月末;小数在合并以后被截断,与先截断 amount 不同。起始月份和月末天数都会影响最终表现。

先决定业务是否允许半个月

“减去 0.5 个月”没有统一的日历含义。二月、三月长度不同,半个月不能自动解释成固定十五天。若接口只允许整月,应在调用前拒绝小数;若既有约定明确规定忽略小数部分,再显式执行向零截断。

下面针对后一种约定,只接收有限的 number:

import { addMonths, isValid } from 'date-fns';

function addWholeMonths(date, amount) {
  if (!(date instanceof Date) || !isValid(date)) throw new RangeError('起始日期无效');
  if (typeof amount !== 'number' || !Number.isFinite(amount)) {
    throw new TypeError('月份数量必须是有限数字');
  }
  const months = Math.trunc(amount);
  if (!Number.isSafeInteger(months)) {
    throw new RangeError('月份数量超出整数范围');
  }
  const result = addMonths(date, months);
  if (!isValid(result)) throw new RangeError('结果日期超出有效范围');
  return result;
}

这里的 date 是已经按应用时区约定取得的有效 Date。Math.trunc(-0.5) 得到负零,addMonths 的零增量分支会保留原时刻;用 Math.floor(-0.5) 则得到 -1,仍会多减一个月。若业务禁止小数,把截断改为整数校验并明确拒绝,不要悄悄改变输入含义。

这层检查还没有规定允许移动多少个月。账期、预约等业务应在原有入口继续限制范围,不应因为结果能生成 Date 就接受几百万个月。

月末截取仍按日历规则执行

向零截断只处理数量。三月三十一日减一个整月,仍会落到二月最后一天,这是 addMonths 对目标月末的正常处理,不是同一个故障。

也不要用时间戳减去 30 * 86400000 替换一个月。那会同时改变月份长度与跨夏令时规则。应用如果真正需要固定时长,应在协议里使用明确的小时或毫秒单位,不在整月函数中猜测。

这与addDays 收到字符串后发生拼接不同:那篇处理参数不是 number,本题即使传入合法数字 -0.5,也会碰到截断顺序。仅加一次 Number 转换不能解决它。

截至 2026 年 10 月 26 日,正式 4.4.0 仍保留上述计算路径。后续采用库更新时,应检查实际安装实现,不把未发布分支的调整当成现有修复。

验收覆盖三月末、普通月中、跨年,以及正负小数、零和正负整数。既要确认月份数量符合约定,也要确认原来的月末规则和本地时间没有被入口修正一并改掉。