当涉及时间的处理时,你通常不用担心时区
。因为你的电脑会在特定的本地时区
中运行,所有的代码都将在特定的时区上一致的运行。
但是
-
如果需要
跨时处理时间
,怎么办呢? -
DateTime
、Timestamp
要如何跨时区转换?
本文目的
本文将学习时区的
关键概念
,再学习时区的运行和计算逻辑
,最后给出跨时区时间转换的具体实现
。这些代码实现,将包括使用框架、手写实现,甚至源码的解读。希望你可以参考这些信息,对比选择你需要的时区开发方式。
如果想先看代码实现,请从多时区的时间处理开始。
关键概念 Date Time Zone/Offset Unix
以一个原生的Date类为例,他包含日期Date、时间Time以及时区Zone,还有对应的Unix时间戳。时区Zone,如北京时间
或东八区
,又经常以一个Offset偏移量的具体数值表示,比如东八区是+8
小时或480
分钟。
Date
Date的日期部分,包括年月日。格式可能是1983-10-14
,也可能是Fri, Oct 14, 1983
等。
-
获取
RFC 822/RFC 1123/...
标准格式的日期,如Fri Oct 14 1983
:Date.prototype.toDateString()
-
获取一个
特定时区与语言环境
格式下的日期,如Donnerstag, 20. Dezember 2012
:Date.prototype.toLocaleDateString(locales, options)
附上示例代码
const date = new Date(Date.UTC(2012, 11, 20, 3, 0, 0)); // request a weekday along with a long date const options = { weekday: "long", year: "numeric", month: "long", day: "numeric", }; console.log(date.toLocaleDateString("de-DE", options)); // "Donnerstag, 20. Dezember 2012" // 应用程序可能想要使用 UTC 时间,并使其可见 options.timeZone = "UTC"; options.timeZoneName = "short"; console.log(date.toLocaleDateString("en-US", options)); // "Thursday, December 20, 2012, UTC"
Time
Date的时间部分,格式可能是19:32:00 GMT+0800 (中国标准时间)
,也可能是7:31:24 PM
,还可能是19:31:24
等。
-
获取
RFC 822/RFC 1123/...
标准格式的时间,如19:32:00 GMT+0800 (中国标准时间)
:Date.prototype.toTimeString()
-
获取一个
特定时区与语言环境
格式下的时间,如7:31:24 PM
:Date.prototype.toLocaleTimeString(locales, options)
附上示例代码
new Date().toLocaleTimeString("en-US", {hour12: true}) // '7:31:24 PM' new Date().toLocaleTimeString() // '19:31:24'
Zone/Offset
Zone是Date的时区部分,格式可能是 +0800
,也可能是Z
,还可能是 UTC
。
而Offset是UTC标准时间(零时区)相对于当前Zone的时间差值(时间偏移),可以是+8
(小时)也可以是480
(分钟)。
以分钟为单位获取偏移量的API如下:
Date.prototype.getTimezoneOffset()
Unix
UNIX时间戳,或称POSIX时间是UNIX或类UNIX系统使用的时间表示方式:从UTC1970年1月1日0时0分0秒起至现在的总秒数,不考虑闰秒。格式可能是1688114469032
(毫秒),也可能是1688114469
(秒)。在Date上这样获取:new Date().valueOf()
。
也就是说,在同一个时刻,时间戳是全球唯一的。 不管在东八区还是西八区,此时此刻的时间戳都是唯一一个,只是根据时区的不同,有的地区是4点,有的地区是8点。
时区关键概念
关键概念的理解将主要参考luxon文档,其次也参考维基百科和其他信息。
参考luxon文档原文
Bear with me here. Time zones are a pain in the ass. Luxon has lots of tools to deal with them, but there's no getting around the fact that they're complicated. The terminology for time zones and offsets isn't well-established. But let's try to impose some order:
- An offset is a difference between the local time and the UTC time, such as +5 (hours) or -12:30. They may be expressed directly in minutes, or in hours, or in a combination of minutes and hours. Here we'll use hours.
- A time zone is a set of rules, associated with a geographical location, that determines the local offset from UTC at any given time. The best way to identify a zone is by its IANA string, such as "America/New_York". That zone says something to the effect of "The offset is -5, except between March and November, when it's -4".
- A fixed-offset time zone is any time zone that never changes offsets, such as UTC. Luxon supports fixed-offset zones directly; they're specified like UTC+7, which you can interpret as "always with an offset of +7".
- A named offset is a time zone-specific name for an offset, such as Eastern Daylight Time. It expresses both the zone (America's EST roughly implies America/New_York) and the current offset (EST means -5). They are also confusing in that they overspecify the offset (e.g. for any given time it is unnecessary to specify EST vs EDT; it's always whichever one is right). They are also ambiguous (BST is both British Summer Time and Bangladesh Standard Time), unstandardized, and internationalized (what would a Frenchman call the US's EST?). For all these reasons, you should avoid them when specifying times programmatically. Luxon only supports their use in formatting.
Some subtleties:
- Multiple zones can have the same offset (think about the US's zones and their Canadian equivalents), though they might not have the same offset all the time, depending on when their DSTs are. Thus zones and offsets have a many-to-many relationship.
- Just because a time zone doesn't have a DST now doesn't mean it's fixed. Perhaps it had one in the past. Regardless, Luxon does not have first-class access to the list of rules, so it assumes any IANA-specified zone is not fixed and checks for its current offset programmatically.
If all this seems too terse, check out these articles. The terminology in them is subtly different but the concepts are the same:
TimeZone/时区
提到时区,你会想到+0800
?中国标准时间
?还是北京时间x点整
?实际上,时区是一种统一标准时间的区域,他更倾向于遵循国家地区分界
,而不是严格的经度,这样有利于通信频繁的地区时间相同。
所以,我们所说的时区,相比+0800
这样的固定偏移量,更接近于北京时间
。虽然北京时间
为国内统一时间,但在国际上的时区维护机构IANA
中,他被标记为Asia/Shanghai
,这是因为通常,时区的登记不以政区为标准,我们使用 <大洲>/<城市> 来命名一个时区。这里的城市一般为该时区中人口最多的城市。
Offset/偏移量
所有时区值,均定义为与协调世界时(UTC) 的偏移量
,范围从UTC−12:00到UTC+14:00。偏移量通常为整数小时
,但也有少数地区会额外偏移30或45分钟
,例如印度、南澳大利亚和尼泊尔。
偏移量,一般可以是分钟也可以是小时,还可以是分钟小时的组合。如:+8
、-480
、+0530
DST/夏令时
一些高纬度地区在大约半年的时间里使用夏令时
,通常是在春季和夏季将当地时间拨快一小时,在秋季和冬季,将当地时间拨慢一小时。
中国在1986 - 1991年也使用过夏令时,所以中国本地时间不完全等同于东八区时区
,也可以说任何一个地区,都不完全等同于固定时区,这就是为什么我们用地区表示现实中的时区,而偏移量更多是用来了解时间本身。这也是为什么需要IANA
这样的机构来维护时区数据。
NamedOffset/偏移量特定于时区的名称
北京时间,又被称为中国标准时间
,缩写为CST(China standard time)
,对应偏移量UTC+8
。在1986 - 1991年间,在每年4月-9月之间,实行夏令时制度,此时中国本地时间被称为中国夏令时间
,对应偏移量UTC+9
。
由于这个别名缩写有很多重复,并不是标准化的,所以是不推荐使用的。只在必要的时候作为展示使用。
FixedOffsetZone/固定偏移时区
固定偏移时区,是指一个固定的概念化的经度时区,如UTC+8
。通常在时间的展示和时区的计算中频繁出现。这样的时区,往往对应一个指定时间的展示,因为指定时间+指定地区,时区是固定的。
如1992-09-09T00:00:00+0800
,他可以被Date对象正确识别。
IANA Time Zone Database/地球上各地的时间历史的代码和数据
IANA Time Zone Database
,简称 tz 或 zoneinfo,是一组表示地球上各地的时间历史的代码和数据,由互联网号码分配机构(Internet Assigned Numbers Authority,IANA
)维护。该数据库维护并更新全球各国的时间信息,包括时区边界
、UTC(世界标准时间)
和夏令时
等规则。
结论
-
当我们在代码中使用地理时区(如
Asia/Shanghai
)时,可以获取到当地时区更准确的数据,因为他记录了当地所有的时间政策,包括令人抓狂的夏令时规则。 -
但是固定偏移时区/经度时区(
+0800
)也有其优点,他更加直观,方便人类快速掌握时区间的时差量。 -
偏移量特定于时区的缩写(如
CST
中国标准时间
)尽量不要使用,除非有必要作为展示使用。
多时区的时间处理
接下来,我将通过纯原生的JavaScript展示多时区的计算和处理技巧。
本地时区
当我需要一个本地时区的时间展示?我只需要
new Date().toString() // 'Fri Jun 30 2023 20:33:38 GMT+0800 (中国标准时间)'
UTC标准时区(零时区)
当我需要一个UTC标准时间的展示?我只需要
new Date().toUTCString() // ''Fri, 30 Jun 2023 12:35:57 GMT'
自定义时区
1 当我有一个本地时间2023-06-30 20:30:28 GMT+0800
,想知道对应的美国纽约时间。
-
已知,美国纽约的IANA地理时区代码是
America/New_York
JavaScript的Date对象内置了IANA的数据
我们可以通过
Date.prototype.toLocaleString(locale, options)
来设置时区const date = new Date('2023-06-30 20:30:28 GMT+0800'); const options = {}; // 使用美国时间,并使其可见 options.timeZone = "America/New_York"; options.timeZoneName = "longOffset"; console.log(date.toLocaleString("en-US", options)); // "6/30/2023, 8:30:28 AM GMT-04:00"
-
已知美国纽约此时的固定偏移时区是
-0400
,没有美国地域时区的数据目前Date对象没有提供有这种直接转换能力的API
但是,我们可以计算一下:- 已知:
UTC时间
与目标时区的差值是-0400
- 已知:Date对象可直接获取
UTC时间
和本地时间
- 那么就可以利用可获取的
UTC时间
去计算目标时间
目标时间 = (已知时间的) UTC时间 + 时区偏移
function transfer2Datetime (unix, timeZone) { const offset = culcMinute(timeZone); // 计算以分钟为单位的时区offset,例如 '-0400'被转换为-240 const target = +unix + offset * 60 * 1000; const t = new Date(target) return `${t.getUTCFullYear()}-` + `${pl0(t.getUTCMonth() + 1)}-` + // pl0意思是place0,用0️⃣占位;不过看到大佬代码里面这里一般使用padStart,取填充首部之意;为了更易读,显然是统一为padStart比较好;我这里不修改是为了作个对比 `${pl0(t.getUTCDate())} ` + `${pl0(t.getUTCHours())}:` + `${pl0(t.getUTCMinutes())}:` + `${pl0(t.getUTCSeconds())}`; } const d = new Date('2023-06-30 20:30:28 GMT+0800').valueOf(); console.log(transfer2Datetime(d, '-0400')) // 2023-06-30 08:30:28
- 已知:
2 当我有一个美国纽约时间2023-06-30 08:30:28
,想知道对应的本地时间。
-
已知
America/New_York
:没法只通过JavaScript获取到对应的偏移量,需要工具查出对应的偏移时区。 -
已知
-0400
现在我们已知的这个时间,是
-0400
时区的2023-06-30 08:30:28
,想要知道本地时间是几点。
这里,我们知道本地时间是可以直接获取的,只要我们可以将-0400
的2023-06-30 08:30:28
这个时刻转为一个唯一的时间戳,就完全可以直接获得本地时间了。这里要提到Date对象的一个特性:他可以识别「任何他支持的日期时间格式」+「标准timeZone」的组合字符串,这么说,
-
如果我们将2023-06-30 08:30:28和-0400拼接起来,Date就可以直接识别它!我们就可以直接获取本地时间了!
-
当然,这依赖我们验证,所传入的是可靠的字符串,在字符串处理上就要下功夫了
以下是通过Date的特性转换的代码,大部分代码都是在验证字符串的合法性。验证合法性后,在代码的27行,直接
new Date(date + timeZone)
,将两者拼接传给Date
就能得到当时的Date,toString
就可以得到本地时间。// 将字符串转成对应的Date对象 const transfer2Date = (date, timeZone) => { // 如果输入的date不是要求的格式,返回Invalid Date const Error1 = 'Invalid Date'; // 如果输入的timeZone不是要求的格式,返回Invalid TimeZone const Error2 = 'Invalid TimeZone'; // 如果检测出date包含时区,则提示第二个timeZone参数无效 const Warn = '参数已包含时区'; // 1. 【Error】date不是字符串,不予解析。 if (typeof date !== 'string') throw Error(Error1); // 2. 【Error】字符串不被Date识别 if (new Date(date).toString() === 'Invalid Date') throw Error(Error1); // 3. 【Warn】字符串末尾包含时区 if (/([+-][01][0-9][0-5][0-9])|Z$/.test(date)) { console.warn(Warn); return new Date(date); } // 4. 【Error】timeZone存在且格式不对,返回Invalid TimeZone if (!/^[+-][01][0-9][0-5][0-9]$|^undefined$|^$/.test(timeZone)) throw Error(Error2); // 5.1 date字符串,加入时区之前需要保证含有hh:mm date = padTime(date); // 5.2 【Success】date是合法字符,没传timeZone,默认是本地时区不用处理timeZone if (!timeZone) return new Date(date); // 5.3 【Success】date是合法字符,可以直接拼接timeZone字符,直接得出Unix。 return new Date(date + timeZone); } const datetime = '2023-06-30 08:30:28'; const timeZoneOffset = '-0400'; const d = transfer2Date(datetime, timeZoneOffset); console.log(d.toString()) // 'Fri Jun 30 2023 20:30:28 GMT+0800 (中国标准时间)'
通过计算转换:假设我们可以确定本地时间是确定的偏移量,如
+0800
,我们可以通过计算获得目标时间- 目标本地时间 = UTC时间 + 目标本地偏移
- UTC时间 = 已知时间 - 已知偏移
=>
目标本地时间 = 已知时间 - 已知偏移 + 目标偏移function transfer2LocalDatetime (date, timeZone, localTimezone) { const unix = date.valueOf(); const offset = culcMinute(timeZone); // 计算以分钟为单位的时区offset,例如 '-0400'被转换为-240 const offsetLocal = culcMinute(localTimezone); // 计算以分钟为单位的时区offset,例如 '-0400'被转换为-240 const target = +unix - offset * 60 * 1000 + offsetLocal * 60 * 1000; const t = new Date(target) return `${t.getFullYear()}-` + `${pl0(t.getMonth() + 1)}-` + `${pl0(t.getDate())} ` + `${pl0(t.getHours())}:` + `${pl0(t.getMinutes())}:` + `${pl0(t.getSeconds())}`; } const datetime = '2023-06-30 08:30:28'; const timeZoneOffset = '-0400'; const d = new Date(datetime); console.log(transfer2LocalDatetime(d, '-0400', '+0800')) // 2023-06-30 20:30:28
-
时间处理库
时间处理库可以帮助我们轻松处理以上烦恼,高效
、可用
、低维护成本
。但是最大的缺点就是太占空间
了,当项目要我们优化加载速度时,有时候不得不舍弃掉更大的库。如果是极少的场景使用,用原生代码有时也很好。
moment.js
moment.js在很长一段时间都是时间处理库的神级存在,不仅全面而且专业。但是在2020年,他已经停止开发并进入维护状态,因为更新不动了,而且也出现了更好用的轻量库……
如果用moment.js写刚才那段自定义时区的代码,该怎么写呢?
已知2023-06-30 20:30:28 GMT+0800
,想得到America/New_York
时间
const moment = require('moment-timezone');
const inputDate = '2023-06-30 20:30:28 GMT+0800';
const outputDate = moment.utc(inputDate, 'YYYY-MM-DD HH:mm:ss [GMT]ZZ').tz('America/New_York').format('YYYY-MM-DD HH:mm:ss');
console.log(outputDate);
已知2023-06-30 20:30:28 GMT+0800
,想得到-0400
时间
const moment = require('moment');
const inputDate = '2023-06-30 20:30:28 GMT+0800';
const outputDate = moment.utc(inputDate, 'YYYY-MM-DD HH:mm:ss [GMT]Z').utcOffset('-0400').format('YYYY-MM-DD HH:mm:ss');
console.log(outputDate);
已知-0400
是2023-06-30 08:30:28
,想得到本地时间
const moment = require('moment-timezone');
const inputDate = '2023-06-30 08:30:28';
const timeZone = '-0400';
const outputDate = moment.tz(inputDate, 'YYYY-MM-DD HH:mm:ss', timeZone).format('YYYY-MM-DD HH:mm:ss');
console.log(outputDate);
luxon
Luxon 是一个轻量的 JavaScript 日期库,他的作者是moment.js的开发者之一icambron
。这决定了他是诞生于moment.js这个神级库的新一代moment.js。
通读Luxon API,你会发现他对时间的专业程度相比moment.js有过之而无不及,但是包大小却清爽了很多,API用法也更加整齐易读,还解决了moment.js中存在的可变对象
等遗留问题。
不过luxon的专业程度较高,API繁多,还是有一定的学习成本的。 而且luxon对moment.js的API进行了大动,如果只是想找一个无缝替代moment的时间库,他完全不适合。
他比较适合在对时间要求较高的专业项目中使用,因为这些项目要求你不得不对时间进行专业学习,而luxon在这种情况下,只会帮助你更好的理解时间。
如果用luxon写刚才那段自定义时区的代码,该怎么写呢?
已知2023-06-30 20:30:28 GMT+0800
,想得到America/New_York
时间
const { DateTime } = require('luxon');
const inputDate = '2023-06-30 20:30:28 GMT+0800';
const outputDate = DateTime.fromFormat(inputDate, 'yyyy-MM-dd HH:mm:ss \'GMT\'ZZ').setZone('America/New_York').toFormat('yyyy-MM-dd HH:mm:ss');
console.log(outputDate);
已知2023-06-30 20:30:28 GMT+0800
,想得到-0400
时间
const { DateTime } = require('luxon');
const inputDate = '2023-06-30 20:30:28 GMT+0800';
const outputDate = DateTime.fromFormat(inputDate, 'yyyy-MM-dd HH:mm:ss \'GMT\'ZZ').setZone('-0400').toFormat('yyyy-MM-dd HH:mm:ss');
console.log(outputDate);
已知-0400
是2023-06-30 08:30:28
,想得到本地时间
const { DateTime } = require('luxon');
const inputDate = '2023-06-30 08:30:28';
const timeZone = '-0400';
const outputDate = DateTime.fromFormat(inputDate, 'yyyy-MM-dd HH:mm:ss', { zone: timeZone }).toFormat('yyyy-MM-dd HH:mm:ss');
console.log(outputDate);
dayjs
dayjs是一个比luxon更轻量的时间库,十分小而美。他是目前无缝替代moment.js的最佳产品,项目替换成本非常小。
而且他的API文档相当清爽,上手比luxon和moment.js都更友好。
他的作者是饿了么前端的开发者之一iamkun
,dayjs就是来源于他使用moment.js的时候苦于包太大而优化的成果。并且,他也解决了moment.js遗留的可变对象
问题。
在dayjs当中,你不仅可以无缝替换moment.js,还可以增量引入你所需要的功能,让你的时间处理性能成本极度合理、极度舒适。尽可能只加载有必要的部分。
而且,dayjs还有十分友好的自定义接口,你可以在在需要的时候自由添加需要的功能,让整个项目尽可能只加载需要的东西,也能加载需要的东西。
不过,dayjs也有缺点,他的文档远没有luxon那么专业,你并不能因此学到更专业的时间知识,也不能知道时间处理容易遇到的问题。
如果用dayjs写刚才那段自定义时区的代码,该怎么写呢?
已知2023-06-30 20:30:28 GMT+0800
,想得到America/New_York
时间
const dayjs = require('dayjs');
require('dayjs/plugin/utc');
require('dayjs/plugin/timezone');
const inputDate = '2023-06-30 20:30:28 GMT+0800';
const outputDate = dayjs.utc(inputDate, 'YYYY-MM-DD HH:mm:ss [GMT]ZZ').tz('America/New_York').format('YYYY-MM-DD HH:mm:ss');
console.log(outputDate);
已知2023-06-30 20:30:28 GMT+0800
,想得到-0400
时间
const dayjs = require('dayjs');
require('dayjs/plugin/utc');
require('dayjs/plugin/timezone');
const inputDate = '2023-06-30 20:30:28 GMT+0800';
const outputDate = dayjs.utc(inputDate, 'YYYY-MM-DD HH:mm:ss [GMT]Z').tz('-0400').format('YYYY-MM-DD HH:mm:ss');
console.log(outputDate);
已知-0400
是2023-06-30 08:30:28
,想得到本地时间
const dayjs = require('dayjs');
require('dayjs/plugin/utc');
require('dayjs/plugin/timezone');
const inputDate = '2023-06-30 08:30:28';
const timeZone = '-0400';
const outputDate = dayjs.utc(inputDate, 'YYYY-MM-DD HH:mm:ss').tz(timeZone).format('YYYY-MM-DD HH:mm:ss');
console.log(outputDate);
未完待续...
最近太忙,源码部分先欠着…