Temporal API 提案:解决 JavaScript Date 对象的时区与夏令时问题

各位编程领域的专家、开发者们:

大家好!

今天,我们齐聚一堂,共同探讨一个在JavaScript开发中长期困扰我们的核心问题:日期和时间处理。更具体地说,是JavaScript原生Date对象在处理时区、夏令时以及复杂日期时间计算时所暴露的种种弊端。面对这些挑战,TC39 委员会正在积极推动一项全新的提案——Temporal API,它旨在彻底革新JavaScript中的日期时间处理方式,使其变得更加健壮、直观和可预测。

作为一名在软件开发领域摸爬滚打多年的工程师,我深知日期时间处理的复杂性。它不仅仅是简单地显示一个数字,更涉及到不同时区的转换、跨越夏令时边界的计算、国际化格式化,以及对时间点和时间段的精确建模。传统的Date对象在这些方面力不从心,甚至可以说是充满了陷阱。

今天的讲座,我将深入剖析Date对象的固有缺陷,然后详细介绍Temporal API的核心概念、设计哲学以及如何利用它来构建更可靠的日期时间逻辑。我们将通过大量的代码示例,一步步揭示Temporal的强大之处,并展望它将如何改变我们未来的开发实践。

JavaScript Date 对象的症结所在

在我们拥抱未来之前,让我们先回顾一下过去,或者说,回顾一下我们正在使用的Date对象。它就像一把瑞士军刀,试图包办一切,但最终却在大多数专业任务上表现平平。

1. 糟糕的 API 设计:可变性与不确定性

Date对象最致命的缺陷之一就是它的可变性(Mutability)。当你对一个Date实例进行操作(例如setHours()setMonth())时,它会直接修改原对象,而不是返回一个新的Date实例。这在并发环境或共享状态的场景下,极易引发难以追踪的副作用。

考虑以下场景:

let d1 = new Date('2023-10-27T10:00:00Z');
let d2 = d1; // d2 和 d1 指向同一个对象

d2.setHours(d2.getHours() + 2); // 修改 d2,同时 d1 也被修改了!

console.log(d1.toISOString()); // 输出 '2023-10-27T12:00:00.000Z'
console.log(d2.toISOString()); // 输出 '2023-10-27T12:00:00.000Z'

这种行为与JavaScript中基本类型(如数字、字符串)的不可变性截然不同,它要求开发者时刻警惕,常常需要手动克隆Date对象,增加了心智负担。

2. 时区处理的混乱与夏令时陷阱

Date对象在内部存储的是一个自协调世界时 (UTC) 1970年1月1日00:00:00 以来的毫秒数,也就是 Unix 时间戳。然而,它的许多方法(如getHours()getMonth()toString())却默认使用宿主环境的本地时区进行解释。这就导致了严重的二义性和混淆。

// 假设当前本地时区是 'Asia/Shanghai' (UTC+8)

let d = new Date('2023-10-27T02:00:00Z'); // UTC时间 2023年10月27日 02:00:00

console.log(d.getHours()); // 10 (本地时间 10:00:00)
console.log(d.getUTCHours()); // 2 (UTC时间 02:00:00)

console.log(d.toString()); // Fri Oct 27 2023 10:00:00 GMT+0800 (中国标准时间)
console.log(d.toUTCString()); // Fri, 27 Oct 2023 02:00:00 GMT

问题在于,Date对象本身并不携带任何时区信息。它只是一个 UTC 毫秒数,然后根据运行环境的默认时区进行 解释。当你将这个Date对象传递给不同时区或不同设置的机器时,它的本地化表示就会发生变化,而它的内部时间点始终不变。

夏令时(DST)更是让情况雪上加霜。DST 导致时钟在一年中的特定日期向前或向后跳跃一小时。Date对象在进行日期时间计算时,会自动根据本地时区的DST规则进行调整,这听起来似乎方便,但在跨时区或特定业务逻辑中,却常常导致意想不到的结果。

例如,在 America/New_York 时区,2023年11月5日凌晨2点,时钟会从 02:00:00 EDT (UTC-4) 跳回 01:00:00 EST (UTC-5)。这意味着 01:xx 的时间会重复出现。

