一篇搞定 JavaScript 时区问题

一篇搞定 JavaScript 时区问题

2 年前

时区是一个开发过程当中无法避免的问题,每次出现都让人头疼不已。 现在是时候学习如何处理这个“难题”了。

这篇文章从 时区 的基本知识入手,到JavaScript如何处理时区,并且最后推荐使用 Moment.js 来处理这个问题的优势。

后续我会推出一系列 moment.js 处理时区问题的案例,尽情期待。

什么是时区?

时区是指遵循国家法律规定的统一当地时间的地区。很多国家都有其独特的时区,这很常见,一些大国,如美国或加拿大,甚至有多个时区。有趣的是,中国虽然大到有多时区,但她只用一个时区。这有时会造成这样一个尴尬的局面,中国西部的太阳在上午10点左右升起。

GMT, UTC 和 Offset(偏移)

GMT

韩国当地时间通常为GMT+09:00。GMT是格林威治时间的缩写,是位于英国格林威治皇家天文台经度0的时钟时间。GMT系统于1925年2月5日开始传播,直到1972年1月1日成为世界时间标准。

UTC

许多人认为格林尼治标准时间和世界时是一回事,在许多情况下,这两个时间是互换使用的,但实际上它们是不同的。世界协调时是在1972年建立的,目的是为了弥补地球自转速度变慢的问题。这个时间系统是以国际原子时为基础,用铯原子频率来设定时间标准。换句话说,UTC是GMT更精确的替代系统。虽然两者之间的实际时差很小,但无论如何,UTC都是软件开发者更准确的选择。

Offset

UTC+09:00中的+09:00意味着当地时间比UTC标准时间提前了9小时。这意味着在韩国是晚上9:00,而在UTC地区是12:00。UTC标准时间与当地时间之间的时间差称为 "偏移",可以这样表示。+09: 00, -03: 00等。 各国用自己独特的名称来命名时区是很常见的。例如,韩国的时区被称为KST(韩国标准时间),有一定的偏移值,表现为KST=UTC+09:00。但是,+09:00的偏移值不仅韩国使用,日本、印尼等国也使用,也就是说偏移值和时区名称的关系不是1:1,而是1:N。在UTC+09:00中可以找到+09:00偏移的国家名单。 有些偏移不是严格按小时计算的。例如,北朝鲜使用+08:30作为他们的标准时间,而澳大利亚则根据地区不同使用+08:45或+09:30。

时区 !== 偏移量?

正如我前面提到的,我们将时区的名称(KST、JST)与偏移互换使用,而不加以区分。但把某个地区的时间和偏移量一视同仁是不对的,原因如下。

夏令时(DST)

虽然这个名词对于一些国家来说可能比较陌生,但是世界上很多国家都采用了夏日时光。"夏令时 "是一个多用于英国和其他欧洲国家的术语。在国际上,通常称为夏令时(Daylight Saving Time,DST)。它的意思是在夏季时间里,将时钟提前到标准时间前一小时。

例如,美国加州冬季使用PST(太平洋标准时间),夏季使用PDT(太平洋夏令时,UTC-07:00)。使用这两个时区的地区统称为太平洋时间(PT),美国和加拿大的很多地区都采用这个名称。

那么接下来的问题就是夏季的具体开始和结束时间。其实,DST的起止日期都是不一样的,每个国家都不一样。比如在美国和加拿大,2006年以前,DST是4月的第一个星期日凌晨02:00到10月的最后一个星期日凌晨12:00,但从2007年开始,DST开始于3月的第二个星期日凌晨02:00到11月的第一个星期日凌晨02:00。在欧洲,夏季时间在各国统一适用,而夏令时则在各州的每个时区逐步适用。

时区会变吗?

正如我在前面简要提到的,每个国家都有自己的权利决定使用哪个时区,这意味着它的时区可以因任何政治和/或经济原因而改变。例如,在美国,由于乔治-布什总统在2005年签署了能源政策,2007年改变了夏令时的时间。埃及和俄罗斯曾经使用过夏令时,但自2011年起停止使用。

在某些情况下,一个国家不仅可以改变其夏令时,还可以改变其标准时间。例如,萨摩亚曾经使用UTC-10:00偏移,但后来改为UTC+14:00偏移,以减少萨摩亚与澳大利亚和新西兰之间的时差造成的贸易损失。这个决定导致该国错过了2011年12月30日的整整一天,并上了全世界的报纸。

