前端时间国际化入门

4,008 阅读26分钟

时间只是幻觉。 —— 阿尔伯特·爱因斯坦

最近在开发一个需要完善国际化方案的前端项目,在处理时间国际化的时候遇到了一些问题。于是花了一些时间研究,有了这篇文章。不过由于网上关于 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 维护的时区数据库(IANA TZ Database),其中包含了自 Unix 时间戳 0 以来各时区的变更信息。当然这一数据库也包含了大量 Unix 时间戳 0 之前的时区变更信息,但并不能保证这些信息的准确性。IANA 时区数据库会定期更新,以反映新的时区变更和新发现的历史史实导致的时区历史变更。

Windows 不使用 IANA 时区数据库。微软为 Windows 自己维护了一套时区数据库,这有时会导致在一个系统上合法的时间在另一系统上不合法。

既然我们不能使用 UTC 偏移来表示一个时区,那就只能为每个时区定义一个标准名称。通常地,我们使用 <大洲>/<城市> 来命名一个时区。这里的城市一般为该时区中人口最多的城市。于是,我们可以将中国的通行时区表示为 Asia/Shanghai。也有一些时区有自己的别名,如太平洋标准时间 PST 和协调世界时 UTC

时区名称使用城市而非国家,是由于国家的变动通常比城市的变动要快得多。

城市不是时区的最小单位。有很多城市同时处于多个时区,甚至澳大利亚有一个机场的跑道两端处于不同的时区。

处理时区困难重重

几个月前的一天,奶冰在他的 Telegram 频道里发了这样的一条消息:

你想的没错,这个问题正是由时区与 UTC 偏移的不同造成的。Asia/Shanghai 时区在 1940 年前后和 1986 年前后曾实行过夏令时,而夏令时的切换会导致一小时的出现和消失。具体来说,启用夏令时当天会有一个小时消失,如 2021.3.28 英国启用夏令时,1:00 直接跳到 3:00,导致 2021-03-28 01:30:00Europe/London 时区中是不合法的;取消夏令时当天又会有一个小时重复,如 2021.10.31 英国取消夏令时,2:00 会重新跳回 1:00 一次,导致 2021-10-31 01:30:00Europe/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 从而导致上文问题发生。而随后社区发现了更准确的史实,因此 IANA 更新了数据库。上文的问题在更新了系统的时区数据库后便解决了。

IANA TZDB 2018a 及之前版本的错误数据

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

  • 使用 UTC 描述时间,你永远不会知道本地时区在未来会发生什么
  • 如果需要以当地时间描述时间,一定带上当前 UTC 偏移

时区历史带来的问题往往意想不到而且远比想象得多。实际上时区历史数据非常详细而繁多且跨设备不一致,并没有简单而统一的处理方法。在需要严谨处理时区时可能需要在应用程序中内嵌一套各端统一的时区数据库,但这样的方案放在前端又会带来不少问题:

  • 体积过大。moment.js 曾经设计过一种简洁的 TZDB 表示,但尽管已经尽可能压缩整个文件仍然达到了 180+KB。在性能优先的 Web 应用中这是不可接受的
  • 需要持续更新。时区数据一直在变动,需要在时区数据更新时尽快更新应用内的时区数据,这带来了额外的维护成本

ES6 为我们带来了 Intl 命名空间。在这里,JavaScript 运行时提供了不少时间相关的国际化能力。因此,在不使用额外数据的情况下准确处理时区是可能的,但这并不完美:

  • 各端不统一。浏览器提供的时区数据受浏览器版本、系统版本等可能变化,最新的时区更新可能无法快速反映到所有设备上
  • 实现复杂。JavaScriptDate 对象的不良设计导致实现完善的时区处理并不容易,且 Intl 命名空间下的对象实例化性能开销较大,需要额外优化

Intl 命名空间下还有很多实用的国际化相关方法,值得我们另开一篇文章来讲讲了。

在真实开发中,这需要取舍。目前主流的 JavaScript 时间处理库都已转向浏览器内置方法,并在需要时通过 Polyfill 保证跨端一致性。在这篇文章中,我们将尝试在不使用第三方库的情况下实现基本的时间国际化处理。此外,还有一些诸如需要使用 Unix 时间戳才能正确地在各端交换时间等细节需要注意。

