现代化的 JS 日期对象 Temporal 介绍

JS 中的日期历史

如果你对 JavaScript 这门脚本语言的历史有一些了解,就知道,在 1995 年,JavaScript 的设计者 Brendan 被网景公司安排了一个巨大而紧急的工作任务:用 10 天的时间来编写 JavaScript 语言。而日期处理是几乎所有编程语言的基本部分,所以 JavaScript 也必须拥有它。

日期处理是一个非常复杂的领域,但留给作者实现它的时间却很短。最终,Brendan 借鉴了当时红极一时的 Java 语言,从 java.Util.Date 日期实现中复制了 Javascript 的日期对象。不幸的是,当初 Java 的日期实现很糟糕;因此,在两年后的 Java 1.1 版本中就弃用和替换这种实现。然而 20 多年后,我们仍然在 JavaScript 编程语言中使用这个 API

Date 对象存在的问题

1、不支持除用户本地时间以外的时区

不支持开发人员通过 API 来切换时区信息。

2、解析器行为不可靠以至于无法使用

new Date(); 
new Date(value); 
new Date(dateString); 
new Date(year, monthIndex [, day [, hours [, minutes [, seconds [, milliseconds]]]]]);

开发人员常常因为输入的参数格式问题,引发时间错误,导致程序崩溃。比如输入 '2022-02-22'2022,02,22 得到的结果却不同。

3、没有提供日期/时间计算 API

涉及时间的运算逻辑通常都需要开发人员自己去写,比如比较两个时间的长短,时间之间的加减运算,没有自己的计算 API。

提示:本站提供了日期计算工具,支持计算两个日期的差值,欢迎使用!

4、不支持非公历

除了全球通用的公历外,无法使用各国的自己的历法。比如中国的农历。

Temporal 的诞生

为了弥补 Date 的缺陷,很多程序员着手开发一些开源的库来绕过对 Date 的直接使用,比较优秀的 npm 库,如 date.js 和 moment.js,但 Date 的问题始终困扰着 Javascript 这门语言的进一步发展,于是 TC39 组织开始了对 Date 的升级改造,他们找到了 moment.js 库的作者 Maggie,由她来担任新特性 Temporal 的主力设计。

提示:感兴趣的同学可以去 Maggie 的博客阅读更多细节,该网页的控制台已经支持 Temporal 对象在本地运行。

安装 Temporal 的 polyfill

$ npm install @js-temporal/polyfill

在 JS 中引入 Temporal 对象:

import { Temporal} from '@js-temporal/polyfill';

Temporal 是一个全局对象,像 MathPromise 一样位于顶级命名空间中,为 Javascript 语言带来了现代化的日期、时间接口。

Temporal 对象组成
Temporal 对象组成

如上图所示,一个 Temporal 对象包含三个部分:

  • 绿色区域为 ISO 8601 格式的 日期和时间
  • 黄色区域为时区(日本东京)
  • 红色区域为日历(日本历法)

说明:ISO 8601 格式是国际通用时间格式,其中,T 用来分割日期 2022-09-27 和时间 10:42:55,+- 分别代表东时区和西时区。例如:+09:00,代表东九区。

对比 Date 对象

new Date()
// Fri Oct 13 2022 10:39:24 GMT+0800 (中国标准时间)

Date 对象采用 GMT 格式的时间(也就是旧的时间表示格式),在使用的时候不如 ISO 8601 格式通用,同时,GMT 时间也不包含时区和历法。

Temporal 各种类型

重新设计的 Temporal 对象,包含 5 种主要类型,每个类型负责不同的功能,类型之间还可以相互进行转换。了解并掌握这 5 种类型,是学习 Temporal 的必经之路。

下面是 Temporal 各种类型的功能与转换关系图,非常重要,对我们全面理解和使用 Temporal 对象很有帮助,下文将逐步讲解。

Temporal 各种类型
Temporal 各种类型

