首页 / 文章

将时间类型与实际日期任务匹配:七个实例解析

通过 JavaScript Temporal 学习七项常见的日期和时间处理任务,涵盖普通日期与月份溢出处理、时区转换、夏令时运算以及数据验证等内容。

发布者:REACTAPP.TOP阅读约 9 分钟2060 词

来源: JavaScript Temporal: 7 Date and Time Problems Explained Simply

日期相关的需求听起来似乎很简单:将订阅期限延长一个月、在同事的时区显示会议时间、计算两个日期之间的天数。但问题往往出现在后来——比如二月天数不足、夏令时调整导致“明天”实际上还有23小时才到,又或者某个生日根本没有时区信息却仍需被存储。JavaScript的Temporal API通过为每种类型的值指定专门的类型来解决这些问题,类型本身就能说明该值的含义。下面的七个实际示例展示了哪种类型适用于何种场景、哪些默认设置可能会让你感到意外,以及Temporal故意将哪些决策交由应用程序自行决定。

准备运行示例的环境

原生支持仍在逐步推出中。在撰写本文时,MDN显示Temporal的功能可用性有限,因此在依赖内置实现之前,请先确认目标浏览器及Node.js版本是否支持该功能。MDN的Temporal参考页面会实时更新兼容性状态。

若想在原生支持尚未就绪时进行测试,可将在Temporal polyfill安装到空的项目文件夹中。这需要Node.js和npm:

mkdir temporal-demo
cd temporal-demo
npm init -y
npm install @js-temporal/polyfill@0.5.1

创建一个名为demo.mjs的文件,并以以下导入语句开头:

import { Temporal } from "@js-temporal/polyfill";

在导入语句下方添加任意示例代码,然后运行该文件:

node demo.mjs

所有示例都假设使用相同的导入方式。该补丁导出的是本地版本的Temporal绑定,并不会安装全局版本,因此每个使用它的文件都需要进行导入。其版本被固定为0.5.1,因为示例正是基于该版本编写的;在实际项目中使用之前,请先查看该补丁的各版本信息。

1. 存储不包含时间或时区的日历日期

生日或合同截止日期往往没有具体的时间信息。如果要求仅仅是“10月15日”,那么加上午夜时间和时区实际上是在编造信息,而正是这些编造的信息导致该日期在其他地方显示时会偏移一天。Temporal.PlainDate仅用于表示日期本身:

const deadline = Temporal.PlainDate.from("2026-10-15");

它的字段都是普通数字,且能序列化为ISO格式的字符串:

console.log(deadline.year);       // 2026
console.log(deadline.month);      // 10
console.log(deadline.day);        // 15
console.log(deadline.toString()); // 2026-10-15

该值没有小时、UTC偏移量也没有时区信息。它默认使用ISO日历,与旧的Date对象不同,月份的编号是从1到12,而非从0开始。

请记住这一限制:PlainDate无法确定绝对时间中的具体时刻。如果截止时间真的是“德里时间的下午5点”,那么还需要时间和时区信息,而这正是ZonedDateTime的作用。

2. 在不修改原对象的情况下生成新日期

可共享的可变日期是导致错误的常见原因:代码的某一部分修改了某个值,而另一部分仍在读取该值。时间对象是不可变的,进行算术运算时总会生成一个新的对象:

const originalDate = Temporal.PlainDate.from("2026-10-15");
const extendedDate = originalDate.add({ days: 7 });

记录两个值可以证明原对象并未被修改:

console.log(originalDate.toString()); // 2026-10-15
console.log(extendedDate.toString()); // 2026-10-22

这意味着无需先进行任何克隆操作,就可以将原来的截止日期与延长的截止日期并列显示。

但常见的错误是:单独在一行中调用originalDate.add({ days: 7 }),却期望originalDate会发生变化。实际上它并不会改变。需要将结果赋值或返回。

3. 确定“一个月后”的含义

对于1月31日来说,加一个月并没有唯一的正确答案,因为2月没有31日这一天。您的应用程序需要制定相应的规则。Temporal的默认做法是将结果限制在最近的有效日期内。可以从1月底开始计算:

const januaryEnd = Temporal.PlainDate.from("2026-01-31");

加一个月时,日期会被限制在2月的最后一天:

const nextMonth = januaryEnd.add({ months: 1 });console.log(nextMonth.toString()); // 2026-02-28

如果不允许静默处理日期移动的情况,可以通过传入overflow: "reject"让Temporal抛出异常。以计费日期为例:

