时间只是幻觉。—— 阿尔伯特·爱因斯坦
最近在开发一个需要完善国际化方案的前端项目,在处理时间国际化的时候遇到了一些问题。于是花了一些时间研究,有了这篇文章。不过由于网上关于 JavaScript 中 Date 对象的坑的文章已经一抓一大把了,因此这篇文章不是 JavaScript 中 Date 对象的使用指南,而是只专注于前端时间国际化。
从时区说起
要想处理时间,UTC 是一个绕不开的名字。协调世界时(Coordinated Universal Time)是目前通用的世界时间标准,计时基于原子钟,但并不等于 TAI(国际原子时)。TAI 不计算闰秒,但 UTC 会不定期插入闰秒,因此 UTC 与 TAI 的差异正在不断扩大。UTC 也接近于 GMT(格林威治标准时间),但不完全等同。可能很多人都发现近几年 GMT 已经越来越少出现了,这是因为 GMT 计时基于地球自转,由于地球自转的不规则性且正在逐渐变慢,目前已经基本被 UTC 所取代了。
JavaScript 的
Date
实现不处理闰秒。实际上,由于闰秒增加的不可预测性,Unix/POSIX 时间戳完全不考虑闰秒。在闰秒发生时,Unix 时间戳会重复一秒。这也意味着,一个时间戳对应两个时间点是有可能发生的。
由于 UTC 是标准的,我们有时会使用 UTC+/-N 的方式表达一个时区。这很容易理解,但并不准确。中国通行的 Asia/Shanghai
时区大部分情况下可以用 UTC+8 表示,但英国通行的 Europe/London
时区并不能用一个 UTC+N 的方式表示——由于夏令时制度,Europe/London
在夏天等于 UTC+1,在冬天等于 UTC/GMT。
一个时区与 UTC 的偏移并不一定是整小时。如
Asia/Yangon
当前为 UTC+6:30,而Australia/Eucla
目前拥有奇妙的 UTC+8:45 的偏移。
夏令时的存在表明时间的表示不是连续的,时区之间的时差也并不是固定的,我们并不能用固定时差来处理时间,这很容易意识到。但一个不容易意识到的点是,时区还包含了其历史变更信息。中国目前不实行夏令时制度,那我们就可以放心用 UTC+8 来表示中国的时区了吗?你可能已经注意到了上一段中描述 Asia/Shanghai
时区时我使用了大部分一词。Asia/Shanghai
时区在历史上实行过夏令时,因此 Asia/Shanghai
在部分时间段可以使用 UTC+9 来表示。
new Date('1988-04-18 00:00:00')
// Mon Apr 18 1988 00:00:00 GMT+0900 (中国夏令时间)
夏令时已经够混乱了,但它实际上比你想象得更混乱——部分穆斯林国家一年有四次夏令时切换(进入斋月时夏令时会暂时取消),还有一些国家使用混沌的 15/30 分钟夏令时而非通常的一小时。
不要总是基于
00:00
来判断一天的开始。部分国家使用 0:00-1:00 切换夏令时,这意味着 23:59 的下一分钟有可能是 1:00。
事实上,虽然一天只有 24 个小时,但当前(2021.10)正在使用的时区有超过 300 个。每一个时区都包含了其特定的历史。虽然有些时区在现在看起来是一致的,但它们都包含了不同的历史。时区也会创造新的历史。由于政治、经济或其他原因,一些时区会调整它们与 UTC 的偏差(萨摩亚曾经从 UTC-10 切换到 UTC+14,导致该国 2011.12.30 整一天都消失了),或是启用/取消夏令时,甚至有可能导致一个时区重新划分为两个。因此,为了正确处理各个时区,我们需要一个数据库来存放时区变更信息。还好,已经有人帮我们做了这些工作。目前大多数 *nix 系统和大量开源项目都在使用 IANA 维护的时区数据库[1](IANA TZ Database),其中包含了自 Unix 时间戳 0 以来各时区的变更信息。当然这一数据库也包含了大量 Unix 时间戳 0 之前的时区变更信息,但并不能保证这些信息的准确性。IANA 时区数据库会定期更新,以反映新的时区变更和新发现的历史史实导致的时区历史变更。
Windows 不使用 IANA 时区数据库。微软为 Windows 自己维护了一套时区数据库[2],这有时会导致在一个系统上合法的时间在另一系统上不合法。
既然我们不能使用 UTC 偏移来表示一个时区,那就只能为每个时区定义一个标准名称。通常地,我们使用 <大洲>/<城市>
来命名一个时区。这里的城市一般为该时区中人口最多的城市。于是,我们可以将中国的通行时区表示为 Asia/Shanghai
。也有一些时区有自己的别名,如太平洋标准时间 PST
和协调世界时 UTC
。
时区名称使用城市而非国家,是由于国家的变动通常比城市的变动要快得多。
城市不是时区的最小单位。有很多城市同时处于多个时区,甚至澳大利亚有一个机场[3]的跑道两端处于不同的时区。
处理时区困难重重
几个月前的一天,奶冰在他的 Telegram 频道里发了这样的一条消息:

你想的没错,这个问题正是由时区与 UTC 偏移的不同造成的。Asia/Shanghai
时区在 1940 年前后和 1986 年前后曾实行过夏令时,而夏令时的切换会导致一小时的出现和消失。具体来说,启用夏令时当天会有一个小时消失,如 2021.3.28 英国启用夏令时,1:00 直接跳到 3:00,导致 2021-03-28 01:30:00
在 Europe/London
时区中是不合法的;取消夏令时当天又会有一个小时重复,如 2021.10.31 英国取消夏令时,2:00 会重新跳回 1:00 一次,导致 2021-10-31 01:30:00
在 Europe/London
时区中对应了两个时间点。而在奶冰的例子中,1988-04-10 00:46:50
正好处于因夏令时启用而消失的一小时中,因此系统会认为此时间字符串不合法而拒绝解析。
你可能会注意到在历史上 1988.4.10 这一天
Asia/Shanghai
时区实际上是去掉了 1:00-2:00 这一小时而不是 0:00-1:00。上文问题更深层次的原因是,在 IANA TZDB 2018a 及更早版本中,IANA 因缺乏历史资料而设置了错误的夏令时规则,规则设定了夏令时交界于 0:00-1:00 从而导致上文问题发生。而随后社区发现了更准确的史实[4],因此 IANA 更新了数据库。上文的问题在更新了系统的时区数据库后便解决了。

再来考虑另一种情况。你的应用的某位巴西用户在 2018 年保存了一个未来时间 2022-01-15 12:00
(按当时的规律那应该是个夏令时时间),不巧那时候你的应用是以格式化的时间字符串形式保存的时间。之后你发现巴西已经于 2019 年 4 月宣布彻底取消夏令时制度,那么 2022-01-15 12:00
这个时间对应的 Unix 时间戳发生了变化,变得不再准确,要正确处理这一字符串就需要参考这一字符串生成的时间(或生成时计算的 UTC 偏移)来做不同的处理。因此,应用从一开始就应该避免使用字符串来传输、存储时间,而是使用 Unix 时间戳。如果不得不使用字符串存储时间,请尽可能:
使用 UTC 描述时间,你永远不