// 假设本地时区为 'America/New_York'
let d = new Date('2023-11-05T01:30:00-04:00'); // DST 结束前
console.log(d.toString()); // Sun Nov 05 2023 01:30:00 GMT-0400 (Eastern Daylight Time)

d.setHours(d.getHours() + 1); // 尝试加一小时

// 预期 02:30:00,但因为 DST 倒退,实际可能还是 01:30:00 (EST)
console.log(d.toString()); // Sun Nov 05 2023 01:30:00 GMT-0500 (Eastern Standard Time)

这种隐式的 DST 调整,使得开发者难以预测计算结果,尤其是在处理跨越 DST 边界的持续时间或日程安排时。

3. 笨拙的日期时间运算

Date对象缺乏对持续时间(Duration)的明确概念。进行日期时间加减运算时,我们通常需要手动将小时、分钟转换为毫秒,再进行加减,最后再创建新的Date对象。

let d = new Date('2023-10-27T10:00:00Z');
let twoHoursInMs = 2 * 60 * 60 * 1000;

let futureDate = new Date(d.getTime() + twoHoursInMs);
console.log(futureDate.toISOString()); // 2023-10-27T12:00:00.000Z

这种方式不仅冗长,而且容易出错,例如在处理月份或年份时,需要考虑不同月份的天数和闰年。

4. 脆弱的字符串解析

Date.parse()new Date(string)的行为高度依赖于实现和字符串格式。虽然 ISO 8601 格式相对可靠,但其他格式的解析结果可能因浏览器、Node.js 版本甚至操作系统的不同而异。

// ISO 8601 相对可靠
let d1 = new Date('2023-10-27T10:00:00Z');

// 其他格式可能不被所有环境支持或解析结果不一致
let d2 = new Date('October 27, 2023 10:00:00 AM GMT+0800'); // 可能在某些环境失败或解析出不同时间点

这种不确定性使得在客户端和服务器之间传输日期时间字符串时,必须格外小心。

5. 缺乏对不同时间概念的区分

Date对象只有一个类型,它模糊了时间点 (Instant)、带有时区的时间 (Zoned Date Time)、不带时区的时间 (Plain Date Time)、日期 (Date Only) 和时间 (Time Only) 等多种概念。这种一刀切的设计,使得我们无法精确地表达业务逻辑中的时间语义。

例如,一个重复的日历事件(如“每周二上午9点”)应该是一个不带时区的“日期时间”,它在不同时区有不同的Instant。而一个会议的开始时间,则是一个精确的“带有时区的时间”。Date对象无法清晰地区分这些。

Temporal API:JavaScript 日期时间的未来

为了解决上述所有问题,Temporal API 应运而生。它不是对Date对象的简单改进,而是一个全新的、现代的、以时区为中心的日期时间处理系统

Temporal 的核心设计理念可以概括为以下几点:

  1. 不可变性 (Immutability):所有 Temporal 对象都是不可变的。任何修改操作都会返回一个新的对象,而不是修改原对象。这极大地提高了代码的可预测性和健壮性。
  2. 显式性 (Explicitness):Temporal 强制你明确地表达你正在处理的是哪种时间概念(时间点、带时区的日期时间、不带时区的日期时间等)以及你正在使用的时区。没有隐式的时区转换或夏令时调整。
  3. 分离关注点 (Separation of Concerns):Temporal 提供了多种独立的类来表示不同的日期时间概念,例如Instant(时间点)、ZonedDateTime(带时区)、PlainDateTime(不带时区)、PlainDate(日期)、PlainTime(时间)、Duration(持续时间)等。
  4. 国际化支持 (Internationalization):内置对不同日历系统(如 Gregorian, Japanese, Islamic, Hebrew)和语言环境格式化的强大支持。
  5. 健壮的解析和格式化 (Robust Parsing and Formatting):提供严格且可预测的字符串解析和格式化方法。

Temporal 的核心对象概览

Temporal API 引入了一系列新的类,每个类都代表了日期时间概念的一个特定方面。理解这些类的作用是掌握 Temporal 的关键。

