找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1788

积分

0

好友

241

主题
发表于 2025-12-30 05:46:24 | 查看: 19| 回复: 0

Temporal API 的核心设计理念基于对“挂钟时间”(Clock Time)与“精确时间”(Exact Time,又称 UTC 时间)的严格区分。挂钟时间受地方政策影响,可能发生突变。

例如,当夏令时(Daylight Saving Time,DST)开始或一个地区变更时区时,当地的挂钟时间会立即调整。而精确时间则拥有全球统一的定义,由 UTC(协调世界时)这一特殊时区表示。

协调世界时(Coordinated Universal Time, UTC):是世界时间和频率计量的基准,接近本初子午线(0°经线)的平太阳时,不进行夏令时调整,它是格林威治标准时间(GMT)的后继者。

挂钟时间使用 UTC 偏移量来定义,即特定地点的时钟时间比 UTC 提前或延后的具体时长。例如,2020年1月19日,美国加利福尼亚州的 UTC 偏移量为 -08:00,这意味着当旧金山当地时间为上午10:00时,UTC 时间是当天18:00。

ISO 8601 和 RFC 3339 标准定义了精确时间的表示格式,例如 2020-09-06T17:35:24.485Z,后缀 Z 即表示这是一个 UTC 时间。

RFC 3339 与 ISO 8601 的区别:最显著的一点是,RFC 3339 要求完整的日期和时间表示(仅小数秒可选),要求年份为4位数,并且只允许使用句点字符作为小数秒的小数点。

Temporal 提供了两种存储精确时间的类型:

  • Temporal.Instant:仅存储精确时间,不包含其他信息。
  • Temporal.ZonedDateTime:存储精确时间、时区以及日历系统。

另一种表示精确时间的常用方法是 Unix 时间戳,它表示自 Unix 纪元(1970年1月1日 UTC 午夜)以来经过的秒数。它是一个整数(可正可负),广泛用于计算机系统。例如,Temporal.Instant 可以通过一个表示自纪元以来纳秒数的 BigInt 值来构造。

图1:UNIX 纪元时间起点示意图

开发者也常遇到“时间戳”这个术语,它通常指代用自 Unix 纪元以来的秒数表示的精确时间。但由于该术语存在历史歧义,Temporal 规范已避免使用它。例如,不同数据库中的 TIMESTAMP 类型含义各不相同:

  • MySQL 中,它是一个精确时间。
  • Oracle 数据库 中,它是自1970年1月1日(挂钟时间)以来的秒数。
  • Microsoft SQL Server 中,它是一个与日期时间无关的单调递增值。

Unix 时间戳基于 UTC,因此不受时区影响,便于跨地区系统进行时间比较和计算。

2. 时区、偏移量变化与夏令时

SDT(Standard Time)表示标准时间,DST(Daylight Saving Time)表示夏令时。中国未实施夏令时。

时区定义了一套规则,规定了本地挂钟时间如何与 UTC 时间相关联。你可以将时区视为一个函数:它接收一个精确时间,返回对应的 UTC 偏移量,反之亦然。

Temporal 采用 IANA 时区数据库作为全球时区规则的存储库。每个 IANA 时区包含:

  • 时区 ID:通常以代表性城市锚定的地理区域命名(例如 Europe/Paris, Asia/Shanghai),也可以是单偏移时区,如 UTC(固定偏移 +00:00)或 Etc/GMT+5(偏移 -05:00)。
  • 时区定义:一组自1970年1月1日以来,将 UTC 日期时间映射到特定偏移量的规则。在某些时区,由于春季开始、秋季结束的夏令时,每年会发生两次临时偏移变化。偏移量也可能因政治决策(如国家变更时区)而发生永久性改变。

IANA 时区数据库每年更新数次以响应全球各地的政治变化。这些更新通常只影响未来的日期时间,但有时也会修正历史数据,例如发现了20世纪早期计时的新史料时。

图2:IANA时区数据库部分条目示例

3. Temporal 中的挂钟时间、精确时间与时区