ZonedDateTime 类型

定义:这是最全面的 Temporal 类型,与时区和日历都有关联。表示从地球上特定区域的角度来看,在特定时刻发生的事。

使用场景:在北京时间的2014年6月29日22时8分52秒出现了流星,或者在纽约时间的2014年6月29日9时8分52秒出现了流星。

如何获得一个 ZonedDateTime 类型?

不仅是获得一个 ZonedDateTime 类型,其实所有的  Temporal 类型都是一样的获取途径。通常有两种方法获得,分别是:

  • 使用 new 构造函数()
  • 使用 from() 方法

1、使用 new 构造函数() 方式

参数:(纳秒数,时区,日历),不同类型要求的参数不同。

纳秒数:从 Unix 纪元(1970 年 1 月 1 日午夜 UTC)计算,所经过的纳秒数,单位为bigint

时区,日期:可以是字符串,也可以是 Temporal 类型。

new Temporal.ZonedDateTime(0n, 'Asia/Shanghai', 'chinese'); 
// Temporal.ZonedDateTime <1970-01-01T08:00:00+08:00[Asia/Shanghai][u-ca=chinese]>

通常每个Temporal 类型的都有 toString() 方法,覆盖了 Object.prototype.toString() 方法,其作用是通过一个字符串表示 Temporal 对象。

示例:调用 toString() 用字符串来表达,以方便阅读。

new Temporal.ZonedDateTime(0n, 'Asia/Shanghai', 'chinese').toString(); 
// 1970-01-01T08:00:00+08:00[Asia/Shanghai][u-ca=chinese]

这个 ZonedDateTime 的类型含义为,从北京时间看,unix 纪元起始时间为 1970-01-01T08:00:00+08:00,而非 1970-01-01T00:00:00+00:00

2、通过 from() 方法获得一个 Temporal 类型

from() 方法的参数更为多样,同时支持溢出处理,所以通常作为获得一个 Temporal 类型的首选方法。

接受字符串

Temporal.ZonedDateTime.from('2022-08-12T00:00:00+08:00[Asia/Shanghai]').toString(); 
// 2022-08-12T00:00:00+08:00[Asia/Shanghai]

或者接受一个对象,形如:from({ 时区, 日期,日历 },options)

其中,参数 options 代表容错机制配置,即可以处理输入的日期溢出问题,有两种配置选项:

  • { overflow: 'constrain' }:自动处理溢出
  • { overflow: 'reject' }:日期溢出则报错

比如,在下面的例子中,2022年2月一共28天,如果选择了 constrain 配置,输入日期超过了会进行溢出处理,即匹配最接近的存在值。

// 输入 31 天,得到 28 天
Temporal.ZonedDateTime.from({
  timeZone: 'Asia/Shanghai',
  year: 2022,
  month: 2,
  day: 31
}, {
  overflow: 'constrain'
}).toString(); 
// 2022-02-28T00:00:00+08:00[Asia/Shanghai]

如果选择 reject 配置,日期超出将会报错。

Temporal.ZonedDateTime.from({
  timeZone: 'Asia/Shanghai',
  year: 2022,
  month: 2,
  day: 31
}, {
  overflow: 'reject'
}).toString(); 
// RangeError: value out of range: 1 <= 31 <= 28

Instant 类型

定义:负责单个时间点(称为 “精确时间” ),精度以纳秒为单位。不存在时区和日历信息。

使用场景:2022-03-10T15:28:40.494266078-08:00,只用来表达一个瞬间的时间,没有其他意义。

获得一个 Instant 类型

new Temporal.Instant( bigint )
// bigint:纳秒数,从 Unix 纪元(1970 年 1 月 1 日午夜 UTC)计算,所经过的纳秒数,单位为 bigint
new Temporal.Instant(1553906700000000000n);
// 2019-03-30T00:45:00Z
new Temporal.Instant(0n);
// 1970-01-01T00:00:00Z
new Temporal.Instant(-2208988800000000000n);
// 1900-01-01T00:00:00Z