Temporal 类型 描述 存储内容 核心用途 示例
Temporal.Instant 精确的时间点,总是 UTC。没有时区或日历概念。 纳秒精度的时间戳(自 Unix 纪元) 记录事件发生的精确时间,进行全局时间比较。 Temporal.Instant.now(), Temporal.Instant.from('2023-10-27T10:00:00Z')
Temporal.ZonedDateTime 带有时区和日历的精确时间点 Instant + TimeZone + Calendar 存储用户在特定时区的具体时间,处理时区转换和夏令时。 Temporal.ZonedDateTime.from({ year: 2023, month: 10, day: 27, timeZone: 'America/New_York' })
Temporal.PlainDateTime 不带时区的日期和时间。例如“2023年10月27日10:00”。 年、月、日、时、分、秒、毫秒、微秒、纳秒 描述重复发生的事件(如“每天上午9点”),或用户输入但尚未分配时区的时间。 Temporal.PlainDateTime.from('2023-10-27T10:00:00')
Temporal.PlainDate 不带时区的日期。例如“2023年10月27日”。 年、月、日 描述生日、节假日等纯日期概念。 Temporal.PlainDate.from('2023-10-27')
Temporal.PlainTime 不带时区的时间。例如“10:00”。 时、分、秒、毫秒、微秒、纳秒 描述开门时间、闹钟时间等纯时间概念。 Temporal.PlainTime.from('10:00')
Temporal.PlainYearMonth 不带时区的年份和月份。例如“2023年10月”。 年、月 描述信用卡有效期、报告周期等。 Temporal.PlainYearMonth.from('2023-10')
Temporal.PlainMonthDay 不带时区的月份和日期。例如“10月27日”。 月、日 描述每年重复的事件,如纪念日。 Temporal.PlainMonthDay.from('10-27')
Temporal.Duration 时间长度或持续时间。例如“2小时30分钟”。 负或正的年、月、周、日、时、分、秒、毫秒、微秒、纳秒数 进行日期时间加减运算,计算时间差。 Temporal.Duration.from({ hours: 2, minutes: 30 })
Temporal.TimeZone 时区对象 时区标识符(如 ‘America/New_York’) 封装时区规则,用于ZonedDateTime的创建和转换。 Temporal.TimeZone.from('Asia/Shanghai')
Temporal.Calendar 日历系统对象 日历标识符(如 ‘iso8601’) 封装日历规则,用于ZonedDateTimePlainDate等。 (通常使用默认的 ISO 8601) Temporal.Calendar.from('iso8601')

1. Temporal.Instant:宇宙中的一个时间点

Instant是 Temporal 中最底层的表示,它代表了宇宙中的一个精确的、不带时区和日历的时间点。它总是 UTC,精度达到纳秒。你可以把它想象成一个全局统一的“时间戳”。

创建 Instant

// 获取当前瞬间
const now = Temporal.Instant.now();
console.log(now.toString()); // 示例: 2023-10-27T08:30:00.123456789Z

// 从 ISO 8601 字符串创建 (必须是 UTC 格式,带 'Z' 或时区偏移)
const specificInstant = Temporal.Instant.from('2023-10-27T10:00:00Z');
console.log(specificInstant.toString()); // 2023-10-27T10:00:00Z

const anotherInstant = Temporal.Instant.from('2023-10-27T18:00:00+08:00'); // 带偏移量,会被转换为 UTC
console.log(anotherInstant.toString()); // 2023-10-27T10:00:00Z (与 specificInstant 相同)

Instant 的运算:

Instant 可以与 Duration 进行加减,也可以进行比较。

const instant1 = Temporal.Instant.from('2023-10-27T10:00:00Z');
const instant2 = Temporal.Instant.from('2023-10-27T12:00:00Z');

// 比较
console.log(instant1.equals(instant2)); // false
console.log(instant1.before(instant2)); // true
console.log(instant1.after(instant2)); // false

// 加减 Duration
const twoHours = Temporal.Duration.from({ hours: 2 });
const futureInstant = instant1.add(twoHours);
console.log(futureInstant.toString()); // 2023-10-27T12:00:00Z

