日历代码中的日期计算:处理闰年、月份和星期起始日

运营教程5小时前发布 jdkjadmin
0 00
日历代码中的日期计算:处理闰年、月份和星期起始日

日历代码中的日期计算:处理闰年、月份和星期起始日

日历看起来只是把数字排进格子,实际实现时却容易被几个细节绊住:二月有时是 28 天、有时是 29 天;星期可能从周日开始,也可能从周一开始;而 JavaScript 的本地时间还会受时区和夏令时影响。若日期来自表单、接口或数据库,输入校验和有效范围也必须明确。

本文以 JavaScript 为例,演示如何计算闰年、某月天数、月历第一行的偏移量,以及完整的月历网格。示例使用 UTC 构造日历日期,避免把时区问题混进纯粹的年月日运算。代码中的应用层范围限定为公历年份 1 至 9999;这是一项示例设计选择,不是 JavaScript 日期类型本身的完整取值范围。

先分清“日期”和“时刻”

“2025 年 3 月 8 日”是一个日历日期;“2025 年 3 月 8 日 09:30,东京时间”则是一个具体时刻。前者没有时区和时分秒,后者需要时区信息。把日期误当成时刻处理,常见后果是用户选了某一天,保存或显示后却变成前一天。

如果业务只关心日历日期,例如生日、假期、账单日或日期筛选,计算时应避免不必要地经过本地时区。下面的实现统一以 UTC 午夜表示某个日期,并只调用 UTC 相关方法。这里的 UTC 只是稳定的计算载体,并不意味着业务日期本身属于 UTC 时区。

闰年规则:先判断,再计算天数

公历闰年的规则是:年份能被 4 整除时通常是闰年;能被 100 整除的年份不是闰年;但能被 400 整除的年份仍是闰年。因此,2000 年是闰年,1900 年不是,2024 年是,2025 年不是。

不能只用“能被 4 整除”判断,否则会把 1900 年等世纪年份算错。按上述规则,可以直接写成:

function isLeapYear(year) {
  return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
}

这个函数按公历规则判断整数年份。实际应用仍应校验输入是否为整数,以及年份是否在产品规定的范围内。下面的完整实现把范围限制为 1 至 9999,便于处理常见业务系统中的公历日期;若产品需要更早或更晚的年份,应重新确认数据模型、格式化方式和运行环境的支持情况。

计算每个月的天数

一月、三月、五月、七月、八月、十月和十二月有 31 天;四月、六月、九月和十一月有 30 天;二月的天数取决于年份是否为闰年。把这些规则集中在一个函数中,后续校验日期和构造月历都可以复用它。

function daysInMonth(year, month) {
  // month 使用人类习惯的编号:1 表示一月,12 表示十二月。
  if (!Number.isInteger(year) || year < 1 || year > 9999) {
    throw new RangeError("year 必须是 1 到 9999 之间的整数");
  }
  if (!Number.isInteger(month) || month < 1 || month > 12) {
    throw new RangeError("month 必须是 1 到 12 之间的整数");
  }

  if (month === 2) {
    return isLeapYear(year) ? 29 : 28;
  }

  return [4, 6, 9, 11].includes(month) ? 30 : 31;
}

JavaScript 的 Date 构造函数在部分场景会自动“进位”:例如把日期设为 2 月 31 日,可能得到 3 月的某一天,而不是报错。这种行为适合某些日期偏移操作,却不适合验证用户输入。验证时,应先用 daysInMonth 判断日是否有效,不能把自动进位当成输入校验。

可靠地构造 UTC 日历日期

可以用 Date.UTC(year, monthIndex, day) 生成时间戳,但它对 0 至 99 年有一个容易忽略的特殊处理:这些年份会被映射到 1900 至 1999 年。为了让构造过程不依赖这个规则,示例先创建一个日期,再通过 setUTCFullYear 设置完整年份。

function utcDate(year, month, day) {
  // 调用前应确保年月日已通过本应用的有效性校验。
  const date = new Date(0);
  date.setUTCHours(0, 0, 0, 0);
  date.setUTCFullYear(year, month - 1, day);
  return date;
}

这里的 month - 1 是因为 JavaScript 的日期对象使用 0 到 11 表示月份:0 是一月,11 是十二月。对外接受参数时采用 1 到 12,通常更符合人类习惯;在调用 Date API 的边界处再转换,可以减少混淆。

JavaScript 的 Date 时间值有实现规定的有效范围:时间戳的绝对值不超过 8.64 × 1015 毫秒,超出会成为无效日期。这个限制与示例的 1 至 9999 年应用范围不是一回事。不要仅凭范围内的年份就假定所有外部输入都有效;应使用 Number.isFinite(date.getTime()) 检查构造结果。不同 API、数据库和格式化器也可能有各自的年份限制。

