问题
在即将到来的ECMAScript变更中,我最喜欢的是Temporal提案。这个提案非常先进,我们甚至已经可以通过FullCalendar团队提供的polyfill来使用这个API。
这个API非常了不起,我可能会专门写几篇博客文章来突出它的主要特点。然而,在这篇文章中,我将专注于解释它的一个主要优势:我们终于有一个原生对象来表示“带时区的日期时间”。
但是…什么是“带时区的日期时间”?
人类日期与JS日期
当我们谈论人类日期时,我们通常会说,“我有一个在2024年8月4日上午10:30的医生预约,”但我们省略了时区。这种省略是有意义的,因为通常,我们的对话者了解我们,并且理解当我谈论日期时,我通常是在我的时区,欧洲/马德里的背景下进行的。
不幸的是,对于计算机来说,情况并非如此。当我们在JavaScript中使用“Date”对象时,我们处理的是纯数字。
如果我们阅读官方规范,它指出:
“一个ECMAScript时间值是一个数字,要么是一个有限的整数数字,代表一个瞬间的时间精度到毫秒,或者是NaN代表没有特定的瞬间”
除了JavaScript中的日期不是UTC而是POSIX,完全忽略了闰秒这个非常重要的事实之外,只有数字的问题在于日期的原始语义丢失了。这是因为给定一个人类日期我们可以获取等效的js日期,但反过来不行。
让我们考虑一个例子:假设我想记录我用卡支付的时刻。许多人可能会倾向于这样做:
const paymentDate = new Date('2024-07-20T10:30:00');
由于我的浏览器位于CET
时区,当我写这个的时候,浏览器只是“计算自EPOX以来给定这个CET瞬间的毫秒数”
这就是我们实际上存储在日期中的:
paymentDate.getTime();
// 1721464200000
这意味着根据你读取这些信息的方式,你将得到不同的“人类日期”:
如果我们从CET的角度来看,我得到10:30:
d.toLocaleString()
// '20/07/2024, 10:30:00'
如果我们从ISO的角度来看,我们得到8:30:
d.toISOString()
// '2024-07-20T08:30:00.000Z'
许多人认为,通过使用UTC或以ISO格式传递日期,他们是安全的;然而,这并不正确,因为信息仍然丢失。
UTC还不够
即使在使用包括偏移的ISO格式的日期时,下次我们想显示那个日期时,我们只知道自UNIX纪元以来经过的毫秒数和偏移。但这仍然不足以知道人类时刻和时区,其中进行了支付。
严格来说,给定一个时间戳t0
,我们可以获取n
个人类可读的日期来表示它…
换句话说,负责将时间戳转换为人类可读日期的函数不是单射的,因为时间戳集合中的每个元素对应于“人类日期”集合中的多个元素。
这种情况在存储ISO日期时也完全相同,因为时间戳和ISO是同一瞬间的两种表示:
如果你仍然不清楚问题所在,让我用一个例子来说明。想象你住在马德里,然后去悉尼旅行。
几周后,你回到马德里,看到你的交易声明上有一笔你不认识的收费…16号凌晨2点的3.50收费?我在做什么?那天晚上我早早就上床睡觉了!…我不明白。
经过一段时间的担忧,你意识到这笔收费对应于你第二天早上喝的咖啡,因为正如你阅读这篇文章时所推断的,你的银行以UTC存储所有交易,应用程序将它们翻译成手机的时区。
这可能最终成为一个轶事,但如果你的银行应用每天一次免费取款的促销活动呢?那一天从什么时候开始和结束?UTC?澳大利亚?…相信我,事情变得复杂…
到目前为止,我希望我说服了你,只使用时间戳是一个问题,幸运的是,现在有了解决方案。
ZonedDateTime
除了许多其他事情之外,新的Temporal API引入了一个Temporal.ZonedDateTime对象,专门设计用来表示带有相应时区的日期和时间。他们还提出了一个对RFC 3339的扩展,以标准化表示日期的字符串的序列化和反序列化:
例如:
1996-12-19T16:39:57-08:00[America/Los_Angeles]
这个字符串表示1996年12月19日第16个小时的39分钟57秒,与UTC有-08:00的偏移,并且还指定了与之相关的人类时区(“太平洋时间”),以便时区感知的应用程序考虑。
此外,这个API允许使用不同的日历,例如:
- buddhist
- chinese
- coptic
- dangi
- ethioaa
- ethiopic
- gregory
- hebrew
- indian
- islamic
- islamic-umalqura
- islamic-tbla
- islamic-civil
- islamic-rgsa
- japanese
- persian
- roc
在所有这些中,最常见的将是iso8601
(公历的标准改编),你将最频繁地使用它。
基本操作
创建日期
Temporal API在创建日期时提供了显著的优势,特别是它的Temporal.ZonedDateTime对象。一个突出的特点是它能够轻松处理时区,包括那些涉及夏令时(DST)的棘手情况。例如,当你像这样创建一个Temporal.ZonedDateTime对象时:
const zonedDateTime = Temporal.ZonedDateTime.from({
year: 2024, month: 8, day: 16, hour: 12, minute: 30, second: 0, timeZone: 'Europe/Madrid'});
你不仅仅是在设置一个日期和时间;你确保这个日期在指定的时区内被准确表示。这种精确性意味着无论DST变化或任何其他本地时间调整,你的日期将始终反映正确的时刻。
这个特性在安排事件或记录需要在不同地区保持一致的行动时特别强大。通过将时区直接纳入日期创建过程,Temporal消除了使用传统Date对象的常见陷阱,例如由于DST或时区差异导致的意外时间变化。这使得Temporal不仅仅是一种便利,而是现代Web开发中全球时间一致性至关重要的必需品。
如果你好奇为什么这个API很棒,请阅读这篇文章解释如何处理时区定义的变化。
比较日期
ZonedDateTime提供了一个名为compare
的静态方法,给定两个ZonedDateTime对象,一和二,将返回:
−1
如果一小于二0
如果两个实例描述的是完全相同的瞬间,忽略时区和日历1
如果一大于二。
你可以轻松地比较像DST结束后重复的时钟小时这样的不寻常情况,实际世界中较晚的值在时钟时间上可能较早,反之亦然:
const one = Temporal.ZonedDateTime.from('2020-11-01T01:45-07:00[America/Los_Angeles]');
const two = Temporal.ZonedDateTime.from('2020-11-01T01:15-08:00[America/Los_Angeles]');
Temporal.ZonedDateTime.compare(one, two);
// => -1 // (因为`one`在实际世界中更早)
酷的内置功能
ZonedDateTime有一些预计算的属性,这将使您的生活更轻松,例如:
hoursInDay
只读属性hoursInDay
返回在zonedDateTime.timeZone的当前一天(通常是午夜)开始到同一时区的下一个日历天开始之间的实际小时数。
Temporal.ZonedDateTime.from('2020-01-01T12:00-08:00[America/Los_Angeles]').hoursInDay;
// => 24 // (正常一天)Temporal.ZonedDateTime.from('2020-03-08T12:00-07:00[America/Los_Angeles]').hoursInDay;
// => 23 // (DST在这一天开始)Temporal.ZonedDateTime.from('2020-11-01T12:00-08:00[America/Los_Angeles]').hoursInDay;
// => 25 // (DST在这一天结束)
其他酷属性是daysInYear, inLeapYear
转换时区
ZonedDateTimes提供了一个.withTimeZone
方法,允许我们按我们的愿望更改ZonedDateTime:
zdt = Temporal.ZonedDateTime.from('1995-12-07T03:24:30+09:00[Asia/Tokyo]');
zdt.toString(); // => '1995-12-07T03:24:30+09:00[Asia/Tokyo]'
zdt.withTimeZone('Africa/Accra').toString(); // => '1995-12-06T18:24:30+00:00[Africa/Accra]'
基本算术
我们可以使用.add
方法使用日历算术来添加持续时间的日期部分。结果将自动根据实例的timeZone字段的规则调整Daylight Saving Time。
最棒的部分是它支持日历算术或纯持续时间的玩耍。
- 添加或减去天数应该在DST转换期间保持时钟时间一致。例如,如果你在周六下午1:00有预约,你要求将其重新安排1天后,你期望重新安排的预约仍然在下午1:00,即使一夜之间有DST转换。
- 添加或减去持续时间的时间部分应该忽略DST转换。例如,你要求一个朋友在2小时内见面,如果你迟到1小时或3小时,他会不高兴。
- 应该有一致且相对不令人惊讶的操作顺序。如果结果在DST转换附近,模棱两可的情况应该自动(不崩溃)和确定性地处理。
zdt = Temporal.ZonedDateTime.from('2020-03-08T00:00-08:00[America/Los_Angeles]');
// 添加一天以获得DST开始后的第二天午夜
laterDay = zdt.add({ days: 1 });
// => 2020-03-09T00:00:00-07:00[America/Los_Angeles] // 注意新偏移不同,表明结果已调整为DST。laterDay.since(zdt, { largestUnit: 'hour' }).hours;
// => 23 // 因为DST丢失了一个时钟小时
laterHours = zdt.add({ hours: 24 });
// => 2020-03-09T01:00:00-07:00[America/Los_Angeles] // 添加时间单位不调整DST。结果是1:00AM:24实际 // 小时后,因为DST跳过了一个时钟小时。laterHours.since(zdt, { largestUnit: 'hour' }).hours; // => 24
计算日期之间的差异。
Temporal提供了一个名为.until
的方法,它计算由zonedDateTime和其他表示的两个时间之间的差异,可以选择性地对其进行舍入,并将其作为Temporal.Duration对象返回。如果other早于zonedDateTime,则返回的持续时间将是负数。如果使用默认选项,将返回的Temporal.Duration添加到zonedDateTime将产生other。
这看起来像是一个显而易见的操作,但我鼓励你阅读完整的规范 以了解它的细微差别。
结论
Temporal API代表了JavaScript中时间处理方式的革命性转变,使其成为少数全面解决这个问题的语言之一。在这篇文章中,我们只是触及了人类可读日期(或墙上时钟时间)和UTC日期之间的区别,以及如何使用Temporal.ZonedDateTime对象来准确表示前者的表面。
在将来的文章中,我们将探索其他有趣的对象,如Instant、PlainDate和Duration。
希望你享受了这个介绍。
编程愉快!:)