const pastInstant = instant2.subtract(twoHours);
console.log(pastInstant.toString()); // 2023-10-27T10:00:00Z

// 计算两个 Instant 之间的 Duration
const durationBetween = instant1.until(instant2);
console.log(durationBetween.toString()); // PT2H

2. Temporal.ZonedDateTime:带有时区的具体时间

ZonedDateTime是 Temporal 中最强大的类型之一,它将一个 Instant 与一个 TimeZoneCalendar 关联起来,从而表示一个特定时区内的具体日期和时间。这是解决时区和夏令时问题的核心。

创建 ZonedDateTime

// 从 Instant 和 TimeZone 创建
const instant = Temporal.Instant.from('2023-10-27T10:00:00Z');
const nyTimeZone = Temporal.TimeZone.from('America/New_York');
const zdtNY = instant.toZonedDateTime(nyTimeZone);
console.log(zdtNY.toString()); // 2023-10-27T06:00:00-04:00[America/New_York] (UTC 10:00 在纽约是 06:00 EDT)

// 从组件和 TimeZone 创建
const zdtShanghai = Temporal.ZonedDateTime.from({
    year: 2023,
    month: 10,
    day: 27,
    hour: 18,
    minute: 0,
    second: 0,
    timeZone: 'Asia/Shanghai' // UTC+8
});
console.log(zdtShanghai.toString()); // 2023-10-27T18:00:00+08:00[Asia/Shanghai]
console.log(zdtShanghai.toInstant().toString()); // 2023-10-27T10:00:00Z (UTC)

// 从 ISO 8601 字符串创建 (必须包含时区信息)
const zdtString = Temporal.ZonedDateTime.from('2023-10-27T10:00:00-04:00[America/New_York]');
console.log(zdtString.toString()); // 2023-10-27T10:00:00-04:00[America/New_York]

ZonedDateTime 如何处理夏令时:

这是 ZonedDateTime 的核心优势。当进行跨越 DST 边界的运算时,它会根据时区规则自动调整。

// 假设 'America/New_York' 在 2023-11-05 T02:00:00 EDT (UTC-4) 结束夏令时,变为 01:00:00 EST (UTC-5)
const nyTimeZone = Temporal.TimeZone.from('America/New_York');

// 2023年11月5日 凌晨1:30 EDT (夏令时期间)
const preDST = Temporal.ZonedDateTime.from({
    year: 2023,
    month: 11,
    day: 5,
    hour: 1,
    minute: 30,
    timeZone: 'America/New_York'
});
console.log('Pre-DST:', preDST.toString()); // 2023-11-05T01:30:00-04:00[America/New_York]

// 尝试在 Pre-DST 基础上增加 1 小时
const oneHourLater = preDST.add({ hours: 1 });
console.log('One hour later (crossing DST):', oneHourLater.toString());
// 输出: 2023-11-05T01:30:00-05:00[America/New_York]
// 注意:时间仍然是 01:30,但时区偏移变了,因为时钟倒退了!
// 实际上,从 Instant 的角度看,时间是递增的:
console.log('Pre-DST Instant:', preDST.toInstant().toString()); // ...T05:30:00Z
console.log('One hour later Instant:', oneHourLater.toInstant().toString()); // ...T06:30:00Z

// 如果想得到下一个实际的小时,需要考虑实际时间流逝
const twoHoursDuration = Temporal.Duration.from({ hours: 2 });
const twoHoursLater = preDST.add(twoHoursDuration);
console.log('Two hours later (crossing DST):', twoHoursLater.toString());
// 输出: 2023-11-05T02:30:00-05:00[America/New_York]
// 这里 ZonedDateTime 自动计算了实际流逝的两个小时,并正确处理了夏令时回退。

ZonedDateTime的加减运算是基于日历语义的,它会正确地计算出经历的时钟时间,而不是简单的 UTC 毫秒数加减。这正是我们对日期时间计算所期望的行为。

时区转换:

在不同的时区之间转换 ZonedDateTime 变得非常简单和明确。