const billingDate = Temporal.PlainDate.from("2026-01-31");

这种拒绝操作会表现为RangeError,代码片段会在捕获该异常后重新抛出其他错误:

try {
  billingDate.add({ months: 1 }, { overflow: "reject" });
} catch (error) {
  if (!(error instanceof RangeError)) throw error;
  console.log("That day does not exist in the next month.");
}

这两种方式都不是计费策略本身,而只是实现该策略的机制。此外还存在累积效应需要注意:一旦1月31日被限制为2月28日,再向该结果添加一个月就会得到3月28日而非3月31日,因此通过不断在原有值上添加一个月来构建的时间表会逐渐偏移。如果规则是“每个月的最后一天”,应明确建模该规则,例如始终从原始基准日期开始计算或使用当月的天数,而非反复进行加法操作。

4. 在多个时区显示同一时刻

事件的时间戳用于标识一个确切的瞬间。某人屏幕上显示的本地日期和时间取决于其所在位置。Temporal.Instant代表的就是那个瞬间:

const meeting = Temporal.Instant.from("2026-10-15T09:00:00Z");

将其转换为指定时区后,即可得到各地区的本地时钟时间,再转换回来则可确认两者仍然指向同一个瞬间:

const delhi = meeting.toZonedDateTimeISO("Asia/Kolkata");
const london = meeting.toZonedDateTimeISO("Europe/London");console.log(delhi.toString());
// 2026-10-15T14:30:00+05:30[Asia/Kolkata]console.log(london.toString());
// 2026-10-15T10:00:00+01:00[Europe/London]console.log(delhi.toInstant().equals(london.toInstant()));
// true

虽然本地显示形式不同,但背后的瞬间是相同的。(此代码片段中连在一起的几行实际上应各自为独立语句。)

建议使用如Europe/London这样的命名时区,而非固定的时差值。命名时区包含了其时差在一年中如何变化的规则;而硬编码的+01:00仅能描述伦敦的夏令时情况。

许多错误都是从这里开始的:时差并非时区。时差表示某一特定时刻与协调世界时的差异,而命名时区则提供了确定任何时刻该差异的规则。

上述字符串都是为机器设计的,包括括号中的时区标注。对于要展示给用户的内容,应使用如 toLocaleString() 或 Intl.DateTimeFormat 等方法按照用户的区域设置格式化数值,而非直接输出原始字符串。

5. 将“明天”与“24小时后”区分开

在一年中的大部分时间,这两个表述的结果是相同的。但在夏令时调整期间则不然。以2026年3月7日纽约的正午为例,即时钟向前调整的前一天:

const beforeClockChange = Temporal.ZonedDateTime.from(
  "2026-03-07T12:00:00-05:00[America/New_York]"
);

增加一天的日历时间与增加24小时会产生不同的本地时间:

const tomorrow = beforeClockChange.add({ days: 1 });
const after24Hours = beforeClockChange.add({ hours: 24 });console.log(tomorrow.toString());
// 2026-03-08T12:00:00-04:00[America/New_York]console.log(after24Hours.toString());
// 2026-03-08T13:00:00-04:00[America/New_York]

日历运算会将实时时钟的时间保持在中午。经过时间运算则精确计算24小时,由于时钟在夜间向前跳了一小时,因此结果会显示为下午1点。

这两种方法适用于不同的需求。日常预约通常意味着“明天的同一本地时间”,因此需要加上天数;而恰好在24小时后到期的令牌或预约则应加上小时数。Temporal可以计算这两种时间,你需要决定你的功能需要哪种含义,并且值得把这一决定记录下来。

6. 拒绝无效日期和不存在的本地时间

在验证表单输入时,不合理的日期应引发错误,而不应悄悄变成另一个日期。对于对象输入,应使用overflow: „reject“来要求严格处理:

try {
  Temporal.PlainDate.from(
    { year: 2026, month: 2, day: 30 },
    { overflow: "reject" }
  );
} catch (error) {
  if (!(error instanceof RangeError)) throw error;
  console.log("Please choose a valid calendar date.");
}

如果没有该选项,对象{ year: 2026, month: 2, day: 30 }将被限制在2月28日。ISO字符串的行为则不同:解析"2026-02-30"时会直接被拒绝,而不会尝试修正。切勿认为字符串解析与对象构造具有相同的溢出处理方式。