荷兰曾经使用+0:19:32.13偏移,这是自1909年以来不必要的准确,但在1937年将其改为+00:20偏移,然后在1940年再次改为+01:00偏移,并坚持至今。

时区1: Offset N

总之,一个时区可以有一个或多个偏移。由于政治和/或经济的原因,一个国家在某一时刻使用哪一个偏移作为其标准时间会有所不同。

这在日常生活中并不是一个大问题,但当试图根据规则将其系统化时,这就是一个大问题。让我们想象一下,你想为你的智能手机设置一个使用偏移的标准时间。如果你生活在适用DST的地区,那么每当DST开始和结束时,你的智能手机时间就应该调整。在这种情况下,你需要一个概念,将标准时间和DST一起纳入一个时区(例如太平洋时间)。

但这并不能只通过几个简单的规则来实现。例如,由于各州改变了2007年DST开始和结束的日期,2006年5月31日应该使用PDT(-07:00)作为标准时间,而2007年3月31日应该使用PST(-08:00)作为标准时间。这意味着,要参考一个特定的时区,你必须知道所有标准时区的历史数据或DST规则改变的时间点。 你不能简单地说:"纽约的时区是PST(-08:00)"。你必须更具体,比如说:"纽约目前的时区是PST"。但是,为了系统的实现,我们需要一个更准确的表达方式。忘记 "时区 "这个词。你需要说:"纽约目前使用PST作为标准时间"。

那么除了offset之外,我们应该用什么来指定特定区域的时区呢?答案是该地区的名称。更具体地说,你应该将DST或标准时区的变化已经统一应用的地区归为一个时区,并适当地引用它。你也许可以使用PT(太平洋时间)这样的名称,但这样的术语只是将当前的标准时间和它的DST结合起来,不一定是所有的历史变化。此外,由于PT目前只在美国和加拿大使用,所以你需要从值得信赖的组织那里获得更多完善的标准,才能普遍使用软件。

IANA 时区数据库

说实话,时区更像是一个数据库,而不是规则的集合,因为它们必须包含所有相关的历史变化。有几个标准的数据库设计来处理时区问题,最常用的是IANA时区数据库。通常被称为tz数据库(或tzdata),IANA时区数据库包含了全球各地当地标准时间的历史数据和DST变化。这个数据库的组织方式是包含所有目前可以验证的历史数据,以确保Unix时间(1970.01/01 00:00:00)以来时间的准确性。虽然它也有1970年以前的数据,但准确性不能保证。

命名惯例遵循Area/Location规则。区域通常是指一个大陆或海洋的名称(亚洲、美洲、太平洋),而位置则是指主要城市的名称,如首尔和纽约,而不是国家的名称(这是因为一个国家的寿命远比一个城市短)。例如,韩国的时区是亚洲/首尔,日本的时区是亚洲/东京。虽然这两个国家共享UTC+09:00,但两国在时区方面有不同的历史。这就是为什么这两个国家使用不同的时区。

IANA时区数据库由众多开发者和历史学家组成的社区管理。新发现的历史事实和政府政策会立即更新到数据库中,使其成为最可靠的来源。此外,许多基于UNIX的操作系统,包括Linux和macOS,以及流行的编程语言,包括Java和PHP,都在内部使用这个数据库。

JS和IANA的交互

正如我前面简单提到的,JavaScript的时区功能相当差。因为它默认遵循的是该地区的时区(更具体地说,是安装操作系统时选择的时区),所以没有办法将其改为新的时区。另外,它对数据库标准的规范也不明确,如果你仔细看一下ES2015的规范,就会发现这一点。关于本地时区和DST的可用性,只有几个模糊的声明。例如,DST的定义如下。ECMAScript 2015--夏令时调整。

An implementation dependent algorithm using best available information on time zones to determine the local daylight saving time adjustment DaylightSavingTA(t), measured in milliseconds. An implementation of ECMAScript is expected to make its best effort to determine the local daylight saving time adjustment.

看起来,它只是在说:"嘿,伙计们,试一试,尽力让它发挥作用。" 这就给各个浏览器厂商也留下了一个兼容性问题。你可能会觉得 "太马虎了!",但你会注意到就在下面还有一行字。

是的,ECMA规范把这个简单的推荐IANA时区数据库的球抛给了你,而JavaScript并没有为你准备具体的标准数据库。因此,不同的浏览器都会使用自己的时区操作来计算时区,而且它们之间往往不兼容。后来ECMA规范在ECMA-402 Intl.DateTimeFormat中增加了一个选项,在国际API中使用IANA时区。但是,这个选项的可靠性还是远远低于其他编程语言。