const nyTime = Temporal.ZonedDateTime.from('2023-10-27T10:00:00-04:00[America/New_York]');

// 转换为东京时区
const tokyoTime = nyTime.withTimeZone('Asia/Tokyo');
console.log(tokyoTime.toString()); // 2023-10-27T23:00:00+09:00[Asia/Tokyo]
console.log(tokyoTime.toInstant().equals(nyTime.toInstant())); // true (表示的是同一个宇宙时间点)

// 转换为上海时区
const shanghaiTime = nyTime.withTimeZone('Asia/Shanghai');
console.log(shanghaiTime.toString()); // 2023-10-27T22:00:00+08:00[Asia/Shanghai]

3. Temporal.PlainDateTime:无时区的日期时间

PlainDateTime 表示一个不带时区信息的日期和时间,例如“2023年10月27日10:00 AM”。它没有一个固定的 UTC 偏移量,因此无法直接转换为 Instant。它的主要用途是描述重复的事件,或者用户在不知道具体时区时输入的日期时间。

创建 PlainDateTime

// 从组件创建
const pdt = Temporal.PlainDateTime.from({
    year: 2023,
    month: 10,
    day: 27,
    hour: 10,
    minute: 30
});
console.log(pdt.toString()); // 2023-10-27T10:30:00

// 从 ISO 8601 字符串创建 (不能带时区信息或 'Z')
const pdtString = Temporal.PlainDateTime.from('2023-10-27T10:30:00');
console.log(pdtString.toString()); // 2023-10-27T10:30:00

PlainDateTime 与时区的结合:

要将 PlainDateTime 转换为 ZonedDateTimeInstant,你必须显式地提供一个 TimeZone

const pdt = Temporal.PlainDateTime.from('2023-10-27T10:30:00');
const nyTimeZone = Temporal.TimeZone.from('America/New_York');

// 转换为 ZonedDateTime
const zonedFromPlain = pdt.toZonedDateTime(nyTimeZone);
console.log(zonedFromPlain.toString()); // 2023-10-27T10:30:00-04:00[America/New_York]

// 转换为 Instant (需要先转换为 ZonedDateTime)
console.log(zonedFromPlain.toInstant().toString()); // 2023-10-27T14:30:00Z

PlainDateTime 的加减运算:

PlainDateTime 的加减运算是基于日历的,它会考虑闰年和不同月份的天数,但不会考虑夏令时(因为它没有时区)。

const pdt = Temporal.PlainDateTime.from('2023-01-31T10:00:00');

// 增加一个月 (自动处理了月份天数不同)
const nextMonth = pdt.add({ months: 1 });
console.log(nextMonth.toString()); // 2023-02-28T10:00:00 (因为2月只有28天)

// 增加一年 (自动处理闰年)
const leapYearPdt = Temporal.PlainDateTime.from('2024-02-29T10:00:00'); // 2024 是闰年
const nextYear = leapYearPdt.add({ years: 1 });
console.log(nextYear.toString()); // 2025-02-28T10:00:00 (2025 不是闰年)

4. Temporal.PlainDateTemporal.PlainTime:纯日期与纯时间

这两个类分别用于表示不带时区的纯日期(如生日)和纯时间(如闹钟设置)。它们不能直接转换为 Instant

Temporal.PlainDate

const today = Temporal.PlainDate.from('2023-10-27');
console.log(today.toString()); // 2023-10-27

const nextWeek = today.add({ weeks: 1 });
console.log(nextWeek.toString()); // 2023-11-03

// 与 PlainTime 结合
const appointmentTime = Temporal.PlainTime.from('09:30:00');
const appointmentDateTime = today.toPlainDateTime(appointmentTime);
console.log(appointmentDateTime.toString()); // 2023-10-27T09:30:00

Temporal.PlainTime

const morning = Temporal.PlainTime.from('08:00');
console.log(morning.toString()); // 08:00:00

const later = morning.add({ hours: 2, minutes: 15 });
console.log(later.toString()); // 10:15:00

// 比较时间
console.log(morning.before(later)); // true