时区转换

JavaScript 中的 Date 并不是不包含时区信息——实际上,Date 对象表示的一定是当前时区。通过尝试:

new Date('1970-01-01T00:00:00Z')
// Thu Jan 01 1970 08:00:00 GMT+0800 (中国标准时间)

就可以知道,JavaScript 运行时其实知道当前时区,并会在需要的时候将其他时区的时间转换为当前时区的时间。那么,如何将本地时间转换为其他时区的时间呢?从 Date 的角度看,这并不行,因为我们无法设置一个 Date 对象的时区。但我们可以“投机取巧”:将 Date 对象的时间加上/减去对应的时差,尽管 Date 对象仍然认为自己在本地时区,但这样不就可以正确显示了嘛!但我们会碰到上文提到的问题:时区之间的时间差并不固定,在没有额外数据的情况下很难正确计算。

还好,ES6 基于 Intl 命名空间扩展了 Date.prototype.toLocaleString() 方法,使其可以接受时区参数并按指定时区格式化时间。如果你在搜索引擎中搜索如何使用 JavaScript 转换时区,你大概率会在 StackOverflow 上找到类似这样的答案:

const convertTimeZone = (date, timeZone) => {
    return new Date(date.toLocaleString('en-US', { timeZone }))
}

const now = new Date() // Wed Oct 13 2021 01:00:00 GMT+0800 (中国标准时间)
convertTimeZone(now, 'Europe/London') // Tue Oct 12 2021 18:00:00 GMT+0800 (中国标准时间)

很好理解,我们使用 en-US 的区域设置要求 JavaScript 运行时以我们指定的时区格式化时间,再将时间字符串重新解析为时间对象。这里的 timeZone 就是诸如 Asia/Shanghai 等的 IANA TZDB 时区名称。这个字符串确实需要自己提供,但这就是我们唯一需要自己准备的数据了!只要提供了时区名称,浏览器就会自动计算正确的时间,无需我们自行计算。

对于时区名称,你可以考虑使用 @vvo/tzdb。这是一个声称为自动更新的 IANA TZDB 的 JSON 导出,并已被数个大型项目使用。你可以从这个包中导出所有时区名称。