Z 在 ISO 8601 时间格式表示没有时区关联。

通过 from() 方法获取,形如:Temporal.Instant.from(thing: any)

from() 方法在生成 Instant 时,会考虑时区的偏差。

Temporal.Instant.from('2019-03-30T01:45:00+01:00[Europe/Berlin]');  
Temporal.Instant.from('2019-03-30T01:45+01:00');
Temporal.Instant.from('2019-03-30T00:45Z');

虽然前两个携带了时区信息,但获取到的 Instant 时间值相同,三个都是 2019-03-30T00:45Z

PlainXXX 系列类型

上图 Plain 开头的类型,负责 Temporal 的日历日期和钟表时间表达,不涉及时区。

使用场景:

  • 日历日期:如小红的生日是农历每年7月23
  • 钟表时间:如现在是下午5:00

对比 Instant 类型,PlainXXX 类型的使用场景有所不同, 内部的属性也不同,Instant 不包含时区和日期,而 PlainXXX 系列则包含日历。

PlainXXX系列包含 5种类型:

  • PlainDateTime:覆盖最广,包含日期和时间
  • PlainDate:只包含日期
    • PlainYearMonth:仅含年月
    • PlainMonthDay:仅含月日
  • PlainTime:只包含时间

PlainDateTime 举例,其他的类同。

获取一个 PlainDateTime

new Temporal.PlainDateTime(year, month, day...);

参数以年->纳秒顺序排列,其中,年、月、日为必填项,其余为选填。

new Temporal.PlainDateTime(2020, 3, 14, 13, 37);
// 2020-03-14T13:37:00

通过 Temporal.PlainDateTime.from() 方法获取:

Temporal.PlainDateTime.from({
  year: 2009,
  month: 4,
  day: 1,
  hour: 25,
  calendar: 'chinese'
}, {
  overflow: 'constrain'
}).toString()
// 2009-04-24T23:00:00[u-ca=chinese]

TimeZone 类型

定义:负责 Temporal 时区的相关信息。

例子:北京时区,东八区,不单独使用,通常结合其他类型搭配。

获取一个 TimeZone 类型

new Temporal.TimeZone(string);

其中,参数 string 表示对一个时区的描述。

// 东八区,即北京时间
new Temporal.TimeZone('8:00');
// 直接字符串描述,前提是 Temporal 内部有定义
new Temporal.TimeZone('Asia/Shanghai');
// Asia/Shanghai

from() 同理

Temporal.TimeZone.from('Asia/Shanghai');
// Asia/Shanghai

在和其他类型搭配时,可以直接使用字符串 Asia/Shanghai 或者 Temporal.TimeZone 对象

例子:

获取一个 ZonedDateTime 类型,设置时区时,使用 Temporal.TimeZone 对象。

new Temporal.ZonedDateTime(0n, Temporal.TimeZone.from('Asia/Shanghai')); 
// 1970-01-01T08:00:00+08:00[Asia/Shanghai]

等价于

new Temporal.ZonedDateTime(0n, 'Asia/Shanghai')); 

Calendar 类型

定义:负责 Temporal 的日历系统。

例子:中国农历。不单独使用,结合其他类型搭配。

获取一个 Calendar 类型

同 TimeZone 类型一样,支持 new Calendar(string) 或者 Temporal.Calendar.from(string) 两种获取方式。

new Temporal.Calendar('chinese').toString();
// chinese

Temporal.Calendar.from('chinese').toString();
// chinese

Calendar 类型不会单独使用,要配合其他带有日历属性的类型使用。

如上所述,在 Temporal 里,包含日历属性的有 plainXXX 系列类型和 ZonedDateTime 类型,这两种类型的原型上有一个 withCalendar() 的方法,用来设置该日期的日历属性。

下面是一些例子。

plainXXX 类型添加日历属性:

