← 中文资源

简体中文版本

带月末夹取的月份加减

在 UTC 下加减月份,并把日期夹取到目标月份的最后一天,避免 1 月 31 日加一月溢出到 3 月。

阅读英文原文

功能说明

addMonthsClamped 在指定日期上增加或减少整数个月,若原日期在目标月份不存在,则夹取到该月最后一天。例如 1 月 31 日加一个月得到 2 月 28 日或 29 日,而不是自动进位到 3 月。addYearsClamped 复用该逻辑处理闰日出生者的年度计算。适合订阅续费、账单周期、试用期和生日提醒等场景。

关键代码

函数先把目标月份的第一天与原始时分秒毫秒一起构造出来,避免直接用溢出日期。随后用 daysInMonth 计算目标月天数,取原始日号与月末日的较小值,再设置回目标日期。daysInMonth 通过下个月第零天获取上个月最后一天,这是 Date 构造器的标准技巧。所有运算使用 UTC,避免本地时区与夏令时干扰。

使用注意

夹取策略会让同一天在不同月份对应不同日号,续费业务应明确是保持月末还是保持日号。函数返回新 Date,不修改原对象。月份参数为负可回退,但必须是整数。UTC 运算意味着显示给用户前可能需要按目标时区格式化。跨时区的账单日不应直接用本地时间构造 Date 再调用,否则边界日仍可能偏移。

验证方式

测试 1 月 31 日加一月、3 月 31 日减一月、闰年 2 月 29 日加一年与平年 2 月 28 日加一年。验证 12 月加一月跨年、1 月减一月跨年,以及保留时分秒毫秒。测试零个月返回相同时间、非法 Date、非整数月份、负数月份与 daysInMonth 在闰年二月的返回值。

代码示例

export function daysInMonth(year: number, monthIndex: number): number {
  if (!Number.isInteger(year) || !Number.isInteger(monthIndex)) {
    throw new TypeError("year and monthIndex must be integers");
  }
  return new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
}

// Add months with end-of-month clamping.
export function addMonthsClamped(date: Date, months: number): Date {
  if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
    throw new RangeError("date must be a valid Date");
  }
  if (!Number.isInteger(months)) throw new TypeError("months must be an integer");
  const target = new Date(Date.UTC(
    date.getUTCFullYear(),
    date.getUTCMonth() + months,
    1,
    date.getUTCHours(),
    date.getUTCMinutes(),
    date.getUTCSeconds(),
    date.getUTCMilliseconds(),
  ));
  const day = Math.min(date.getUTCDate(), daysInMonth(target.getUTCFullYear(), target.getUTCMonth()));
  target.setUTCDate(day);
  return target;
}

export function addYearsClamped(date: Date, years: number): Date {
  if (!Number.isInteger(years)) throw new TypeError("years must be an integer");
  return addMonthsClamped(date, years * 12);
}