时区在服务端

我们将假设一个必须考虑时区的简单场景。假设我们要开发一个简单的日历应用,处理时间信息。当用户在客户端环境的注册页面的字段中输入日期和时间时,数据会被传输到服务器并存储在DB中。然后客户端从服务器接收注册的日程数据,将其显示在屏幕上。

不过这里有一些需要考虑的问题。如果访问服务器的一些客户端处于不同的时区,怎么办?在首尔注册的2017年3月11日上午11:30的日程表在纽约查询时,必须显示为2017年3月10日下午09:30。为了让服务器支持来自不同时区的客户,存储在服务器中的日程表必须具有不受时区影响的绝对值。

每个服务器都有不同的存储绝对值的方式,这不在本文的讨论范围内,因为根据服务器或数据库环境的不同,都是不同的。然而要想做到这一点,从客户端传输到服务器的日期和时间必须是基于相同偏移量(通常是UTC)的值,或者也包括客户端环境的时区数据的值。

通常的做法是,这种数据以基于UTC的Unix时间或包含偏移信息的ISO-8601的形式传输。在上面的例子中,如果要将2017年3月11日上午11:30在首尔的时间转换为Unix时间,它将是一个整数类型,其中值是1489199400。在ISO-8601下,它将是一个字符串类型,其中的值是2017-03-11T11:30:00+09:00。 如果你是在浏览器环境下使用JavaScript来处理这个问题,你必须按照上面的描述转换输入的值,然后再转换回来以适应用户的时区。这两个任务都要考虑。在编程语言的意义上,前者叫做 "解析",后者叫做 "格式化"。现在我们来看看JavaScript中是如何处理这些的。

即使你在服务器环境下使用Node.js使用JavaScript工作,你也可能要根据情况对从客户端检索的数据进行解析。然而由于服务器通常会将时区同步到数据库,而格式化的任务通常会留给客户端,因此你需要考虑的因素比浏览器环境下要少。在本文中,我将基于浏览器环境进行讲解。

JS中的 Date 对象

在JavaScript中,涉及到日期或时间的任务都是用Date对象来处理的,它是ECMAScript中定义的本地对象,就像Array或Function一样。它是一个在ECMAScript中定义的本地对象,就像Array或Function一样,大部分是在C++等本地代码中实现的。它的API在MDN文档中得到了很好的描述。它受Java的java.util.Date类影响很大。因此,它继承了一些不理想的特性,如数据可变和月份以0开头的特性。

JavaScript的Date对象内部使用绝对值来管理时间数据,如Unix时间。但是,构造函数和方法,如parse()函数、getHour()、setHour()等,都会受到客户端的本地时区(准确的说是运行浏览器的操作系统的时区)的影响。因此,如果直接使用用户输入数据创建Date对象,数据将直接反映客户端的本地时区。

正如我前面提到的,JavaScript并没有提供任何任意改变时区的方法。因此,我这里假设一种情况,即可以直接使用浏览器的时区设置。

根据用户输入来构建 Date 对象实例

让我们回到第一个例子。假设用户在设备中输入了2017年3月11日上午11:30,该设备遵循首尔的时区。这个数据被存储在2017、2、11、11和30这5个整数中--每个整数分别代表年、月、日、时、分。(由于月份是从0开始的,所以数值必须是3-1=2。)通过构造函数,你可以轻松地使用数值创建一个Date对象。

const d1 = new Date(2017, 2, 11, 11, 30);
d1.toString(); // Sat Mar 11 2017 11:30:00 GMT+0900 (KST)

如果你看d1.toString()返回的值,那么你会知道创建对象的绝对值是2017年3月11日上午11:30,基于偏移量+09:00(KST)。

你也可以将构造函数与字符串数据一起使用。如果你对Date对象使用字符串值,它内部会调用Date.parse()并计算出合适的值。这个函数支持RFC2888规范和ISO8601规范。然而,正如MDN的Date.parse()文档中所描述的那样,该方法的返回值因浏览器而异,而且字符串类型的格式会影响准确值的预测。因此,建议不要使用此方法。

例如,像2015-10-12 12:00:00这样的字符串在Safari和Internet Explorer上返回NaN,而同样的字符串在Chrome和Firefox上返回当地时区。在某些情况下,它返回的值是基于UTC标准的。

用服务器数据生成Date对象实例

