前端时间校验与校准
在最近的业务中,有一个需求是根据用户本地时间进行处理的,在处理时发现自己之前对于时间校验和校准的理解存在误解,于是将相关问题记录下来。
预备知识
时间戳
时间戳记或称为时间标记(英语:Timestamp)是指字符串或编码信息用于辨识记录下来的时间日期。国际标准为ISO 8601。 - 维基百科
时间不分东西南北、在地球的每一个角落都是相同的。他们都有一个相同的名字,叫时间戳。
时间戳用来唯一地标识某一刻的时间。数字时间戳技术是数字签名技术一种变种的应用,其具体含义是指格林威治时间1970年01月01日00时00分00秒(北京时间1970年01月01日08时00分00秒)起至现在的总秒数。
因此,不论在哪个时区,获取的时间戳应该是一样的,所以当接口返回1595338556343
时,我们可以明确知道它表示的是北京时间2020年7月21号21点35分56秒。
时区
参考
尽管时间戳是一样的,但由于地球自转一天需要一天,因此在同一个时刻各个地方时间的表达方式是不同的,比如写这篇文章时北京时间是21点36分,但向东的东京时间已经是22点36分,而向西的印度新德里时间却是19点06分。
所以当接口返回2020-07-22 00:00:00
时,我们并不知道他对应的具体时间戳
- 当时区为
Asia/Chongqing
时,对应的时间戳为1595347200
- 当时区为
Asia/Tokyo
时,对应的时间戳为1595343600
因此需要我们自己实现将时间字符串转换成对应时区时间的功能,在此之前,还必须了解各个时区的含义。
由于地球自西向东自转,因此东边的地区会先看见太阳升起,因此东边的时间也比西边的早,对于出国旅行的人,
- 向西走时每经过一个时区,就需要将手表往回拨一个小时;
- 向东走时每经过一个时区,就需要将手表往前拨一个小时;
按照规定,英国(格林尼治天文台旧址)为本初子午线,即零度经线,然后东西各12个时区,每个时区跨越经度为15度,其中东部时区使用+
加数字表示,西部时区使用-
加时区表示,
此外,关于时区还有一个夏令时的概念:为了节约电能,在天亮早的夏天将时间调快一个小时,可以使人早起早睡,减少照明量...各个采纳夏时制的国家具体规定不同。全世界有近110个国家每年要实行夏令时。(虽然我觉得这是一个掩耳盗铃的举动~
下面是一些计算例子
- 北京时间(东8区)为中午12点,求伦敦时间(中时区)时间,12 - (8-0) 则结果为早上4点,伦敦夏令时的时候为早上5点
- 北京时间(东8区)为中午12点,求东京时间(东9区)时间, 12 -(8-9),则结果为13点
- 北京时间(东8区)为5月1号中午12点,求纽约时间(西5区)时间, 12 -(8--5) = -1,结果为负时向前一天 -1 + 24,则结果为4月30号晚上23点
OK,现在对于时区就有了一个比较直观的概念,也知道了两个时区之间的时间转换,接下来实现一个将上面的时间字符串转换为对应时区日期的方法
function transformLocalDate(str, timezone){
let now = new Date(str); // 在默认不指定时区的情况下,使用`new Date(str)`会使用本地时区,
let offsetGMT = now.getTimezoneOffset() // 这里的单位是分钟
let target = new Date(+now + offsetGMT * 60 * 1000 + timezone * 60 * 60 * 1000);
return target
}
transformLocalDate('2020-07-22 00:00:00', +9) // 转换为为东9区的
这里需要注意的是,对于YYYY-MM-DD
和YYYY-MM-DD HH:mm:ss
的字符串而言,Date的内部处理是不一样的,前者将会被处理成UTC(0时区时间)而后者将会被处理成本地时间
new Date('2020-07-22 00:00:00')
// Wed Jul 22 2020 00:00:00 GMT+0800 (中国标准时间)
new Date('2020-07-22')
// Wed Jul 22 2020 08:00:00 GMT+0800 (中国标准时间)
MDN上给出了比较明确的建议:不推荐使用Date构造函数来解析日期字符串(但这却是一个很常见的写法...
有时候我们会看见一些不一样的日期字符串
2020-07-07T15:45:43.736Z
,通过Date.prototype.toISOString
获得,Tue Jul 07 2020 23:45:57 GMT+0800 (中国标准时间)
,Date对象默认toString
方法
上面这些都是已经携带了时区信息的字符串,也可以作为构造参数传递给Date
构造函数。但是即使在初始化Date时传入了时区信息,最后获取的日期对象还是会被转换成本地时区,原因是JavaScript原生日期对象并不支持获得一个指定时区的日期对象
// 本地为东8区
var date1 = new Date('August 19, 1975 23:15:30 GMT+07:00');
var date2 = new Date('August 19, 1975 23:15:30 GMT-02:00');
console.log(date1.getTimezoneOffset()); // -480
console.log(date2.getTimezoneOffset()); // -480
在第三方库如moment中,提供了parseZone和插件moment-timezone
等工具用于解析带时区的日期字符串。
时间校验
由于可以手动修改系统时间,因此用户本地的时间不一定是准确的。换言之,我们需要对用户的本地时间进行校验。
由于时间戳在同一时间各个时区的值是一样的,那么理论上在不修改本地时间的情况下,服务端和客户端应该获取到相同的时间戳。
因此,只需要服务端返回接口返回一个当前时间戳,然后与用户端本地的当前时间戳进行比较即可,考虑到网络传输带来的延时,可能需要提供一个误差精度范围。
const timestamp = await getServerTimestamp()
const localTimestamp = +new Date()
const diff = 5000
if(Math.abs(timestamp - localTimestamp) > diff){
console.error('本地时间有误')
}
如果接口返回的不是时间戳,而是时间字符串,那么只能先跟后端交涉换成时间戳格式,或者前端手动将时间字符串转成时间戳
function getBeijingTimestamp(str) {
// 由于服务器返回的格式为YYYY-MM-DD HH:mm:ii,约定为北京时间,但是没有携带时区信息;如果服务器返回时间戳则不需要进行该步骤
const date = moment.utc(str) // 因此先将其转换成utc时间
const timezone = 8 // 目标时区时间,东八区
return +date - timezone * 60 * 60 * 1000
}
// 然后执行上面的校验流程
总是,如果需要校验用户是否修改了本地时间,就可以通过对比本地时间戳与服务器时间戳进行判断。
时间校准
对于需要渲染倒计时提示的业务,如活动开始、秒杀倒计时,往往需要进行时间校准,对于这种业务,一般的处理方法为:
- 初始化时调用后台接口获取活动截止时间,与用户当前时间比较,计算剩余时长
- 开启定时器,根据剩余时长渲染倒计时
但是深究一下,里面还有很多需要考虑的细节问题。首先要确认的是:倒计时到底是相对于哪个时间校准的倒计时? 比如产品说活动在“20号凌晨0点开始”,指的是服务器的时间(假设是北京时间),还是一个位于伦敦的用户的本地时间(19号下午5点)
- 如果是用户的本地时间,常见的场景如8点开始早起活动打卡,接口可以返回不带时区的字符串格式
YYYY-MM-DD HH:mm:ss
- 如果是服务端的时间,常见的场景如某个商品的秒杀开始时间,接口可以返回与时区无关的时间戳,然后由前端根据时间戳展示对应本地时间的倒计时
第二个问题是:如果用户本地时间并不准确,该如何渲染正确的倒计时?一种粗暴的解决方案是直接通过上面的时间校验进行检测,并在不通过时提示用户校对时间(可能过不了产品这一关);所以接下来需要考虑一下如何解决这个问题。
思路一
所有需要使用日期的地方均通过接口获取数据,如果有需要再转换成用户本地时区的时间,避免使用用户本地不受信任的日期。存在的问题有
- 频繁请求可能导致服务端压力增大,在上面的倒计时场景下,每一次更新都需要从服务端拉去数据,如果是每一秒更新一下倒计时,对于1000个用户而言,服务器每秒都会接收到1000个查询当前时间请求
- 从原本同步获取日期数据变成异步网络请求,导致倒计时可能不是平滑地更新,影响页面展示和用户体验
通过偏移量计算服务端时间
参考:客户端秒级时间同步方案
在上面的思路中,我们需要每次都去获取服务端的时间,这一步很明显是可以进行优化的
- 在初始化应用时调用接口获取服务器初始
serverInitTime
,以及本地初始时间localInitTime
- 在获取本地时间的地方得到
localCurrentTime
,此时对应的服务器时间应该就是serverInitTime + (localCurrentTime - localInitTime)
了
let serverInitTime, localInitTime
function getServerInitTime(){
return 1000
}
async function initAdjustTime(){
serverInitTime = await getServerInitTime(); // 接口响应时服务端的本地时间
localInitTime = +new Date() // 初始化时用户本地时间
}
function getCurrentTime(){
if(!serverInitTime) {
console.error('日期校验暂未初始化')
return
}
const localCurrentTime = +new Date()
return serverInitTime + (localCurrentTime - localInitTime)
}
async function test(){
await initAdjustTime()
console.log('init: ' + serverInitTime)
// 如果在此之前修改了本地时间,则会得到错误的结果
document.addEventListener("click", ()=>{
const now = getCurrentTime()
console.log(now)
}, false)
}
test()
在实际场景中getServerInitTime
是一个异步的接口,返回的是服务端接收到请求时的服务端时间serverInitTime
,在接口响应到达浏览器时才初始化localInitTime
,这个过程存在接口响应的延迟delta
,因此通过上面公式计算得到的当前服务端时间会比真实慢delta
,在对于时间精度要求不高的业务场景下是可以接受的。
但是,我们不得不考虑一个新的问题,如果在获取到localInitTime
后至调用getCurrentTime
获取localCurrentTime
,如果用户本地时间发生了调整,那么通过上面公式计算得到的服务端时间就不正确了。
因此,使用一个不随本地时间变化的维度作为校对的标准是最理想的
performance.now
浏览器提供了一个performance.timeOrigin
,可以大致理解为整个页面加载的初始时间,其具体计算规则参考MDN文档
performance.now
接口返回值表示从timeOrigin
之后到当前调用时经过的时间,主要用来测试某个函数的执行时间等
let t0 = window.performance.now();
doSomething();
let t1 = window.performance.now();
console.log("doSomething函数执行了" + (t1 - t0) + "毫秒.")
performance.now
是一个与用户本地时间无关的数据,它是以一个恒定的速率慢慢增加的,因此可以用来替代上面的localInitTime
和localCurrentTime
let serverInitTime, localInitTime
async function initAdjustTime(){
serverInitTime = await getServerInitTime(); // 接口响应时服务端的本地时间
localInitTime = performance.now() // 初始化时用户本地时间
}
function getCurrentTime(){
if(!serverInitTime) {
console.error('日期校验暂未初始化')
return
}
const localCurrentTime = performance.now()
return serverInitTime + (localCurrentTime - localInitTime)
}
async function test(){
await initAdjustTime()
console.log('init: ' + serverInitTime)
// 即使修改了本地时间,得到的还是正确的服务端时间
document.addEventListener("click", ()=>{
const now = getCurrentTime()
console.log(now)
}, false)
}
// console.log(performance.timeOrigin)
test()
这样就能得到比较准确的服务端时间了。
处理系统恢复休眠
当我最开使用performance.now
来计算时间偏移量之后,以为从此变高枕无忧了,甚至还沾沾自喜了一段时间,直到某一天我发现了这样一个场景
- 打开页面,会使用
performance.now
获取开始时间 - 然后一通操作,没啥问题,合上电脑准备歇了,注意这里没有关闭页面,系统处于休眠状态(自动用了Mac之后就很少关过机,基本上打开电脑就能恢复上次的工作环境
- 过了大概一个小时,重新打开电脑,继续上次打开的页面访问,突然发现,时间对不上了!!!
在系统休眠期间,performance.now
应该是不会增加的,当自系统休眠之后继续操作时,localCurrentTime - localInitTime
就无法用来表示服务器已经走过的时间了。实际上,当系统处于休眠状态时,不会执行任何JS代码,因此在系统恢复休眠时,我们需要重新更新localInitTime
、serverInitTime
等数据,查了一下,貌似使用visibilitychange
事件可以达到我们的目的
// 初始化模块时就获取服务器的基准时间,服务器时区以北京时间为准
setTimeout(initAdjustTime)
// 处理系统休眠时当前performance.now滞留的问题
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
initAdjustTime()
}
})
小结
时间与时区是前端业务中经常碰见的问题,在最开始都是new Date(xxx)
一把梭,后来才发现里面还是有很多门道的~
本文首先整理了时间戳与时区的概念,然后探究了一些业务问题及解决方案
- 通过时间戳校验用户本地时间
- 通过服务端初始时间与
timeOrigin
校准用户本地时间
以后遇见此类问题,还是要多思考一下
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。