一、事情是这样的
在一个平凡的下午,刚发完版本的我想着:今天真是完美的一天,版本顺顺利利,今晚可以早点回家睡觉了。这时,不出意外就是要出意外了。线上收到反馈,一位同事去了美国出差,在创建活动的时候发现:创建出来的活动开始时间竟然比设置的晚15个小时。当然第一时间就让这位同事暂停活动,并且切换中国时区重新新建,线上先恢复。
但是这个问题还是得修,怎么办,看呗。
二、现状是怎么样的
在页面上有一个这样的输入框,要求用户选择UTF+8的时间:
拿到选择的字符串后,我们就会使用new Date().getTime()获取时间戳传递给后台:
let dateStr = '2024/07/06 10:00:00'
let localTimeStamp = new Date(dateStr).getTime() // 传递给后台
理论上根据选择的时间,再转换为时间戳,最后传递给后台这个流程应该是对的。那么问题到底出在了哪里?
三、问题分析
我们把数值打印出来看看:
console.debug('vince', data?.activity_time?.[0]);
console.debug('vince', new Date(data?.activity_time?.[0]).getTime());
此时,我们惊讶的发现了问题,罪魁祸首就是API:new Date().getTime() 。同一个时间字符串,在不同的时区下,转换出来的时间戳是不一样的。
从图三我们能很清晰的看出,经过new Date()之后,时间字符串看起来好像没有变化,但是实际上已经被带上了时区的概念。这里开始出现了偏差。
GPT老师:JavaScript 中的 Date 对象包含时区信息,以确保在全球范围内日期和时间的准确性和一致性。当你创建一个新的 Date 对象时,它会根据系统的时区设置来表示当前时间,并在转换为字符串时包含时区信息。这是为了确保在不同的时区之间进行一致的时间表示。并且,Date对象不支持传入时区来指定字符串时区信息。
要解决这个问题,在不引入第三方包的情况下,就需要我们手动调整时差偏移,由于new Date会隐含根据当前时区进行转换,因此我们需要先抹平这个时差,再转换成UTC8时间字符串对应的时间戳,这里有点绕,我们先来看看时间戳的概念:
时间戳(TimeStamp)是用来表示特定时间点的数值。它通常是一个整数,表示从某个时间点(通常是1970年1月1日00:00:00 UTC,即Unix纪元)开始经过的秒数或毫秒数。 因此单纯的时间戳是没有任何时区概念的,指的就是Unix纪元的毫秒偏移量。
因此单纯的时间戳是没有任何时区概念的,指的就是Unix纪元的毫秒偏移量。
但是,还记得new Date()会把时区算进去么,我们看看对到new Date()来说,不同时区的0节点,对应的时间是什么:
那么同一个时间字符串,经过偏移之后其实对应的时间戳是不一样的:
由于new Date()会把当前时间转换为当前时区的时间,那么此时的时间就会根据时区偏移,时间戳也会偏移:
我们可以这样理解,由于UTC+8的“时间戳0节点”是更晚的,所以达到同一时间,需要的偏移量更少。而UTC-7的“时间戳0节点”是更早的,所以达到同一时间,需要的偏移量更多。
为了更好的理解这里的关系和为了后续算法更好的说明,我们把“0”节点挪到同一个起点:
结合交互,我们可以看到,最终我们期望拿到的时间戳,是UTC+8的时间对应的时间戳,又由于new Date()会认为这个“时间”是跟本地时区有关的,所以这里的计算要分为两部分:
- 先把转换后的时间戳,减去当前时区到UTC0的偏移;
- 再加上目标时区的偏移量;
问题解决
问题分析完之后,我们只需要根据上面的思路撸代码就好了,这里有个核心的API:new Date().getTimezoneOffset() ,它可以获取当前跟UTC+0时区的偏移。例如:当前时区为 UTC+8时,就会返回 -8(因为UTC+8 减去 8才能回到UTC)。根据这个API我们可以编排以下代码:
const activityTime // 假设有一个时间字符串
const timezone = new Date().getTimezoneOffset() / 60; // 时区偏移量
const hourStamp = 60 * 60 * 1000 // 一小时的毫秒数
const originTimeStamp = new Date(activityTime).getTime() //偏移后的时间戳
// 标准时间
const basicTimeStamp = originTimeStamp - timezone * hourStamp
// 期望的时间戳
const UTC8TimeStamp = basicTimeStamp - 8 * hourStamp
优化一下算法:
const activityTime // 假设有一个时间字符串
const timezone = new Date().getTimezoneOffset() / 60; // 时区偏移量
const hourStamp = 60 * 60 * 1000 // 一小时的毫秒数
const getLocalTimeFromUtc = (time: string, utc: string) => {
const timezoneOffset =
new Date().getTimezoneOffset() / 60 + Number(utc.replace(/UTC/i, ''));
return Number(time) - timezone * 60 * 60 * 1000;
};
const originTimeStamp = new Date(activityTime).getTime() //偏移后的时间戳
const UTC8TimeStamp = getLocalTimeFromUtc(originTimeStamp, 'UTC+8')
展示要做的转换
保存的时候我们减去的偏移量,那我们展示的时候就要加回来,这样我们通过new Date().toString()之后,才能得到我们原来想要展示的字符串:
const activityTime // 假设有一个时间字符串
const timezone = new Date().getTimezoneOffset() / 60; // 时区偏移量
const hourStamp = 60 * 60 * 1000 // 一小时的毫秒数
const getLocalTimeFromUtc = (time: string, utc: string) => {
const timezoneOffset =
new Date().getTimezoneOffset() / 60 + Number(utc.replace(/UTC/i, ''));
return Number(time) + timezone * 60 * 60 * 1000;
};
const originTimeStamp = new Date(activityTime).getTime() //偏移后的时间戳
const UTC8TimeStamp = getLocalTimeFromUtc(originTimeStamp, 'UTC+8')
四. 长期解决方案
经过上面的一顿分析,其实问题基本已经解决了,但是项目中隐含的“炸弹”其实还很多。因为很多时候我们处理时间的时候并不是直接用原生的js处理的, 可能还需要经过一些第三方包(dayJs,moment Js)。接下来会根据不同的情况详细的阐述我们开发的时候需要注意的地方。
1. 时间处理util
对到原生的JS,其实只需要复用上述的util逻辑即可:
// 保存的时候的算法
const getLocalTimeFromUtc = (time: string, utc: string) => {
const timezoneOffset =
new Date().getTimezoneOffset() / 60 + Number(utc.replace(/UTC/i, ''));
return Number(time) - timezone * 60 * 60 * 1000;
};
// 展示的时候的算法
const getLocalTimeFromUtc = (time: string, utc: string) => {
const timezoneOffset =
new Date().getTimezoneOffset() / 60 + Number(utc.replace(/UTC/i, ''));
return Number(time) + timezone * 60 * 60 * 1000;
};
2.第三方包
我们一般使用的第三方包有两个:dayJs和momentJs,这两个包主要是协助我们做时间的对比以及时间的格式化等。但是这两个包原生也是不支持时间区的转换的,假如直接使用这两个包就会同时踩入上面所说的坑里面:
当然作为成熟的第三方包,他们都各自针对这种情况给出了不同的解决方案。
2.1 dayJS
dayJs默认情况下不支持时区操作。但是,可以通过插件扩展来处理时区问题。Day.js 提供了一个名为 dayjs/plugin/utc 的插件来处理 UTC 时间,并提供了 dayjs/plugin/timezone 插件来处理时区转换。
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
2.1.1 plugin/utc
dayjs-plugin-utc 插件主要用于处理协调世界时(UTC)。它提供了将本地时间转换为 UTC+0 时间的功能,以及在 UTC+0时间和本地时间之间进行转换的功能。接下来我们试用一下:
console.debug('vince', dayjs().utc()); // 先是用插件用当前时间生成一个dayjs的utc对象
console.debug('vince', dayjs().utc().format()); // 再转换为时间字符串
dayjs().utc() 生成的时间对象仍然会保留本地时区的信息,但时间本身已经转换为 UTC 时间。
当然我们也可以传入自定的时间字符串和时间戳给它进行转换,不同的用法返回的时间不一样:
// 自定义格式的时间字符串
const customString = '2024/08/04 17:10:15';
const customFormat = 'YYYY/MM/DD HH:mm:ss';// 使用 dayjs.utc() 解析和转换
// 根据2024/08/04 17:10:15获取的UTC+8时的时间戳,对应UTC+02024-08-04T09:10:15
const unixTimestamp = 1722762615000;
// 用法一: 会认为当前传入的是UTC+0的时间
const utcFromCustom = dayjs.utc(customString, customFormat).format();
//输出:2024-08-04T17:10:15Z
// 用法二:会根据当前本地时间计算偏移得出UTC+0时间
const utcFromCustom = dayjs(customString).utc().format();
// 输出 2024-08-04T09:10:15Z
// 用法三:以下两种用法会认为当前传入的是UTC+0的时间
const utcFromUnix = dayjs.utc(unixTimestamp).format();
const utcFromUnix = dayjs(unixTimestamp).utc().format();
// 输出 2024-08-04T09:10:15Z
2.1.2 plugin/timezone
dayjs-plugin-timezone 插件用于处理时区转换。它允许你将日期和时间转换为指定的时区,并在不同的时区之间进行转换。
我们用上面的用例试验一下:
const timeInAmerica = dayjs().tz('America/Los_Angeles').format();
const timeInShanghai = dayjs().tz('Asia/Shanghai').format();
// 根据当前每个时区当前时间,生成对象
console.debug('vince', timeInAmerica);
// 输出: vince 2024-08-04T02:52:12-07:00
console.debug('vince', timeInShanghai);
// 输出:vince 2024-08-04T17:52:12+08:00
时间转换:
// 自定义格式的时间字符串
const customString = '2024/08/04 17:10:15';
const customFormat = 'YYYY/MM/DD HH:mm:ss';// 使用 dayjs.utc() 解析和转换
// 根据2024/08/04 17:10:15获取的UTC+8时的时间戳,对应UTC+02024-08-04T09:10:15
const unixTimestamp = 1722762615000;
// 用法一:直接把输入时间加入时区属性
const utcFromUnixAmerica = dayjs
.tz(customString, customFormat, 'America/Los_Angeles')
.format();
// 输出: 2024-08-04T17:10:15-07:00
const utcFromUnixAsia = dayjs
.tz(customString, customFormat, 'Asia/Shanghai')
.format();
// 输出 2024-08-04T17:10:15+08:00
// 用法二:认为传入字符串为当前时区时间,并且自动转换为目标时区的相对时间
const utcFromUnixAmerica = dayjs(customString)
.tz('America/Los_Angeles')
.format();
// 输出: vince 2024-08-04T02:10:15-07:00
const utcFromUnixAsia = dayjs(customString).tz('Asia/Shanghai').format();
// 输出: vince 2024-08-04T17:10:15+08:00
// 用法三:把当前时间戳转为对应的时区时间
const utcFromUnixAmerica = dayjs(unixTimestamp)
.tz('America/Los_Angeles')
.format();
// 输出: vince 2024-08-04T02:10:15-07:00
const utcFromUnixAsia = dayjs(unixTimestamp).tz('Asia/Shanghai').format();
// 输出: vince 2024-08-04T17:10:15+08:00
2.1.3 获取时间戳
我们只需要把上面的dayJs对象使用valueOf()就可以获取对应的时间戳:
const startTime = dayjs(data?.activity_time?.[0])
.tz('Asia/Shanghai')
.valueOf();
2.1.4 总结
根据上面两个plugin,其实我们已经能十分方便的无视用户本地时区来转换时间戳了,但是具体使用的参数需要根据实际的业务来定,如我上面的交互是要求用户输入UTC+8的,那么我们就需要这样做:
import dayjs from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
dayjs.extend(timezone);
// 保存
const startTime = dayjs
.tz(data?.activity_time?.[0], 'Asia/Shanghai')
.valueOf();
// 取出展示时
const startTime = dayjs
.tz(data?.activity_time?.[0], 'Asia/Shanghai')
.valueOf();
2.2 Moment
moment和dayJs的用法其实很类似,但是moment如果希望支持时区的话需要安装moment-timezone:
pnpm i moment-timezone
2.2.1 timezone转换
这里的用法和dayjs基本一致,所以复用上面的用例:
// 会根据当前UTC+0的时间,生成对应时区的时间
momentjs().tz('Asia/Shanghai').format() // 2024-08-04T21:20:17+08:00
momentjs().tz('America/Los_Angeles').format() // 2024-08-04T06:20:17-07:00
时间转换:
// 自定义格式的时间字符串
const customString = '2024/08/04 17:10:15';
const customFormat = 'YYYY/MM/DD HH:mm:ss';// 使用 dayjs.utc() 解析和转换
// 根据2024/08/04 17:10:15获取的UTC+8时的时间戳,对应UTC+02024-08-04T09:10:15
const unixTimestamp = 1722762615000;
// 方式一 直接把输入时间加入时区属性
const utcFromUnixAmerica = momentjs
.tz(customString, customFormat, 'America/Los_Angeles')
.format();
// 输出: 2024-08-04T17:10:15-07:00
const utcFromUnixAsia = momentjs
.tz(customString, customFormat, 'Asia/Shanghai')
.format();
// 输出: 2024-08-04T17:10:15-07:00
// 方式二 认为传入字符串为当前时区时间,并且自动转换为目标时区的相对时间
const utcFromUnixAmerica = momentjs(customString)
.tz('America/Los_Angeles')
.format();
// 输出: 2024-08-04T02:10:15-07:00
const utcFromUnixAsia = momentjs(customString).tz('Asia/Shanghai').format();
// 输出: 2024-08-04T17:10:15+08:00
// 方式三:把当前时间戳转为对应的时区时间
const utcFromUnixAmerica = momentjs(unixTimestamp)
.tz('America/Los_Angeles')
.format();
// 输出: 2024-08-04T02:10:15-07:00
const utcFromUnixAsia = momentjs(unixTimestamp)
.tz('Asia/Shanghai')
.format();
// 输出: 2024-08-04T17:10:15+08:00
2.2.2 获取时间戳
const utcFromUnixAsia = momentjs(unixTimestamp)
.tz('Asia/Shanghai')
.valueOf();
2.2.3 总结
moment的使用跟dayJs基本类似,api的调用基本相同,因此针对业务可以参考dayJs上述例子即可。
2.3 时间对比
在日常的业务中,我们其实除了时间戳的转换,还经常用到时间对比,但是在使用时间对比的时候就不需要关注时区问题了。因为new出来的时间是本地时区的,转换后的时间也是本地时区的, 这样就会“错进错出”,两个时间对比就会是对的。
dayjs(Number(registration_end_time)).valueOf() > dayjs().valueOf(); // it works
五、总结
因为这个问题,美好的两天无了。orz