没有添加日历属性前

Temporal.PlainDate.from('2018-01-19');
// 2018-01-19

添加日历属性后

Temporal.PlainDate.from('2018-01-19').withCalendar('chinese');
// 2018-01-19[u-ca=chinese]

ZonedDateTime 类型添加日历属性:

没有添加日历属性前

Temporal.ZonedDateTime.from('2022-08-10T00:00:00+08:00[Asia/Shanghai]')
// 2022-08-10T00:00:00+08:00[Asia/Shanghai]

添加日历属性后

Temporal.ZonedDateTime.from('2022-08-10T00:00:00+08:00[Asia/Shanghai]').withCalendar('chinese')
// 2022-08-10T00:00:00+08:00[Asia/Shanghai][u-ca=chinese]

Duration 类型

定义:表示一段持续时间,并且这段时间可以用来进行算术。

使用场景:两段时间,一小时一分钟和一小时十分钟,可以把它们转换成 Duration 类型,再进行时间的长度比较,从而得知前者的时长小于后者。

Duration 并非像 Date 的时间戳形式那样表达一段时间,而是根据 ISO 8601 表示法生成一个字符串来表达一段时间。

也就是说,ISO 8601 表示法的首字母必须由 P 开头,紧接着跟上日期(年、月、周和日),再由 T 字母进行分割,后面再跟上时间(小时、分钟、 秒)。

一个 Duration 字符串可以缺失年、月、周、日、小时、分、秒中的任意一个,但必须包含首字母 P,如果同时有小时、分和秒,则必须包含字母 T

举例:

  • 一年:P1Y,必须保留 P,没有时间信息,不用加 T 来分割。
  • 一分钟:PT1M,必须保留 P,有时间信息,则加 T 来分割日期和时间

一些常用 Duration 字符串表达式:

常用 Duration 类型字符
常用 Duration 类型字符

获得一个 Duration 类型

new Temporal.Duration()

参数:年=>纳秒全部可选,非必填。需要按照顺序输入,某单位空缺则输入 undefined 或者 0

new Temporal.Duration(1, 2, 3, 4, 5, 6, 7, 987, 654, 321); 
// P1Y2M3W4DT5H6M7.987654321S  
// 中文翻译 => 1年2月3周4天5小时6分钟7秒987毫秒654微秒321纳秒 
new Temporal.Duration(0, 0, 0, 40); 
// P40D  中文翻译 => 40天
Temporal.Duration.from(undefined, undefined, undefined, 40); 
// P40D 
new Temporal.Duration(); 
// PT0S 

了解了 Duration 的字符串含义以及怎么生成一个 Duration 后,可以用其进行一些日期与时间的计算与运算。

比对日期或时间的长度大小:

调用 Duration 原型上的 compare() 方法。返回值:-1, 0, 1

let one = Temporal.Duration.from({ hours: 79, minutes: 10 });
// PT1H10M
let two = Temporal.Duration.from({ days: 3, hours: 7, seconds: 630 });
// P3DT7H630S

Temporal.Duration.compare(one,two)
// -1

返回值说明如下:

  • 返回 -1,则 one 比 two 的时间短
  • 返回 0,则 one 比 two 的时间一样
  • 返回 1,则 one 比 two 的时间长

事实上,除了 Timezone 和 Calendar 类型外,所有具备日期和时间属性的类型都可以进行算术。

如 PlainDateTime 类型:

one = Temporal.PlainDateTime.from('1996-02-07T04:36');
two = Temporal.PlainDateTime.from('1996-02-07T02:36');
Temporal.PlainDateTime.compare(two, two)
// 1

日期或时间的加减运算。

加法:

Temporal.Duration.from('PT1H');
// PT1H
hour.add({ minutes: 30 }); 
// => PT1H30M

减法:

hourAndAHalf = Temporal.Duration.from('PT1H30M');
// 输出 PT1H30M