从星期编号得到月历偏移量

getUTCDay() 返回 0 至 6,其中 0 是星期日,6 是星期六。很多地区的周历从星期一开始;如果月历采用“周一为第一列”,就需要把 JavaScript 的编号转换为以周一为 0 的编号。

function weekdayIndex(day, weekStartsOn) {
  // day 是 Date 对象;weekStartsOn:0 表示周日,1 表示周一。
  const sundayBasedDay = day.getUTCDay();
  return (sundayBasedDay - weekStartsOn + 7) % 7;
}

例如,某月 1 日是星期日,且一周从星期一开始,那么它在第一行中的偏移量是 6;若一周从星期日开始,偏移量则是 0。通用公式是:

偏移量 = (星期编号 - 一周起始日 + 7) % 7

加 7 再取模是为了确保结果为非负数。JavaScript 的余数运算对负数不会自动得到数学意义上的正模,因此直接写 (a - b) % 7 可能得到负值。

生成月历网格

月历常见有两种显示策略:只生成实际覆盖该月的周数,或固定显示 6 行(42 个格子)。固定 6 行便于界面高度一致;按需生成则减少空白格,但不同月份高度可能不同。下面的函数提供 fixedWeeks 参数:为 true 时始终返回 42 个日期格,为 false 时返回覆盖该月所需的最少完整周数。每个格子包含日期和是否属于目标月份的信息。

function isLeapYear(year) {
  return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
}

function daysInMonth(year, month) {
  if (!Number.isInteger(year) || year < 1 || year > 9999) {
    throw new RangeError("year 必须是 1 到 9999 之间的整数");
  }
  if (!Number.isInteger(month) || month < 1 || month > 12) {
    throw new RangeError("month 必须是 1 到 12 之间的整数");
  }

  if (month === 2) {
    return isLeapYear(year) ? 29 : 28;
  }
  return [4, 6, 9, 11].includes(month) ? 30 : 31;
}

function utcDate(year, month, day) {
  const date = new Date(0);
  date.setUTCHours(0, 0, 0, 0);
  date.setUTCFullYear(year, month - 1, day);
  return date;
}

function weekdayIndex(day, weekStartsOn) {
  return (day.getUTCDay() - weekStartsOn + 7) % 7;
}

function buildMonthGrid(year, month, options = {}) {
  const weekStartsOn = options.weekStartsOn ?? 1;
  const fixedWeeks = options.fixedWeeks ?? true;

  if (!Number.isInteger(year) || year < 1 || year > 9999) {
    throw new RangeError("year 必须是 1 到 9999 之间的整数");
  }
  if (!Number.isInteger(month) || month < 1 || month > 12) {
    throw new RangeError("month 必须是 1 到 12 之间的整数");
  }
  if (!Number.isInteger(weekStartsOn) || weekStartsOn < 0 || weekStartsOn > 6) {
    throw new RangeError("weekStartsOn 必须是 0 到 6 的整数");
  }
  if (typeof fixedWeeks !== "boolean") {
    throw new TypeError("fixedWeeks 必须是布尔值");
  }

  const firstDay = utcDate(year, month, 1);
  if (!Number.isFinite(firstDay.getTime())) {
    throw new RangeError("日期超出 JavaScript Date 的有效范围");
  }

  const offset = weekdayIndex(firstDay, weekStartsOn);
  const monthLength = daysInMonth(year, month);
  const cellCount = fixedWeeks
    ? 42
    : Math.ceil((offset + monthLength) / 7) * 7;

  // 先定位到网格第一格,再逐日生成,避免手工处理跨月和跨年。
  const gridStart = utcDate(year, month, 1 - offset);
  const cells = [];

  for (let i = 0; i < cellCount; i += 1) {
    const date = new Date(gridStart.getTime());
    date.setUTCDate(gridStart.getUTCDate() + i);

    cells.push({
      date,
      year: date.getUTCFullYear(),
      month: date.getUTCMonth() + 1,
      day: date.getUTCDate(),
      inCurrentMonth:
        date.getUTCFullYear() === year &&
        date.getUTCMonth() + 1 === month
    });
  }

  return cells;
}

const cells = buildMonthGrid(2024, 2, {
  weekStartsOn: 1, // 周一为第一列
  fixedWeeks: true
});

console.log(cells.length); // 42
console.log(cells.filter(cell => cell.inCurrentMonth).length); // 29
console.log(
  cells.filter(cell => cell.inCurrentMonth).map(cell => cell.day)
);
// [1, 2, 3, ..., 29]