现在让我们假设你将从服务器接收数据。如果数据是Unix时间值的数值,你可以简单地使用构造函数来创建一个Date对象。虽然我在前面跳过了解释,但当Date构造函数接收一个单一的值作为唯一的参数时,它将被识别为以毫秒为单位的Unix时间值。(注意:JavaScript以毫秒为单位处理Unix时间。JavaScript处理Unix时间的单位是毫秒。这意味着第二个值必须乘以1,000)。) 如果你看到下面的例子,结果的值和前面的例子是一样的。

const d1 = new Date(1489199400000);
d1.toString(); // Sat Mar 11 2017 11:30:00 GMT+0900 (KST)

那么如果用ISO-8601等字符串类型代替Unix时间呢?正如我在上一段解释的那样,Date.parse()方法是不可靠的,最好不要使用。不过由于ECMAScript 5或更高版本指定了对ISO-8601的支持,所以在支持ECMAScript 5的Internet Explorer 9.0或更高版本的Date构造函数中,如果仔细使用,可以使用ISO-8601指定格式的字符串。 如果您使用的浏览器不是最新版本,请务必保留最后的Z字母。如果没有这个字母,您的旧浏览器有时会根据您的当地时间而不是UTC进行解释。下面是一个在Internet Explorer 10上运行的例子。

const d1 = new Date('2017-03-11T11:30:00');
const d2 = new Date('2017-03-11T11:30:00Z');
d1.toString(); // "Sat Mar 11 11:30:00 UTC+0900 2017"
d2.toString(); // "Sat Mar 11 20:30:00 UTC+0900 2017"

根据规范,两种情况下的结果值应该是相同的。但是,正如你所看到的,d1.toString()和d2.toString()的结果值是不同的。在最新的浏览器上,这两个值将是相同的。为了防止这种版本问题,如果没有时区数据,你应该在字符串的最后加上Z。

把Date实例传输到服务端

现在使用前面创建的Date对象,你可以根据当地的时区自由地增减时间。但是不要忘记在处理结束后将你的数据转换回以前的格式,然后再传回服务器。 如果是Unix时间,你可以简单地使用getTime()方法来执行。注意使用的是毫秒)。

const d1 = new Date(2017, 2, 11, 11, 30);
d1.getTime(); // 1489199400000

ISO-8601格式的字符串怎么办?如前所述,支持ECMAScript 5或更高版本的Internet Explorer 9.0或更高版本支持ISO-8601格式。您可以使用toISOString()或toJSON()方法创建ISO-8601格式的字符串。toJSON()可以用于JSON.stringify()或其他方法的递归调用)。这两种方法产生的结果是一样的,除了它处理无效数据的情况。

const d1 = new Date(2017, 2, 11, 11, 30);
d1.toISOString(); // "2017-03-11T02:30:00.000Z"
d1.toJSON();      // "2017-03-11T02:30:00.000Z"
const d2 = new Date('Hello');
d2.toISOString(); // Error: Invalid Date
d2.toJSON();      // null

你也可以使用toGMTString()或toUTCString()方法来创建UTC的字符串。由于它们返回的是符合RFC-1123标准的字符串,你可以根据需要利用这个方法。 Date对象包括toString()、toLocaleString()及其扩展方法。然而,由于这些方法主要用于返回基于本地时区的字符串,而且它们会根据你的浏览器和使用的操作系统返回不同的值,所以它们并不是很有用。

修改客户端时区

现在你可以看到JavaScript提供了一点时区的支持。如果你想在你的应用程序中改变本地时区设置而不遵循操作系统的时区设置怎么办?或者如果你需要在一个应用程序中同时显示多个时区怎么办?就像我多次说过的,JavaScript不允许手动更改本地时区。唯一的解决办法就是在你已经知道时区偏移值的前提下,从日期中添加或删除偏移值。不过先别沮丧。让我们来看看是否有任何解决方案来规避这个问题。

我们继续前面的例子,假设浏览器的时区设置为首尔。用户根据首尔时间输入2017年3月11日上午11:30,并希望用纽约当地时间查看。服务器以毫秒为单位传输Unix时间数据,并通知纽约的偏移值为-05:00。那么如果你只知道当地时区的偏移值,就可以转换数据。

在这种情况下,你可以使用getTimeZoneOffset()方法。这个方法是JavaScript中唯一可以用来获取本地时区信息的API。它以分钟为单位返回当前时区的偏移值。

const seoul = new Date(1489199400000);
seoul.getTimeZoneOffset(); // -540