5. Temporal.PlainYearMonthTemporal.PlainMonthDay:特定组合

这些是更专业的类型,用于表示仅包含年/月或月/日的概念。

Temporal.PlainYearMonth

const expiry = Temporal.PlainYearMonth.from('2025-12');
console.log(expiry.toString()); // 2025-12

const nextMonth = expiry.add({ months: 1 });
console.log(nextMonth.toString()); // 2026-01

Temporal.PlainMonthDay

const birthday = Temporal.PlainMonthDay.from('03-15');
console.log(birthday.toString()); // 03-15

// 需要提供年份来转换为 PlainDate
const thisYearsBirthday = birthday.toPlainDate({ year: 2023 });
console.log(thisYearsBirthday.toString()); // 2023-03-15

6. Temporal.Duration:时间长度

Duration是表示一段时间长度的对象,例如“2小时30分钟”。它是进行日期时间加减运算的关键。

创建 Duration

const twoHoursAndThirtyMinutes = Temporal.Duration.from({ hours: 2, minutes: 30 });
console.log(twoHoursAndThirtyMinutes.toString()); // PT2H30M

const oneYearTwoMonths = Temporal.Duration.from({ years: 1, months: 2 });
console.log(oneYearTwoMonths.toString()); // P1Y2M

Duration 的运算:

Duration 可以相互加减,也可以与 InstantZonedDateTimePlainDateTime 等进行加减。

const d1 = Temporal.Duration.from({ hours: 1 });
const d2 = Temporal.Duration.from({ minutes: 30 });

const totalDuration = d1.add(d2);
console.log(totalDuration.toString()); // PT1H30M

const zdt = Temporal.ZonedDateTime.from('2023-10-27T10:00:00-04:00[America/New_York]');
const futureZdt = zdt.add(totalDuration);
console.log(futureZdt.toString()); // 2023-10-27T11:30:00-04:00[America/New_York]

7. Temporal.TimeZoneTemporal.Calendar:时区与日历封装

TimeZone 对象封装了特定时区的规则(如 DST 转换日期)。Calendar 对象封装了日历系统(如 ISO 8601 或 Japanese)。它们通常作为参数传递给其他 Temporal 对象的构造函数或方法。

Temporal.TimeZone

const ny = Temporal.TimeZone.from('America/New_York');
console.log(ny.id); // America/New_York

// 获取特定 Instant 的 UTC 偏移量
const instant = Temporal.Instant.from('2023-10-27T10:00:00Z');
console.log(ny.getOffsetNanosecondsFor(instant)); // -14400000000000 (纳秒,-4小时)

// 获取当前系统的默认时区
console.log(Temporal.TimeZone.from(Intl.DateTimeFormat().resolvedOptions().timeZone).id);

Temporal.Calendar

const isoCalendar = Temporal.Calendar.from('iso8601');
console.log(isoCalendar.id); // iso8601

// 创建一个使用日本日历的 PlainDate (目前大多数应用仍使用 ISO 8601)
const japaneseDate = Temporal.PlainDate.from({ year: 2023, month: 10, day: 27, calendar: 'japanese' });
console.log(japaneseDate.toString()); // 2023-10-27[japanese]

解决 Date 痛点:Temporal 的实践

现在,让我们回过头来看看 Date 对象的那些痛点,并看看 Temporal 是如何优雅地解决它们的。

1. 告别可变性:拥抱不可变性

Temporal 对象都是不可变的。这意味着你可以放心地传递它们,而不必担心它们在其他地方被意外修改。

let pdt1 = Temporal.PlainDateTime.from('2023-10-27T10:00:00');
let pdt2 = pdt1; // pdt2 和 pdt1 指向同一个对象引用,但对象内容不可变

pdt2 = pdt2.add({ hours: 2 }); // add 方法返回一个新对象,赋给 pdt2

console.log(pdt1.toString()); // 2023-10-27T10:00:00 (pdt1 未被修改)
console.log(pdt2.toString()); // 2023-10-27T12:00:00

这种设计大大简化了状态管理和并发编程。

2. 清晰的时区处理与夏令时管理