Temporal 对象体系包含以下关键类型:

  • Temporal.Instant:表示精确时间。
  • Temporal.PlainDateTime:表示日历日期和挂钟时间。同系列的还有 Temporal.PlainDateTemporal.PlainTime 等。这些类型都关联一个日历系统,默认为 “iso8601”(ISO 8601 日历),也可被 “islamic” 或 “japanese” 等其他日历覆盖。
  • Temporal.TimeZone:时区函数,用于在精确时间和挂钟时间之间进行转换,也包含获取特定精确时间当前偏移量等辅助方法。
  • Temporal.ZonedDateTime:封装了上述所有概念:一个精确时间(Temporal.Instant)、其对应的挂钟时间(Temporal.PlainDateTime)以及连接两者的时区(Temporal.TimeZone)。

若要从存储精确时间的 Temporal 类型获取人类可读的日期和时间,有两种方法:

  1. 如果精确时间由 Temporal.ZonedDateTime 表示,可以直接使用其属性(如 .year, .hour)或方法(如 .toLocaleString())获取挂钟时间值。
  2. 如果精确时间由 Temporal.Instant 表示,则需要使用时区(和可选的日历)来创建一个 Temporal.ZonedDateTime

例如:

instant = Temporal.Instant.from('2019-09-03T08:34:05Z');
formatOptions = {
  era: 'short',
  year: 'numeric',
  month: 'short',
  day: 'numeric',
  hour: 'numeric',
  minute: 'numeric',
  second: 'numeric'
};
zdt = instant.toZonedDateTimeISO('Asia/Tokyo');
  // => 2019-09-03T17:34:05+09:00[Asia/Tokyo]
zdt.toLocaleString('en-us', { ...formatOptions, calendar: zdt.calendar});
  // => 'Sep 3, 2019 AD, 5:34:05 PM'
zdt.year;
  // => 2019
zdt = instant.toZonedDateTime({timeZone: 'Asia/Tokyo', calendar: 'iso8601'}).toLocaleString('ja-jp', formatOptions);
  // => '西暦 2019 年 9 月 3 日 17:34:05'
zdt = instant.toZonedDateTime({timeZone: 'Asia/Tokyo', calendar: 'japanese'});
  // => 2019-09-03T17:34:05+09:00[Asia/Tokyo][u-ca=japanese]
zdt.toLocaleString('en-us', { ...formatOptions, calendar: zdt.calendar});
  // => 'Sep 3, 1 Reiwa, 5:34:05 PM'
zdt.eraYear;
  // => 1

同样,也支持从日历日期和/或挂钟时间到精确时间的转换:

// 通过提供时区,将各种本地时间类型转换为精确时间类型
date = Temporal.PlainDate.from('2019-12-17');
// 如果省略时间,则默认为当天的开始时间(00:00)
zdt = date.toZonedDateTime('Asia/Tokyo');
  // => 2019-12-17T00:00:00+09:00[Asia/Tokyo]
zdt = date.toZonedDateTime({timeZone: 'Asia/Tokyo', plainTime: '10:00'});
  // => 2019-12-17T10:00:00+09:00[Asia/Tokyo]

time = Temporal.PlainTime.from('14:35');
zdt = time.toZonedDateTime({timeZone: 'Asia/Tokyo', plainDate: Temporal.PlainDate.from('2020-08-27') });
  // => 2020-08-27T14:35:00+09:00[Asia/Tokyo]

dateTime = Temporal.PlainDateTime.from('2019-12-17T07:48');
zdt = dateTime.toZonedDateTime('Asia/Tokyo');
  // => 2019-12-17T07:48:00+09:00[Asia/Tokyo]

// 获取自 UNIX 纪元以来的精确时间(秒、毫秒、纳秒)
inst = zdt.toInstant();
epochNano = inst.epochNanoseconds;
  // => 1576536480000000000n
epochMilli = inst.epochMilliseconds;
  // => 1576536480000
epochSecs = inst.epochSeconds;
  // => 1576536480

4. 导致挂钟时间不准确的几个原因

4.1 时区偏移突变使时钟时间存在二义性

时区定义提供了本地日期时间与对应 UTC 时间之间双向的一一映射关系。UTC 偏移量(Offset)是 UTC 与特定地点时间之间的小时和分钟差。通常,英国以东为 UTC+,以西为 UTC-。

图3:世界主要城市时区示意图