返回值为-540表示时区比目标时间提前540分钟。要注意的是,数值前面的负号与首尔的正号(+09:00)是相反的。我不知道为什么,但就是这样显示的。如果我们用这种方法计算纽约的偏移量,我们将得到60*5=300。将840的差值转换为毫秒,并创建一个新的Date对象。然后你可以使用该对象的getXX方法将该值转换为你选择的格式。让我们创建一个简单的格式化函数来比较结果。

function formatDate(date) {
  return date.getFullYear() + '/' + 
    (date.getMonth() + 1) + '/' + 
    date.getDate() + ' ' + 
    date.getHours() + ':' + 
    date.getMinutes();
const seoul = new Date(1489199400000);
const ny = new Date(1489199400000 - (840 * 60 * 1000));
formatDate(seoul);  // 2017/3/11 11:30
formatDate(ny);     // 2017/3/10 21:30

formatDate()根据首尔和纽约之间的时区差异显示了正确的日期和时间。看来我们找到了一个简单的解决方案。那么,如果我们知道该地区的偏移量,是否可以将其转换为当地时区呢?很遗憾,答案是 "不能"。还记得我之前说过的话吗?时区数据是一种包含所有偏移量变化历史的数据库?为了得到正确的时区值,你必须知道日期时的偏移值(而不是当前日期的)。

修改本地时区所导致的问题

如果你再继续用上面的例子工作,你很快就会面临一个问题。用户想查看纽约当地时间的时间,然后把日期从10号改成15号,如果使用Date对象的setDate()方法,可以在保持其他值不变的情况下改变日期。如果使用Date对象的setDate()方法,就可以在保持其他值不变的情况下改变日期。

ny.setDate(15);
formatDate(ny);   // 2017/3/15 21:30

看上去很简单,但这里却隐藏着一个陷阱。如果你要把这些数据传回服务器,你会怎么做?由于数据已经被改变,你不能使用getTime()或getISOString()等方法。因此,你必须在将数据传回服务器之前,重新进行转换。

const time = ny.getTime() + (840 * 60 * 1000);  // 1489631400000
> 有些人可能会奇怪为什么我添加了使用转换后的数据反正我必须在返回之前将其转换回来看起来我可以不转换就直接处理只在格式化的时候临时创建一个转换后的Date对象然而事实并非如此如果把基于首尔时间的Date对象的日期从11日改成15日会增加4天24*4*60*60*1000)。然而在纽约当地时间由于日期从10日改为15日结果增加了5天(24*5*60*60*1000)这意味着你必须根据当地的偏移量来计算日期以获得精确的结果
问题还不止于此还有一个问题在等着你你不会通过简单的加减偏移得到想要的值由于3月12日是纽约当地时间DST的起始日期2017年3月15日的偏移量应该是-04:00而不是-05:00所以当你恢复换算的时候应该加上780分钟比之前少了60分钟
```javascript
const time = ny.getTime() + (780 * 60 * 1000);  // 1489627800000
相反,如果用户的本地时区是纽约,想知道首尔的时间,则不必要地应用DST,从而导致另一个问题。

简单的说,你不能仅仅使用得到的偏移量来执行基于你所选择的时区的精确操作。如果你回忆一下我们在本文前面所讨论的内容,你就会很容易知道,如果你知道夏季时间的规则,这种转换还是有漏洞的。为了得到准确的值,你需要一个包含整个偏移变化历史的数据库,比如IANA时区数据库。 要解决这个问题,必须存储整个时区数据库,每当从Date对象中检索到日期或时间数据时,就找到日期和相应的偏移量,然后用上面的过程进行转换。从理论上讲,这是可行的。但实际上,这需要花费太多精力,而且测试转换后的数据的完整性也会很艰难。但先别失望。直到现在,我们讨论了JavaScript的一些问题以及如何解决这些问题。现在我们已经准备好使用一个完善的库了。

Moment 时区

Moment是一个成熟的JavaScript库,几乎是处理日期的标准。提供了各种日期和格式化的API,最近它的稳定和可靠得到了这么多用户的认可。还有Moment Timezone这个扩展模块,可以解决上面讨论的所有问题。这个扩展模块包含了IANA时区数据库的数据,可以精确计算偏移量,并提供了多种API,可以用来更改和格式化时区。

在本文中,我不会详细讨论如何使用库或库的结构。我只会告诉你如何简单地解决我前面讨论的问题。如果大家有兴趣的话,可以看一下Moment Timezone的文档。

我们用时刻时区来解决图中所示的问题。

const seoul = moment(1489199400000).tz('Asia/Seoul');
const ny = moment(1489199400000).tz('America/New_York');