ZonedDateTime 彻底解决了时区和夏令时的困扰。通过显式地绑定时区,你可以精确地知道日期时间在特定地理位置的含义,并且所有运算都会自动遵循该时区的规则。

DST 结束时的重复时间问题:

Date 对象中,new Date('2023-11-05T01:30:00-04:00').setHours(new Date('2023-11-05T01:30:00-04:00').getHours() + 1) 可能会在 DST 结束时产生意想不到的结果。使用 ZonedDateTime 则非常清晰:

const nyTimeZone = Temporal.TimeZone.from('America/New_York');
const preDST = Temporal.ZonedDateTime.from('2023-11-05T01:30:00-04:00[America/New_York]'); // EDT

// 增加 1 小时
const oneHourLater = preDST.add({ hours: 1 });
console.log(oneHourLater.toString()); // 2023-11-05T01:30:00-05:00[America/New_York] (EST)
// 我们可以看到,本地时间仍然是 01:30,但 UTC 偏移量变了,因为时钟回拨了。
// ZonedDateTime 准确反映了实际的“墙上时间”流逝。

// 如果我们想获取下一个不同的“墙上时间”点,例如 02:00
const twoHoursLater = preDST.add({ hours: 2 });
console.log(twoHoursLater.toString()); // 2023-11-05T02:30:00-05:00[America/New_York]

ZonedDateTimeadd() 方法执行的是日历时间算术,而不是简单的线性时间戳加减。这意味着它会考虑所有时区规则,包括夏令时。

3. 语义化的日期时间运算

Duration 对象和各种 Temporal 类型的 add() / subtract() 方法提供了强大且语义化的日期时间运算。

日期加减:

const startDate = Temporal.PlainDate.from('2023-01-31');
const threeMonthsLater = startDate.add({ months: 3 });
console.log(threeMonthsLater.toString()); // 2023-04-30 (正确处理了2月和3月的天数)

const d = Temporal.Duration.from({ days: 10, hours: 5 });
const newDate = startDate.add(d);
console.log(newDate.toString()); // 2023-02-10 (PlainDate 忽略了小时部分)

时间差计算:

const start = Temporal.PlainDateTime.from('2023-10-27T10:00:00');
const end = Temporal.PlainDateTime.from('2023-10-28T14:30:00');

const diff = start.until(end);
console.log(diff.toString()); // PT1DT4H30M (1天4小时30分钟)
console.log(diff.total({ unit: 'hours' })); // 28.5 (总小时数)

4. 可靠的解析与格式化

Temporal 提供了严格的 ISO 8601 格式解析。对于自定义格式,你可以结合 Intl.DateTimeFormat 进行本地化格式化。

解析:

Temporal 对象的 from() 静态方法提供了强大的解析功能,但要求输入字符串严格符合 ISO 8601 规范。

const pdt = Temporal.PlainDateTime.from('2023-10-27T10:00:00'); // 成功
// const invalidPdt = Temporal.PlainDateTime.from('October 27, 2023 10 AM'); // 抛出错误

格式化:

所有 Temporal 对象都提供 toString() 方法返回 ISO 8601 格式的字符串。对于用户友好的格式,则建议使用 toLocaleString() 方法,它与 Intl.DateTimeFormat 集成。

const zdt = Temporal.ZonedDateTime.from('2023-10-27T10:00:00-04:00[America/New_York]');

console.log(zdt.toString()); // 2023-10-27T10:00:00-04:00[America/New_York]

// 本地化格式化
console.log(zdt.toLocaleString('en-US', {
    dateStyle: 'full',
    timeStyle: 'long',
    timeZoneName: 'shortOffset'
}));
// 示例: Friday, October 27, 2023 at 10:00:00 AM GMT-4

console.log(zdt.toLocaleString('zh-CN', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    hour: '2-digit',
    minute: '2-digit',
    timeZoneName: 'long'
}));
// 示例: 2023年10月27日 上午10:00 美东时间

5. 清晰区分时间概念

通过引入 Instant, ZonedDateTime, PlainDateTime, PlainDate, PlainTime 等多种类型,Temporal 强制开发者在代码中明确地表达其业务语义。