示例中的 utcDate(year, month, 1 - offset) 可能得到上一个月的日期,这是有意为之。月历第一行的空位并不是“不存在的日期”,而是上个月的实际日期;最后一行也可能包含下个月的日期。生成连续日期,再通过年月比较标记是否属于当前月,通常比逐格编写分支更简单,也不容易漏掉跨月和跨年的情况。

weekStartsOn 在本实现中取 0 到 6,含义沿用 JavaScript 的编号:0 为星期日,1 为星期一,以此类推。它只控制网格列的排列,不改变日期本身。真实产品通常应由地区设置或用户偏好决定一周起始日,不要假定所有用户都采用同一种周历。

校验用户提供的年月日

生成月历和验证具体日期是两个不同问题。月历函数只需要年月;如果表单提交了年、月、日,则应逐项校验,再比较日期与该月的实际天数。下面的函数拒绝小数、字符串和超出范围的值,不会悄悄把无效日期滚动到下个月。

function validateCivilDate(year, month, day) {
  if (!Number.isInteger(year) || year < 1 || year > 9999) {
    return false;
  }
  if (!Number.isInteger(month) || month < 1 || month > 12) {
    return false;
  }
  if (!Number.isInteger(day)) {
    return false;
  }

  return day >= 1 && day <= daysInMonth(year, month);
}

console.log(validateCivilDate(2024, 2, 29)); // true
console.log(validateCivilDate(2023, 2, 29)); // false
console.log(validateCivilDate(2024, 4, 31)); // false

如果输入来自 HTML 表单或 JSON,数据通常先以字符串出现。应在解析阶段明确接受的格式,并检查转换结果;不要因为 Number(" ") 得到 0,就把空字符串当成合法年份。也不要用宽松的日期字符串解析来代替格式校验,因为浏览器对不同字符串格式的解析行为可能不适合作为业务规则。

常见错误与规避方式

  • 只按 4 的倍数判断闰年:会误判 1900 年。使用完整的 4、100、400 规则。
  • 混用月份编号:业务参数采用 1 至 12,Date 的月份索引采用 0 至 11。应在明确的边界处转换,并在函数注释中写清楚。
  • 把“周一为第一天”写死:星期编号来自 API,周起始日来自产品或地区设置。二者不是同一个概念。
  • 直接用本地日期做逐日运算:夏令时切换日的本地一天不一定是固定的 24 小时。纯日历计算使用 UTC 方法;若业务处理的是当地时刻或时区规则,应使用明确的时区模型。
  • 依赖构造函数自动修正日期:无效输入可能被滚动到后续月份。先验证年月日,再构造日期。
  • 把秒数加减当作所有日期偏移的通用办法:本地时间跨越夏令时会改变实际时长;即便在 UTC 中,也应明确操作的是“增加一天”还是“增加 24 小时”。对日历格子使用 UTC 日期字段逐日递增更直观。
  • 固定假设月历只需 5 行:某些月份会占据 6 个完整周行。若布局高度固定,使用 42 格;若按需显示,按偏移量和月长计算周数。

参数限制、错误处理与安全用途

示例把年份限制在 1 至 9999、月份限制在 1 至 12、日限制在该月实际天数内;weekStartsOn 限制为 0 至 6,fixedWeeks 必须是布尔值。这些限制让函数输入含义明确,但它们不是所有系统通用的标准。若要表示历史日期、天文学年份编号、不同历法或更宽的时间范围,应先确定业务约定,并检查目标语言、数据库、序列化格式和用户界面的支持情况。

日期校验可以帮助避免错误的业务输入,但不能替代安全控制。日期字段本身不会让数据库查询自动安全,也不能防止跨站脚本、注入或越权访问。将日期用于查询时,应使用参数化查询;将日期用于 HTML 输出时,应按对应上下文进行转义;服务器端还应重新校验客户端提交的数据。日期范围限制也不等同于权限校验或防滥用措施。

实际落地时的检查清单

  1. 确认数据表示的是日历日期还是带时区的具体时刻。
  2. 明确公历范围、输入格式以及月份编号约定。
  3. 用完整规则判断闰年,并依据实际月长验证日。
  4. 明确一周从星期几开始,避免在代码中隐式假设。
  5. 纯日历运算统一使用 UTC 方法,避免本地时区和夏令时干扰。
  6. 测试闰年世纪例外、跨月网格、跨年网格和输入边界。

日历代码的难点不在于算术复杂,而在于把每个约定说清楚:日期与时刻的区别、月份编号、星期起始日、有效年份范围,以及无效输入的处理策略。将这些规则集中在小函数中,并用边界用例验证,月历显示、日期筛选和账单周期等功能就能建立在更可靠的基础上。

© 版权声明

相关文章