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
是一个全局对象,像 Math
、Promise
一样位于顶级命名空间中,为 Javascript 语言带来了现代化的日期、时间接口。
如上图所示,一个 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 对象很有帮助,下文将逐步讲解。
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 类型
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 的各种类型,除了完成自身的功能外,还可以进行类型转换。
再次回看这个类型关系图,左侧黄色区域的 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 的问题:
- 不支持除用户本地时间以外的时区。Temparal 支持开发人员通过 TimeZone 来设置本地时间以外的时区;
- 计算 API 缺失。除了时区和日历类型外,其他类型都可以进行算术运算,即时间的比较,增加,减少等;
- 不支持非公历,Calendar 类型支持 Temparal 选择日历;
- 解析器行为不可靠以至于无法使用,在 Temporal 里,new 构造函数() 或者 From 方法,对参数的要求都更加规范,同时 From 方法支持 日期溢出 后的逻辑处理,可以防止系统崩溃。
最后附一张 Temparal 各种类型的功能对照图。每个类型负责 Temparal 哪些功能已经标注清楚。
全文完,感谢阅读,希望通过本文对 JS 日期处理有新的认识。