概念 Date 对象 Temporal API
精确时间点 内部是 UTC 毫秒,但 API 默认本地化解释 Temporal.Instant (总是 UTC)
带时区的时间 依赖本地时区或手动计算偏移量 Temporal.ZonedDateTime (明确绑定时区)
不带时区时间 不支持,所有操作都会隐式关联一个时区 Temporal.PlainDateTime, Temporal.PlainDate, Temporal.PlainTime
时间长度 需手动转换为毫秒进行计算 Temporal.Duration (语义化加减)
日期计算 setMonth, setDate 等可变方法,DST 影响 add, subtract 返回新对象,考虑日历规则,ZonedDateTime 处理 DST
时区转换 需手动计算偏移,易出错,且不处理 DST 历史规则 withTimeZone 方法,基于 IANA 时区数据库,完全支持 DST 历史规则

Temporal 与 Date 对象的互操作性

在过渡期间,我们需要在 Temporal 和 Date 之间进行转换。Temporal 提供了方便的方法来实现这一点。

Date 到 Temporal:

Date 对象可以转换为 Temporal.Instant,因为 Date 内部存储的就是 UTC 毫秒数。

const oldDate = new Date('2023-10-27T10:00:00Z');
const temporalInstant = Temporal.Instant.from(oldDate);
console.log(temporalInstant.toString()); // 2023-10-27T10:00:00Z

// 然后可以转换为 ZonedDateTime
const systemTimeZone = Temporal.TimeZone.from(Intl.DateTimeFormat().resolvedOptions().timeZone);
const temporalZdt = temporalInstant.toZonedDateTime(systemTimeZone);
console.log(temporalZdt.toString()); // 2023-10-27T18:00:00+08:00[Asia/Shanghai] (假设系统时区是上海)

从 Temporal 到 Date

Temporal.Instant 可以转换为 Date 对象。

const temporalInstant = Temporal.Instant.from('2023-10-27T10:00:00Z');
const newDate = temporalInstant.toDate();
console.log(newDate.toISOString()); // 2023-10-27T10:00:00.000Z

// ZonedDateTime 也可以先转换为 Instant 再转 Date
const zdt = Temporal.ZonedDateTime.from('2023-10-27T18:00:00+08:00[Asia/Shanghai]');
const dateFromZdt = zdt.toInstant().toDate();
console.log(dateFromZdt.toISOString()); // 2023-10-27T10:00:00.000Z

请注意,Date 对象总是基于 UTC 毫秒数,所以从 ZonedDateTime 转换为 Date 会丢失 ZonedDateTime 中携带的时区信息,Date 对象会根据当前环境的本地时区来 解释 这个 UTC 时间点。

Temporal 的现状与未来

Temporal API 目前仍处于 TC39 提案的 Stage 3 阶段,这意味着其设计已经相对稳定,并正在等待各大 JavaScript 引擎的实现和广泛测试。虽然它尚未在所有主流浏览器和 Node.js 版本中原生支持,但已经有 polyfill 可用,允许开发者提前体验和使用。

Polyfill:

你可以使用 @js-temporal/polyfill 包来在当前环境中使用 Temporal API:

npm install @js-temporal/polyfill
// 在你的应用入口处导入 polyfill
import '@js-temporal/polyfill';

// 现在你可以使用 Temporal API 了
const now = Temporal.Instant.now();
console.log(now.toString());

随着提案的最终定稿和原生支持的普及,Temporal API 将成为 JavaScript 日期时间处理的黄金标准,彻底取代 Date 对象及其相关的第三方库。

结语

Temporal API 代表了 JavaScript 在日期时间处理领域的一次重大飞跃。它以其不可变性、显式性、清晰的关注点分离和对时区与夏令时的全面支持,为开发者提供了一个健壮、直观且可预测的工具集。掌握 Temporal,不仅能帮助我们解决当前 Date 对象带来的诸多痛点,更能提升我们构建复杂日期时间应用的信心和效率。随着它的普及,我们期待一个更加清晰、少陷阱的 JavaScript 日期时间编程未来。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注