hourAndAHalf.subtract({ hours: 1 });
// 输出 PT30M

同样,这些算术除了除了 Timezone 和 Calendar 类型外,其他类型都适用。

如 PlainDateTime 类型:

dt = Temporal.PlainDateTime.from('1995-12-07T03:24:30.000003500'); 
dt.add({ years: 20, months: 4, nanoseconds: 500 }); 
// 输出 2016-04-07T03:24:30.000004

Temporal 类型之间的转换

Temporal 的各种类型,除了完成自身的功能外,还可以进行类型转换。

Temporal 类型关系图
Temporal 类型关系图

再次回看这个类型关系图,左侧黄色区域的 Instant 类型,用来表达某个瞬间的时间,不包含时区和日历的信息。

右侧黄色区域的 PlainXXX 系列类型,用来表达日历日期或者钟表时间,包含日历信息,而中间的 ZonedDateTime 类型则横跨左右两个区域,包含时区和日历信息,可以作为一个通道,连接左侧的 Instant 和右侧的 PlainXXX 系列,负责类型之间转换的桥梁,同时中间的 Timezone 时区类型 Calendar 日历类型,不单独使用,配合上方的 ZonedDateTime 类型来辅助转换。

最下面的 Duration 与所有类型没有直接关系,不参与类型转换,表示一段持续时间,并且这段时间可以用来进行算术。

Instant 转 ZonedTimeDate

转换前

Temporal.Instant.from('2020-08-05T20:06:13+0900').toString()
// 2020-08-05T11:06:13Z

转换后

Temporal.Instant.from('2020-08-05T20:06:13+0900').toZonedDateTimeISO('Asia/Tokyo').toString();
// 2020-08-05T20:06:13+09:00[Asia/Tokyo]

ZonedTimeDate 转 Instant

转换前

Temporal.ZonedDateTime.from('2020-11-01T01:45-07:00[America/Los_Angeles]').toString();
// 2020-11-01T01:45:00-07:00[America/Los_Angeles]

转换后

Temporal.ZonedDateTime.from('2020-11-01T01:45-07:00[America/Los_Angeles]').toInstant().toString();
// 2020-11-01T08:45:00Z

ZonedTimeDate 转 PlainDateTime

转换前

Temporal.ZonedDateTime.from('2020-11-01T01:45-07:00[America/Los_Angeles]').toString()
// 2020-11-01T01:45:00-07:00[America/Los_Angeles]

转换后

Temporal.ZonedDateTime.from('2020-11-01T01:45-07:00[America/Los_Angeles]').toPlainDateTime().toString();
// 2020-11-01T01:45:00

PlainDateTime 转 ZonedTimeDate

转换前

Temporal.PlainDateTime.from('2020-08-05T20:06:13').toString()
// 2020-08-05T20:06:13

转换后

Temporal.PlainDateTime.from('2020-08-05T20:06:13').toZonedDateTime('Asia/Tokyo').toString();
// 2020-08-05T20:06:13+09:00[Asia/Tokyo]

总结

回到最开始 Date 的问题:

  1. 不支持除用户本地时间以外的时区。Temparal 支持开发人员通过 TimeZone 来设置本地时间以外的时区;
  2. 计算 API 缺失。除了时区和日历类型外,其他类型都可以进行算术运算,即时间的比较,增加,减少等;
  3. 不支持非公历,Calendar 类型支持 Temparal 选择日历;
  4. 解析器行为不可靠以至于无法使用,在 Temporal 里,new 构造函数() 或者 From 方法,对参数的要求都更加规范,同时 From 方法支持 日期溢出 后的逻辑处理,可以防止系统崩溃。

最后附一张 Temparal 各种类型的功能对照图。每个类型负责 Temparal 哪些功能已经标注清楚。

Temporal 对象整体架构图
Temporal 对象整体架构图

全文完,感谢阅读,希望通过本文对 JS 日期处理有新的认识。

分享