有效的日期仍可能与根本不存在的本地时间组合。当纽约在2026年3月8日将时钟拨快时,凌晨2点30分这一时间会被完全跳过。disambiguation选项用于控制此类输入时的处理方式:

try {
  Temporal.ZonedDateTime.from(
    "2026-03-08T02:30[America/New_York]",
    { disambiguation: "reject" }
  );
} catch (error) {
  if (!(error instanceof RangeError)) throw error;
  console.log("Choose a time that exists and is unambiguous.");
}

当使用disambiguation: "reject"时,Temporal会在时间被跳过或重复出现的情况下抛出异常,比如时钟回退时出现两次的同一小时。其他设置则会自动选择一种解释方式。对于预订表单而言,要求用户选择有效时间通常比在未告知他们的情况下更改预约时间更为清晰。

7. 显式比较、测量和排序日期

进行时间顺序比较时,应使用该类型自带的方法。首先准备两个日期:

const startDate = Temporal.PlainDate.from("2026-10-15");
const endDate = Temporal.PlainDate.from("2026-10-20");

Temporal.PlainDate.compare()会根据第一个参数是更早、相等还是更晚,返回-1、0或1。而until()则返回一个Temporal.Duration对象,该对象会以ISO 8601时间间隔格式显示结果,因此五天会表示为P5D:

console.log(Temporal.PlainDate.compare(startDate, endDate));
// -1const difference = startDate.until(endDate, {
  largestUnit: "days",
});console.log(difference.days);       // 5
console.log(difference.toString()); // P5D

对于PlainDate而言,时间差是以日历天数来计算的。它不会计算任何时区内的经过小时数,也不会决定产品是否将起始日和结束日都计入其中;这一“偏移一天的规则”需要由您自行定义。

由于compare的方法签名与Array.prototype.sort的要求一致,因此可以直接将其传入。给定一个未排序的日期列表:

const dates = [
  "2026-11-03",
  "2026-10-15",
  "2026-10-20",
].map(value => Temporal.PlainDate.from(value));

排序操作就可以用一行代码完成:

dates.sort(Temporal.PlainDate.compare);console.log(dates.map(date => date.toString()).join(", "));
// 2026-10-15, 2026-10-20, 2026-11-03

避免沿用 Date 的习惯。对这些类型而言,减去时间对象或使用 <、> 进行比较会引发异常,这是有意为之:旨在防止出现错误类型的意外比较。而 === 是用于检查对象身份的,因此分别创建的两个相同日期不会满足 === 的条件;若要判断值是否相等,请使用 .equals()。关于旧习惯如何以更隐蔽的方式再度出现的问题,可参阅我们的指南 那些会再次引发日期错误的Temporal API陷阱。

在编写计算代码之前先确定类型

大多数日期错误实际上都是建模错误,因此首先需明确某个值的含义:

  • 不含时间或时区的日期:PlainDate
  • 不含日期或时区的时间:PlainTime
  • 不含时区的本地日期和时间:PlainDateTime
  • 全球时间线上的精确节点:Instant
  • 与指定时区关联的日期和时间:ZonedDateTime
  • 日历时间或时钟时间的长度:Duration
  • 在设计 API 接口或数据库字段时,这一选择值得仔细讨论。仅命名为 date 的字段容易引发争议,因为人们不确定它存储的是日历日期、UTC 时间戳还是本地时间。

    迁移无需一次性完成。先挑选一个需求明确的功能,定义其输入格式、时间类型、时区规则以及预期输出,并针对月末和时钟调整等情况编写测试用例。在没有必要之前,可以暂不修改日期处理代码。

    生产前检查清单

    • 在您部署的每个环境中验证时间功能的支持情况,如果使用了填充实现,则需在打包预算中考虑其下载和解析所需的成本。
    • 与后端明确约定序列化格式。普通日期、UTC时间戳以及带有括号内时区标识的字符串是三种不同的格式。
    • 明确业务规则:是否允许“一个月后”这一表述调整日期、遇到模糊的预约时间时应否拒绝,以及时长是指日历天数还是实际经过的小时数?

    总结

    Temporal 的主要贡献并非提供更友好的 API 接口,而是明确了 Date 所模糊的区分标准:日期与瞬间、时差与时区、日历运算与经过时间。它为每种情况提供了精确的工具,还包括严格模式,在出现错误时会明确报错而非猜测。但它无法替你选择规则——你需要自行制定这些规则,将其编码到传入的类型和选项中,并通过测试处理边缘情况。