这个方法看起来还不错,对吧?但实际上,它有两个问题:

  • 指定了区域设置和时区的 toLocaleString() 实际上每次调用都会在 JavaScript 运行时中创建新的 Intl.DateTimeFormat 对象(在后文详述),而后者会带来昂贵的性能开销(在 Node 14 中,实例化一次会在 V8 中增加内存使用约 46.3Kb。但这是符合预期的,详见 V8 Issue。因此,在密集调用的情况下需要考虑计算并缓存时差,并在一定时间后或需要时进行更新
  • 使用 toLocaleString() 并使用 en-US 区域设置格式化的默认时间格式类似于 10/13/2021, 1:00:00 AM。这可以被大部分浏览器正确解析,但这是不规范的,不同浏览器有可能产生不同结果。你也可以自行配置格式(同下文的 Intl.DateTimeFormat),但仍然无法构造出规范的字符串

因此,更佳的方案是,我们需要建立一个可反复使用的格式化器以避免重复创建 Intl.DateTimeFormat 带来的额外开销,并需要手动构造出符合规范的时间字符串,并将其重新解析为 Date 对象。

const timeZoneConverter = (timeZone) => {
    // 新建 DateTimeFormat 对象以供对同一目标时区重用
    // 由于时区属性必须在创建 DateTimeFormat 对象时指定,我们只能为同一时区重用格式化器
    const formatter = new Intl.DateTimeFormat('zh-CN', {
        year: 'numeric',
        month: '2-digit',
        day: '2-digit',
        hour: '2-digit',
        minute: '2-digit',
        second: '2-digit',
        hour12: false,
        timeZone
    })
    return {
        // 提供 conver 方法以将提供的 Date 对象转换为指定时区
        convert (date) {
            // zh-CN 的区域设置会返回类似 1970/01/01 00:00:00 的字符串
            // 替换字符即可构造出类似 1970-01-01T00:00:00 的 ISO 8601 标准格式时间字符串并被正确解析
            return new Date(formatter.format(date).replace(/\//g, '-').replace(' ', 'T').trim())
        }
    }
}

const toLondonTime = timeZoneConverter('Europe/London') // 对于同一时区,此对象可重用

const now = new Date() // Wed Oct 13 2021 01:00:00 GMT+0800 (中国标准时间)
toLondonTime.convert(now) // Tue Oct 12 2021 18:00:00 GMT+0800 (中国标准时间)

目前 zh-CN 的区域设置会产生类似 1970/01/01 00:00:00 的格式化字符串。这一格式目前跨端一致,但由于规范没有指定时间格式,这个格式在未来有可能变更。更好的方案是使用 formatToParts() 方法(在后文详述)获取时间字符串的各部分并手动拼接出标准格式的字符串,但在这个例子中直接 replace 拥有更好的性能。

现在,尝试反复转换时间至同一时区 1000 次,耗时从 toLocaleString() 1.5 秒降低到了 0.04 秒。尽管代码长了点,但这次重写在最好的情况下为我们带来了超过 20 倍的性能提升。

需要注意的是,虽然这看起来就算最终方案了,但这个方案依然不完美。主要有以下两个问题:

  • 在需要密集转换为不同时区时,由于无法重用格式化器,性能依然较差且难以进一步优化
  • 由于 Intl.DateTimeFormat 不支持格式化毫秒,在格式化字符串的过程中毫秒会丢失,导致最终结果可能会与期望结果产生最高 999ms 的误差,需要额外处理。比如需要计算时差时,我们可能需要这么写:
const calcTimeDiff = (date, converter) => {
    const secDate = date - date.getMilliseconds() // 去掉毫秒,避免转换前后精度差异
    return converter.convert(new Date(secDate), tzName) - secDate
}

calcTimeDiff(new Date(), timeZoneConverter('Europe/London')) // -25200000

无论如何,在折腾一番后我们还是把时区正确转换了。接下来准备格式化时间字符串了吗?不过在此之前,我们得先来聊聊语言、文字和区域。

语言文字区域傻傻分不清

如何在计算机中表示中文?

“这不简单,”你可能会说,“用 zh 啊。”

那简体中文呢?

zh-CN。”你或许会说出这个答案。

那用于新加坡的简体中文和用于中国大陆的简体中文该如何区分呢?

嗯……好问题。

要能正确区分不同的简体中文,我们还得先回到定义上。实际上,“国际化”并不只是语言的翻译而已,国际化包含的是一整套对于各个区域的本地化方案。要准确表示一个国际化方案,我们实际至少需要确定三个属性:语言(Language)、文字(Script)和区域(Locale)。

  • 语言通常指的是声音语言。不同的语言都有一套自己的发音规则,很难互通。如中文和英语都属于语言
  • 文字对应的是某个语言的书写方式,同样的语言可能会有多种书写方案。如中文主要有简体和繁体两种书写方案
  • 区域指国际化面向的地区,相同的语言和文字,在不同地区也有可能会有不同的使用习惯。如新加坡和中国大陆都使用简体中文,但两地的用词习惯等有些许差异

只有确定了这三个属性,我们才能正确定义一个国际化方案(或者说区域设置)。当然,还有很多其他属性可以更准确的表达某个区域设置,但通常有语言、文字和区域就已经足够了。

于是,基于 BCP 47,我们可以知道:

cmn-Hans-CN = 中文普通话-简体-中国大陆
cmn-Hans-SG = 中文普通话-简体-新加坡
cmn-Hant-TW = 中文普通话-繁体-台湾
yue-Hant-HK = 中文粤语-繁体-香港

等等,这都是啥?还有 BCP 47 又是啥?BCP 是 IETF 发布的“最佳当前实践”文档,而 BCP 47 是一些国际化相关的 ISO 和备忘录的集合,也是目前事实上由 HTML 和 ECMAScript 所使用的表达区域设置的标准。BCP 47 定义的区域设置标签实际上比较复杂,但对于大部分简单使用情况,上文示例中的格式已经完全够用了。简单来说,要表达一个区域设置,我们会使用 语言[-文字][-区域] 的格式,而文字和区域都是可选的。而对于每个部分的具体代码,BCP 47 也有做具体定义。其中:

  • 语言使用 ISO 639-1 定义的两位字母代码(如中文为 zh,英文为 en)或 ISO 639-2/3 定义的三位字母代码(如中文普通话为 cmn,英文为 eng),通常小写
  • 文字使用 ISO 15924 定义的四位字母代码,通常首字母大写。如简体中文是 Hans,繁体中文是 Hant
  • 区域通常使用 ISO 3166-1 定义的两位字母代码,通常大写,如中国大陆为 CN,英国为 GB

ISO 639-1/2/3 的关系实际是:ISO 639-1 是最早制定的规范,使用两位字母表示语言,但语言数量之多并不能只用两位代码表示。因此后来修订了 ISO 639-2 和 3,使用三位字母表示了更多语言。通常 639-1 代码和 ISO-2/3 代码是一对多的关系。如中文 zh 其实是中文普通话 cmn 的宏语言(macrolanguage),同样使用 zh 为宏语言的语言还有 wuu(中文吴语)、hak(中文客家话)、yue(中文粤语)等数十种。从规范上我们现在应该使用 ISO 639-2/3 代码来替代 ISO 639-1 代码了,但由于历史阻力和真实需求中分类无需如此细致等原因,使用 ISO 639-1 指定语言仍然非常常见而且完全可以接受。此外,特别地,我们在 ISO 639-3 中定义未指明的语言为 und

因此,对于这一节开头的两个问题,在 BCP 47 中正确答案其实是:

zh = 中文
cmn = 中文普通话

zh-Hans = 中文-简体
cmn-Hans = 中文普通话-简体

zh-CN 实际是指在中国大陆使用的中文,当然也包含在中国大陆使用的繁体中文。不过,由于大部分情况下一个区域只会通用一种文字,很多情况下我们可以忽略文字这一项,即使用 zh-CN(或者 cmn-CN)来表示中国大陆的简体中文普通话——毕竟在大部分业务中在中国大陆使用繁体和非普通话的情况非常少。

事实上,类似 zh-Hanszh-Hant 开头的区域设置名称已经被标记为 redundant 废弃,因此尽可能只使用 zh-CN 或者 cmn-Hans-CN 这样的区域设置名称。所有区域设置名称的列表可以在 IANA 找到。

现在我们可以准确定义一个区域设置了。不过我们还有一些小小的需求。比如我们想在 cmn-Hans-CN 的区域设置中使用农历来表示日期,但显然我们上文定义的表示方法并不能表达这一需求。好在,Unicode 为 BCP 47 提供了 u 扩展。在区域设置名称后面加上 -u-[选项] 就可以表达更细致的变体了。所以我们有:

cmn-Hans-CN-u-ca-chinese = 中文普通话-简体-中国大陆-u-日历-中国农历
jpn-Jpan-JP-u-ca-japanese = 日语-日文汉字/平假名/片假名-日本-u-日历-日本日历
cmn-Hans-CN-u-nu-hansfin = 中文普通话-简体-中国大陆-u-数字-简体大写数字

u 扩展的具体可选项可以在 Unicode 网站上找到。而多个 u 扩展还可以连接——于是我们甚至可以写出 cmn-Hans-CN-u-ca-chinese-nu-hansfin 这种丧心病狂的区域设置名称。当然,相信你现在已经可以看懂这个区域设置的意思了。

不同地区可能会有不同的日历使用习惯,如中国有使用农历的需求,泰国有使用佛历的需求,我们可以通过 u 扩展指定不同的日历。不过,大部分情况下我们会使用标准的 ISO 8601 日历(gregory),JavaScript 的 Date 对象也只支持这种日历。

你可以使用 BCP47 language subtag lookup 工具快速检查你编写的 BCP 47 区域标签是否规范。

终于我们可以正确表达一个完美符合我们需求的区域设置了。接下来,让我们开始格式化时间吧。

格式化时间

这题我会!

const formatDate(date) => {
    return `${date.getFullYear()}-${`${date.getMonth() + 1}`.padStart(2, '0')}-${`${date.getDate()}`.padStart(2, '0')} ${`${date.getHours()}`.padStart(2, '0')}:${`${date.getMinutes()}`.padStart(2, '0')}:${`${date.getSeconds()}`.padStart(2, '0')}`
}

formatDate(new Date()) // 2021-10-13 01:00:00

就完事了……吗?先不论这样的格式化代码难以阅读,尽管上文这样的日期格式国际通用,但并非所有区域都习惯于这样的日期表示方法。比如英语国家/地区在很多时候习惯在日期中加入星期,而阿拉伯语国家/地区在部分情况下习惯使用阿拉伯语数字(而非常用的阿拉伯-印度数字);再比如美式英语国家/地区习惯月-日-年的日期表示法,而英式英语国家/地区习惯日-月-年的日期表示法……不同区域在时间表示格式习惯上的差异是巨大的,我们很难通过一个简单的方法来正确地、国际化地格式化一个日期

好在 ES6 早就为我们铺平了道路。还记得上文提到过的 Intl.DateTimeFormat 吗?我们通过它来实例化一个日期格式化器并用进行日期的国际化。

直接来看例子吧:

const options = {
    year: 'numeric',
    month: 'short',
    day: 'numeric',
    weekday: 'long'
}
const now = new Date()

const enUSFormatter = new Intl.DateTimeFormat('en-US', options)

const zhCNFormatter = new Intl.DateTimeFormat('zh-CN', options)
const zhCNAltFormatter = new Intl.DateTimeFormat('zh-CN-u-ca-chinese', options)
const zhCNAlt2Formatter = new Intl.DateTimeFormat('zh-CN-u-ca-roc-nu-hansfin', options)

const jaFormatter = new Intl.DateTimeFormat('ja', options)
const jaAltFormatter = new Intl.DateTimeFormat('ja-JP-u-ca-japanese', options)

const arEGFormatter = new Intl.DateTimeFormat('ar-EG', options)

enUSFormatter.format(now) // Wednesday, Oct 13, 2021

zhCNFormatter.format(now) // 2021年10月13日星期三
zhCNAltFormatter.format(now) // 2021辛丑年九月8星期三
zhCNAlt2Formatter.format(now) // 民国壹佰壹拾年拾月拾叁日星期三

jaFormatter.format(now) // 2021年10月13日水曜日
jaAltFormatter.format(now) // 令和3年10月13日水曜日

arEGFormatter.format(now) // الأربعاء، ١٣ أكتوبر ٢٠٢١

在这里我们使用 ISO 639-1 代码来表示语言,是由于事实上 ISO 639-1 代码更加常见与通用。在大部分支持 Intl.DateTimeFormat 的 JavaScript 运行时中我们也可以使用 ISO 639-2/3 代码来表示语言(但实际会 fallback 至对应的 ISO 639-1 代码)。

你也可以通过在 options 中设置 calendar 属性和 numberingSystem 属性来替换区域设置名称中对 u 扩展的使用。这也是推荐方式。

这非常直观,我们可以指定区域设置和格式化选项来初始化一个格式化器,并在之后使用格式化器对象的 format 方法来格式化一个 Date 对象。这里的格式化选项其实非常灵活,能格式化的不只是日期,时间也可以被灵活地格式化,有非常多的组合可以选择。我们不会在这里详细解释每一个选项,你可以访问 MDN 文档来了解更多。

如前文所述,Intl.DateTimeFormat 无法格式化毫秒。

不过需要注意的是,JavaScript 运行时不一定支持所有区域设置,也不一定支持所有格式化选项。在遇到不支持的情况时,Intl.DateTimeFormat 默认会静默 fallback 到最匹配的支持项,因此在处理不常见的区域设置或选项时,你可能需要再额外检查。你可以通过 Intl.DateTimeFormat.supportedLocalesOf() 静态方法判断当前运行时是否支持指定的区域设置,也可以在实例化格式化器后在对象上调用 resolvedOptions() 方法来检查运行时的解析结果是否与预期一致。

new Intl.DateTimeFormat('yue-Hant-CN').resolvedOptions()
// {locale: 'zh-CN', calendar: 'gregory', …}
// fallback 至 zh-CN,与 yue-CN 的预期不一致

此外,正如你所看到的,各种语言在日期格式化中使用的文本 JavaScript 运行时都已经帮我们内置了。因此,我们甚至可以利用这些国际化特性来为我们的应用减少一点需要翻译的字符串——打包进应用的翻译越少,应用体积也就越小了嘛——比如说获取一周七天对应的名字:

const getWeekdayNames = (locale) => {
     // 基于一个固定日期计算,这里选择 1970.1.1
     // 不能使用 0,因为 Unix 时间戳 0 在不同时区的日期不一样
    const base = new Date(1970, 0, 1).getTime()
    const formatter = new Intl.DateTimeFormat(locale, { weekday: 'short' })
    return Array.from({ length: 7 }, (_, day) => (
        formatter.format(new Date(base + 3600000 * 24 * (-4 + day))) // 1970.1.1 是周四
    ))
}

getWeekdayNames('en-US') // ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
getWeekdayNames('zh-CN') // ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
getWeekdayNames('ja') // ['日', '月', '火', '水', '木', '金', '土']
getWeekdayNames('ar-EG') // ['الأحد', 'الاثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة', 'السبت']

当然,如果你还是不喜欢运行时为你提供的格式,我们还有上文提到过的 formatToParts() 方法可以用。来看一个简单的例子吧:

new Intl.DateTimeFormat('zh-CN', {
    year: 'numeric',
    month: 'short',
    day: 'numeric',
    weekday: 'long',
    hour: 'numeric',
    minute: 'numeric',
    second: 'numeric',
}).formatToParts(new Date())
// [
//     { type: 'year', value: '2021' },
//     { type: 'literal', value: '年' },
//     { type: 'month', value: '10' },
//     { type: 'literal', value: '月' },
//     { type: 'day', value: '13' },
//     { type: 'literal', value: '日' },
//     { type: 'weekday', value: '星期三' },
//     { type: 'literal', value: ' ' },
//     { type: 'dayPeriod', value: '上午' },
//     { type: 'hour', value: '1' },
//     { type: 'literal', value: ':' },
//     { type: 'minute', value: '00' },
//     { type: 'literal', value: ':' },
//     { type: 'second', value: '00' }
// ]

随后,你就可以自己解析这个数组来构造出你想要的时间格式了。最后,我们还可以使用 Intl.RelativeTimeFormat 来格式化相对日期。当然我们不会在这里详细讲解这个 API,你可以参考 MDN 文档。直接来看一个简单例子吧:

const getRelativeTime = (num, unit, locale) => {
    return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(num, unit)
}

getRelativeTime(-3, 'day', 'en-US') // 3 days ago
getRelativeTime(-1, 'day', 'zh-CN') // 昨天
getRelativeTime(0, 'second', 'zh-CN') // 现在
getRelativeTime(3, 'hour', 'ja') // 3 時間後

Intl.RelativeTimeFormat 是一个相对较晚进入标准的对象,因此浏览器支持程度较差,可能需要使用 Polyfill。不过目前(2021.10)主流浏览器的最新版本均已支持此 API。

未来

我希望这篇文章时区转换的部分可以很快过时——这并非无稽之谈,目前(2021.10)TC39 的 Temporal 提案已经进入 Stage 3 了。Temporal 提案定义了一个新的、时区友好的 Temporal 命名空间,并期望在不久后就能进入标准并最终应用于生产环境Temporal 定义了完整的时区、时间段、日历规则的处理,且拥有简单明了的 API。那时候,JavaScript 的时区处理就不会再如此痛苦了。由于目前 Temporal 提案还未进入标准,API 暂未稳定,我们无法将其用于生产环境,但我们可以来看一个简单的例子感受一下这个 API 的强大。

const zonedDateTime = Temporal.ZonedDateTime.from({
  timeZone: 'America/Los_Angeles',
  year: 1995,
  month: 12,
  day: 7,
  hour: 3,
  minute: 24,
  second: 30,
  millisecond: 0,
  microsecond: 3,
  nanosecond: 500,
  calendar: 'iso8601'
}) // 1995-12-07T03:24:30.0000035-08:00[America/Los_Angeles]

如果你希望立刻开始使用 Temporal,现在已有 Polyfill 可用。

不过,时区问题不会消失,各地区的习惯也很难融合到一起。时间的国际化处理是极其复杂的,前端中的时间国际化仍然值得我们认真关注。