然而,在时区偏移发生变化的时刻附近,可能会产生时间模糊性,即不清楚应使用哪个偏移量将挂钟时间转换为精确时间。这可能导致一个挂钟时间对应两个可能的 UTC 时间。

  • 当偏移量向后变化(例如结束夏令时),相同的挂钟时间会重复出现。
    例如:2018年11月4日,美国加利福尼亚州的凌晨1:30发生了两次。当天的“第一个”凌晨1:30处于太平洋夏令时(偏移 -07:00)。30分钟后,夏令时结束,太平洋标准时间(偏移 -08:00)开始。又过了30分钟,“第二个”凌晨1:30出现。因此,“2018年11月4日星期日1:30AM”这个表述本身存在歧义。

  • 当偏移量向前变化(例如开始夏令时),一段本地挂钟时间会被跳过。
    例如:加利福尼亚州于2018年3月11日开始夏令时。当时钟从凌晨1:59走到2:00时,立即跳到了凌晨3:00,也就是说,凌晨2:00到2:59之间的任何时间从未真正发生!为了避免错误,大多数计算环境(包括 ECMAScript)在转换被跳过的挂钟时间时,会使用转换前或转换后的偏移量。

图4:2021年夏令时开始与结束示意图

在这两种情况下,将本地时间转换为精确时间时,需要解决歧义:选择使用两个可能偏移量中的哪一个,或者直接抛出异常。

在 Temporal 中,如果已知精确时间或时区偏移,则不可能存在歧义。例如:

// 不可能有歧义,因为源是精确的 UTC 时间
inst = Temporal.Instant.from('2020-09-06T17:35:24.485Z');
  // => 2020-09-06T17:35:24.485Z

// 已知偏移量可以使本地时间“精确”而无歧义
inst = Temporal.Instant.from('2020-09-06T10:35:24.485-07:00');
  // => 2020-09-06T17:35:24.485Z

zdt = Temporal.ZonedDateTime.from('2020-09-06T10:35:24.485-07:00[America/Los_Angeles]');
  // => 2020-09-06T10:35:24.485-07:00[America/Los_Angeles]

// 如果源是精确的 Temporal 对象,也不可能出现歧义
zdt = inst.toZonedDateTimeISO('America/Los_Angeles');
  // => 2020-09-06T10:35:24.485-07:00[America/Los_Angeles]
inst2 = zdt.toInstant();
  // => 2020-09-06T17:35:24.485Z

但是,从非精确源(如 Temporal.PlainDateTime)创建精确时间类型时,就可能出现歧义:

// 偏移量未知,可能产生歧义
zdt = Temporal.PlainDate.from('2019-02-19').toZonedDateTime('America/Sao_Paulo'); // can be ambiguous
zdt = Temporal.PlainDateTime.from('2019-02-19T00:00').toZonedDateTime('America/Sao_Paulo'); // can be ambiguous

// 即使源字符串包含偏移量,如果解析的类型(如 PlainDateTime)不是精确类型,偏移量也会被忽略,因此仍可能歧义
dt = Temporal.PlainDateTime.from('2019-02-19T00:00-03:00');
zdt = dt.toZonedDateTime('America/Sao_Paulo'); // can be ambiguous

// 从精确类型转换为非精确类型时,偏移量信息会丢失
zdt = Temporal.ZonedDateTime.from('2020-11-01T01:30-08:00[America/Los_Angeles]');
  // => 2020-11-01T01:30:00-08:00[America/Los_Angeles]
dt = zdt.toPlainDateTime(); // offset is lost!
  // => 2020-11-01T01:30:00
zdtAmbiguous = dt.toZonedDateTime('America/Los_Angeles'); // can be ambiguous
  // => 2020-11-01T01:30:00-07:00[America/Los_Angeles]
// 注意,现在偏移量是 -07:00(太平洋夏令时),即“第一个”凌晨1:30,而非原始时间的-08:00(太平洋标准时间)。

为了解决这种可能的歧义,从非精确源创建精确时间类型的方法(如 Temporal.ZonedDateTime.from)接受一个 disambiguation 选项,用于控制在歧义情况下的行为:

  • 'compatible':向后转换时类似 'earlier',向前转换时类似 'later'。这是默认值,与旧版 Date 对象以及 moment.js、Luxon 等库的行为匹配,也符合 RFC 5545 (iCalendar) 等标准。
  • 'earlier':返回两个可能精确时间中较早的一个。
  • 'later':返回两个可能精确时间中较晚的一个。
  • 'reject':抛出 RangeError 异常。

