JavaScript Date 计算会错得有多离谱?
Source: Dev.to
问题
2025 年 1 月,我在加利福尼亚州圣克拉拉编写一些 JavaScript 用于生成报告。我想获取一个月内发生的事件数量,于是创建了该月第一天的日期对象,给它加了一个月,然后减去一天得到该月的最后一天。听起来很直接,对吧?
但我得到了一个非常奇怪的结果。我把问题简化到了下面的代码。
const date = new Date("2024-01-01T00:00:00.000Z");
date.toISOString(); // => "2024-01-01T00:00:00.000Z" as expected
date.setMonth(1);
date.toISOString(); // => "2023-03-04-T00:00:00.000Z" WTF?
我在 2024 年 1 月 1 日上加了一个月,却落在了 2023 年 3 月 4 日。到底发生了什么?
时间和时区
你可能会觉得奇怪,我把场景设在美国西海岸,但这确实很重要。这段代码在 UTC 以及它东部的地区运行时是正常的。
JavaScript 的 Date 不仅仅是日期,它还包含时间。即使我只想处理天和月,时间仍然会产生影响。
2024 年 1 月 1 日午夜(UTC)在太平洋时间(UTC‑8)仍然是 2023 年 12 月 31 日下午 4 点。date.setMonth(1) 将月份设为二月(月份是从 0 开始计数的)。但我们实际上是从 2023 年 12 月 31 日开始的,所以 JavaScript 必须处理不存在的“2023 年 2 月 31 日”。它会溢出到下一个月,得到 3 月 3 日。最后,在打印时,日期被转换回 UTC,结果就是 2023 年 3 月 4 日的午夜。
把这些步骤拆开来看都很合理;混淆之处在于意外的结果。
始终使用 UTC
因为我并不关心时间,只想在 UTC 下工作,我使用 Date 对象的 setUTCMonth 方法修正了代码。我的原始代码是通过减去一天来得到一个月的最后一天,所以我也使用了 setUTCDate。所有 set${timePeriod} 方法都有对应的 setUTC${timePeriod} 方法。
const date = new Date("2024-01-01T00:00:00.000Z");
date.toISOString(); // => "2024-01-01T00:00:00.000Z"
date.setUTCMonth(1);
date.toISOString(); // => "2024-02-01-T00:00:00.000Z"
这样就解决了问题。
引入 Temporal
原始代码出错的一个原因是我在不自觉的情况下同时操作了日期 和 时间。Temporal 提供了专门用于不含时区的日历日期的对象。
使用 Temporal.PlainDate 来表示日历日期可以简化操作。与其设置月份和日期或添加毫秒,不如直接添加一个持续时间。Temporal 对象是不可变的,每一次更改都会返回一个新对象。
const startDate = Temporal.PlainDate.from("2024-01-01"); // => Temporal.PlainDate 2024-01-01
const nextMonth = startDate.add({ months: 1 }); // => Temporal.PlainDate 2024-02-01
const endDate = nextMonth.subtract({ days: 1 }); // => Temporal.PlainDate 2024-01-31
不必担心时间的日期操作——太棒了!
注意时区
Temporal 正在逐步推向 JavaScript 引擎。撰写本文时,它已在 Firefox 中可用,并 刚刚在 Chrome 144 中上线。在 Safari Technical Preview 中也可以通过标志位启用。要在本地测试,可打开 Firefox 或 Chrome,或使用以下 polyfill:
如果你仍需使用 Date,请牢记时区。考虑现在就迁移到,或至少学习如何使用 Temporal。即使你尝试避免它们,时区仍然会让你头疼。