基于 Temporal 的时区及夏令时处理
May 16, 2022
概念介绍
分别介绍时区和夏令时的概念,先让各位开发同学有一个基础认知。
什么是时区?
全球划分为二十四个时区(东、西各十二个时区),规定英国为零时区,相邻时区时间相差一小时。
东边的时区时间比西边的时区时间早(快),例如北京位于东八区,洛杉矶位于西八区,正常来说北京比洛杉矶快 16 个小时,但此时洛杉矶处于夏令时,实际上北京比洛杉矶快了 15 个小时。
复原辽阔的国家,横跨多个时区,常常以国家内部行政分界线为时区界线,这就是实际时区:
- 中国横跨多个时区,然而实际上我们国家法定时区为东八区标准时:
- 新疆当地时间可能还处于凌晨 3 点,天空一片漆黑,但一看手机显示早上六点
- 而美国同样横跨多个时区,但是美国本土使用了:
- 美东时间:Eastern Standard Time UTC -5、Eastern Daylight Time UTC -4(纽约)
- 美中时间:Centrral Standard Time UTC -6、Centrral Daylight Time UTC -5(芝加哥)
- 山地时间:Mountain Standard Time UTC -7、Mountain Daylight Time UTC -6(凤凰城)
- 太平洋时间:Pacific Standard Time UTC -8、Pacific Daylight Time UTC -7(洛杉矶)
什么是夏令时?
夏令时(DST),又称“日光节约时”(Daylight Saving Time),为了通俗易懂解释这个概念,我将化身为一名勤劳的打工人。
春季昼夜平分,日照时间 12 h,我 09:00 上班,22:00 睡觉,开灯时间总计 4h.
夏季开始,日照时间从 12h -> 14h,由于日落时间晚了 1 小时,开灯时间从 4h -> 3h.
我发现日出提早了一小时,但是却在被窝里呼呼大睡,白白浪费一小时日照时间。
这可不行,作为奋斗的新生代,必须要卷起来!
于是我 08:00 就开始干活,早上班也早下班,21:00 便入睡了,开灯时间从 3h -> 2h.
但是更改上下班时间和生活作息实在不习惯,看着手机才 08:00,却要起床,而且我想让身边的同学也一起加入到“卷”的大部队中,从不能强迫他们早起床一小时吧~
于是我想到了在每年夏季开始的那天凌晨晚上,人为将时间调快一小时(大家睡眠时间少了一个小时,但上班时间还是 09:00),可以使人早起早睡,减少开灯时间,以充分利用光照资源,从而节约照明用电。
以洛杉矶为例:
- 当地时间 2022 年 03 月 13 日 02:00:00 时钟向前调整 1 小时 变为 2022 年 03 月 13 日 03:00:00,标志着夏令时开始,俗称 "Spring Forward 1 Hour".
- 调整前时区:PST(Pacific Standard Time) - 太平洋标准时间 ( 标准时间 ) UTC -08:00
- 调整后时区:PDT(Pacific Daylight Time) - 太平洋夏令时 ( 夏令时间 ) UTC -07:00
- 当地时间 2022 年 11 月 06 日 02:00:00 时钟向后调整 1 小时 变为 2022 年 11 月 06 日 01:00:00,标志着夏令时结束,俗称 "Fall Back 1 Hour".
- 调整前时区:PDT(Pacific Daylight Time) - 太平洋夏令时 ( 夏令时间 ) UTC -07:00
- 调整后时区:PST(Pacific Standard Time) - 太平洋标准时间 ( 标准时间 ) UTC -08:00
以下录屏为模拟洛杉矶夏令时开始的情况:
值得注意的是,我国在 1986 年至 1991 年期间也使用了夏令时,我们国家在上述时期的夏天,打印出来的时区是 UTC +09:00.
typescript
new Date("1986-07-01 00:00:00");// Tue Jul 01 1986 00:00:00 GMT+0900 (中国夏令时间)new Date("1988-07-01 00:00:00");// Fri Jul 01 1988 00:00:00 GMT+0900 (中国夏令时间)
typescript
new Date("1986-07-01 00:00:00");// Tue Jul 01 1986 00:00:00 GMT+0900 (中国夏令时间)new Date("1988-07-01 00:00:00");// Fri Jul 01 1988 00:00:00 GMT+0900 (中国夏令时间)
业务场景
在活动平台的真实业务场景中,每个活动都需要选择活动投放时区,才能进行页面配置。
因为活动投放时区会影响到活动的开始时间-结束时间,以及各组件的时间配置。
在中国配置洛杉矶活动时间
而平台往往是由中国的运营同学来创建其他国家地区的活动,比如我在中国配置一个投放洛杉矶地区的活动,当我在时间选择器选择 2022-05-24 00:00:00 ~ 2022-05-27 23:59:59 时,想要的是洛杉矶时间,而我身在中国,如果不做任何处理就将 timestamp 保存至后端,待活动正式上线,会发现开始日期直接比 2022-05-24 00:00:00 晚 16 个 或 15 个小时。
所以,前端需要在背后劫持时间选择器的 onChange
事件,拿到 timestamp 后进行转换,即实现本地时间装换为当地时间,这里的本地指中国,当地指洛杉矶。
在中国显示洛杉矶活动时间
配置好洛杉矶时间后,平台还会回显洛杉矶时间,但问题在于我身在中国,timestamp 会被默认处理成中国时间,导致我看到开始时间直接比 2022-05-24 00:00:00 快 16 个 或 15 个小时。
所以,前端需要在背后劫持时间选择器的 value
,拿到 timestamp 后进行转化,根据本地和当地的时区偏移量进行计算,即实现当地时间在本地正常显示,这里的本地指中国,当地指洛杉矶。
考虑到实行夏令时的国家地区
目前活动平台投放国家涉及美国、英国等实行夏令时的国家,在之前使用 UTC [+/-]X
来决定活动时间,X 的范围是 [0, 12],但夏令时国家的时区一年会发生两次变化:
UTC -X
->UTC -X + 1
UTC -Y
->UTC -Y - 1
这些是硬编码无法解决的,后果就是活动会在当地提早或延迟一个小时结束。
因此,不再使用 UTC [+/-]X
来进行时区判断,而是使用国家地区名称动态判断,例如:
解决方案
下述的解决方案都离不开 TC39 Proposal: Temporal,它旨在为 JavaScript 提供全新的日期、时间、时区、日历处理 API,以解决原有 Date 对象的不足。
- Docs:https://tc39.es/proposal-temporal/docs/
- Polyfill:https://www.npmjs.com/package/@js-temporal/polyfill
本地时间装换为当地时间
typescript
// e.g.// 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// }); => 1995-12-07T03:24:30.0000035-08:00[America/Los_Angeles]const zonedDateTime = (timestamp: number, activityTimeZone?: string) => {const date = new Date(timestamp || Date.now());return Temporal.ZonedDateTime.from({timeZone: activityTimeZone,year: date.getFullYear(),month: date.getMonth() + 1,day: date.getDate(),hour: date.getHours(),minute: date.getMinutes(),second: date.getSeconds(),millisecond: 0,microsecond: 0,nanosecond: 0,});};const convertLocalTimeToActivityTime = (timestamp: number | string,activityTimeZone: string) => zonedDateTime(Number(timestamp), activityTimeZone).epochMilliseconds;
typescript
// e.g.// 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// }); => 1995-12-07T03:24:30.0000035-08:00[America/Los_Angeles]const zonedDateTime = (timestamp: number, activityTimeZone?: string) => {const date = new Date(timestamp || Date.now());return Temporal.ZonedDateTime.from({timeZone: activityTimeZone,year: date.getFullYear(),month: date.getMonth() + 1,day: date.getDate(),hour: date.getHours(),minute: date.getMinutes(),second: date.getSeconds(),millisecond: 0,microsecond: 0,nanosecond: 0,});};const convertLocalTimeToActivityTime = (timestamp: number | string,activityTimeZone: string) => zonedDateTime(Number(timestamp), activityTimeZone).epochMilliseconds;
当地时间在本地正常显示
要想还原当地时间,必须先将本地时区偏移量给减去,先变为零时区,再加上当地时区偏移量。
获取某个时间点的时区偏移量需要两个条件:location_name
和 timestamp
,缺一不可。
还是上面的例子,2022-05-24 00:00:00 在洛杉矶正值夏令时,因此时区为 UTC -07:00;2022-05-24 00:00:00 在中国的时区为 UTC +08:00.
所以最终的时间戳等于 timestamp - 8 * 3600 _ 1e3 + -7 _ 3600 \_ 1e3
typescript
// Getting the UTC offset for a time zone at a particular timeconst getUTCOffset = (timestamp: number, activityTimeZone: string) => {const tz = Temporal.TimeZone.from(activityTimeZone);return tz.getOffsetNanosecondsFor(Temporal.Instant.fromEpochMilliseconds(timestamp || Date.now()));};// Temporal.Now.timeZone().toString() => 'Asia/Shanghai'const displayTimeInActivityTimeZone = (timestamp: number | string,activityTimeZone: string) => {const localOffset =getUTCOffset(Number(timestamp), Temporal.Now.timeZone().toString()) / 1e6;const activityTimeZoneOffset =getUTCOffset(Number(timestamp), activityTimeZone) / 1e6;return Number(timestamp || Date.now()) - localOffset + activityTimeZoneOffset;};
typescript
// Getting the UTC offset for a time zone at a particular timeconst getUTCOffset = (timestamp: number, activityTimeZone: string) => {const tz = Temporal.TimeZone.from(activityTimeZone);return tz.getOffsetNanosecondsFor(Temporal.Instant.fromEpochMilliseconds(timestamp || Date.now()));};// Temporal.Now.timeZone().toString() => 'Asia/Shanghai'const displayTimeInActivityTimeZone = (timestamp: number | string,activityTimeZone: string) => {const localOffset =getUTCOffset(Number(timestamp), Temporal.Now.timeZone().toString()) / 1e6;const activityTimeZoneOffset =getUTCOffset(Number(timestamp), activityTimeZone) / 1e6;return Number(timestamp || Date.now()) - localOffset + activityTimeZoneOffset;};