4.2 夏令时修改偏移导致时间二义性

进入夏令时时,时钟“向前”拨快一小时。实际上移动的不是时间本身,而是偏移量。偏移量向前移动造成了“一小时消失”的错觉。观察数字时钟,它会从 1:58 -> 1:59 -> 3:00。

当考虑偏移量时,更容易理解实际情况:在 1:59:59 到 3:00:00 之间的任何挂钟时间都从未真正发生。

// 春季 DST 开始时,在“跳过”的时钟小时内,不同消歧模式的行为
// 偏移量 -07:00 是夏令时,-08:00 是标准时间
props = {timeZone: 'America/Los_Angeles', year: 2020, month: 3, day: 8, hour: 2, minute: 30};
zdt = Temporal.ZonedDateTime.from(props, { disambiguation: 'compatible'});
  // => 2020-03-08T03:30:00-07:00[America/Los_Angeles]
zdt = Temporal.ZonedDateTime.from(props); // 'compatible' 是默认值
  // => 2020-03-08T03:30:00-07:00[America/Los_Angeles]

earlier = Temporal.ZonedDateTime.from(props, { disambiguation: 'earlier'});
  // => 2020-03-08T01:30:00-08:00[America/Los_Angeles] (1:30 挂钟时间;仍在标准时间)
later = Temporal.ZonedDateTime.from(props, { disambiguation: 'later'});
  // => 2020-03-08T03:30:00-07:00[America/Los_Angeles] ('later' 和 'compatible' 在向前转换时一致)

later.toPlainDateTime().since(earlier.toPlainDateTime());
  // => PT2H (挂钟时间相差2小时...)
later.since(earlier);
  // => PT1H (但实际时间只过了1小时)

同样,在夏令时结束时,时钟“向后”拨慢一小时,造成“一小时重复”的错觉。在 'earlier' 模式下,返回的是重复挂钟时间的较早实例;在 'later' 模式下,返回的是较晚实例。'compatible' 模式与 'earlier' 模式行为一致,这与旧版 JavaScript Date 对象的行为相匹配。

// 秋季 DST 结束时,在“重复”的时钟小时内,不同消歧模式的行为
props = {timeZone: 'America/Los_Angeles', year: 2020, month: 11, day: 1, hour: 1, minute: 30};
zdt = Temporal.ZonedDateTime.from(props, { disambiguation: 'compatible'});
  // => 2020-11-01T01:30:00-07:00[America/Los_Angeles]
earlier = Temporal.ZonedDateTime.from(props, { disambiguation: 'earlier'});
  // => 2020-11-01T01:30:00-07:00[America/Los_Angeles] (向后转换时,'earlier' 与 'compatible' 相同)
later = Temporal.ZonedDateTime.from(props, { disambiguation: 'later'});
  // => 2020-11-01T01:30:00-08:00[America/Los_Angeles] (同样的挂钟时间,但实际晚了1小时,-08:00 表示标准时间)

later.toPlainDateTime().since(earlier.toPlainDateTime());
  // => PT0S (相同的挂钟时间...)
later.since(earlier);
  // => PT1H (但实际时间晚了1小时)

4.3 时区永久更改导致的日期二义性

时区定义可能会发生更改,这些变化通常只影响未来,因此不会波及历史数据。但计算机系统常常需要存储未来的数据,例如日历应用中的未来事件提醒。

当未来时间的日期时间数据与偏移量、时区信息一同存储后,如果时区定义发生更改,新的定义可能与先前存储的数据发生冲突。此时,可以使用 Temporal.ZonedDateTime.from 方法的 offset 选项来解决冲突:

  • 'use':即使提供的偏移量在当前时区规则下无效,也强制使用它。这能保持精确时间(UTC)不变,但会导致当地时间与最初存储的不同。
  • 'ignore':完全忽略输入中提供的偏移量,始终根据当前时区规则重新计算偏移量。这能保证当地时间不变,但可能导致精确时间(UTC)发生变化。
  • 'prefer':如果提供的偏移量对此时区有效则使用它;如果无效,则重新计算。
  • 'reject':如果提供的偏移量对此时区中的日期时间无效,则抛出 RangeError这是默认值,因为没有一种解决方案是绝对正确的,开发者需要根据实际情况决定如何处理无效数据。

注意,偏移量与时区的冲突仅对 Temporal.ZonedDateTime 重要,因为其他时间类型(如 Temporal.Instant.from)在解析时会忽略输入中的时区信息,只使用偏移量。

5. 时区偏移选项应用示例

offset 选项可用于解析在时区定义更改之前保存的日期时间值。例如,巴西于2019年宣布永久停止使用夏令时,并于2019年2月16日最后一次结束夏令时。

假设一个在2018年运行的应用以包含偏移量和 IANA 时区的字符串格式保存了一个未来时间(Temporal.ZonedDateTime.prototype.toString 或类似 Java 的 ZonedDateTime 使用此格式)。假设存储的是圣保罗2020年1月15日中午:

zdt = Temporal.ZonedDateTime.from({year: 2020, month: 1, day: 15, hour: 12, timeZone: 'America/Sao_Paulo'});
zdt.toString();
  // => 2020-01-15T12:00:00-02:00[America/Sao_Paulo]
// 假设此字符串被保存到外部数据库,偏移量“-02:00”对应当时的夏令时。
// 注意:如果在今天(规则更改后)运行此代码,会返回偏移量 `-03:00`。
// 但在2018年运行时,会返回 `-02:00`,对应巴西当时的夏令时规则。

该字符串在2018年保存时有效。但在2019年4月时区规则更改后,2020-01-15T12:00-02:00[America/Sao_Paulo] 不再有效,因为当前正确的偏移量是 -03:00。使用当前规则解析此字符串时,Temporal 需要通过 offset 选项知道如何处理。

savedUsingOldTzDefinition = '2020-01-01T12:00-02:00[America/Sao_Paulo]'; // 以前存储的字符串

/* 错误 */ zdt = Temporal.ZonedDateTime.from(savedUsingOldTzDefinition);
  // 报错 => RangeError: Offset is invalid for '2020-01-01T12:00' in 'America/Sao_Paulo'. Provided: -02:00, expected: -03:00.
  // 默认行为 'reject' 会在冲突时抛出异常。

zdt = Temporal.ZonedDateTime.from(savedUsingOldTzDefinition, { offset: 'use'});
  // => 2020-01-01T11:00:00-03:00[America/Sao_Paulo]
  // 使用旧的偏移量解析,保证了UTC时间一致,但当地时间变为11:00。

zdt = Temporal.ZonedDateTime.from(savedUsingOldTzDefinition, { offset: 'ignore'});
  // => 2020-01-01T12:00:00-03:00[America/Sao_Paulo]
  // 忽略保存的偏移量,使用当前时区规则计算,保证了当地时间不变(仍是12:00)。

zdt = Temporal.ZonedDateTime.from(savedUsingOldTzDefinition, { offset: 'prefer'});
  // => 2020-01-01T12:00:00-03:00[America/Sao_Paulo]
  // 保存的偏移量在当前规则下无效,因此退而使用当前时区规则重新计算。

6. 关于 Temporal 的几个疑问

6.1 UTC 偏移量和时区的区别

UTC 偏移量和时区是相关但用途不同的概念。
图5:东部标准时间(EST)信息图

时区可以定义为使用相同标准时间的区域,而 UTC 偏移量是定义和表达每个时区内当地时间的具体方式。

6.2 夏令时期间时区会调整

UTC 全年保持不变,不受夏令时影响。但一些国家和地区在夏令时期间会切换到不同的时区。例如,纽约在夏令时期间使用东部夏令时(EDT,UTC-4);秋季恢复标准时间后,使用东部标准时间(EST,UTC-5)。

图6:世界时区表示例

尽管大多数时区与 UTC 的偏移是整小时,但也有少数存在30分钟或45分钟的偏移。这就是为什么 UTC 本身基于24小时制,但全球的 UTC 偏移量枚举却可能超过24种。

参考资料

想深入探讨更多关于 ECMAScript 标准和现代化 JavaScript 开发的最佳实践,欢迎访问 云栈社区 与其他开发者交流。在处理复杂的日期时间转换逻辑时,理解这些底层机制至关重要。




上一篇:基于nRF5340与Zephyr RTOS:实现NFC一键拉起网易云音乐播放
下一篇:小米17 Ultra发布:首款搭载徕卡可乐标与光学变焦的影像旗舰
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-10 18:36 , Processed in 0.310447 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表