前端时间校验与校准

在最近的业务中,有一个需求是根据用户本地时间进行处理的,在处理时发现自己之前对于时间校验和校准的理解存在误解,于是将相关问题记录下来。

<!--more-->

1. 预备知识

1.1. 时间戳

时间戳记或称为时间标记(英语:Timestamp)是指字符串或编码信息用于辨识记录下来的时间日期。国际标准为ISO 8601。 - 维基百科

时间不分东西南北、在地球的每一个角落都是相同的。他们都有一个相同的名字,叫时间戳

时间戳用来唯一地标识某一刻的时间。数字时间戳技术是数字签名技术一种变种的应用,其具体含义是指格林威治时间1970年01月01日00时00分00秒(北京时间1970年01月01日08时00分00秒)起至现在的总秒数。

因此,不论在哪个时区,获取的时间戳应该是一样的,所以当接口返回1595338556343时,我们可以明确知道它表示的是北京时间2020年7月21号21点35分56秒。

1.2. 时区

参考

尽管时间戳是一样的,但由于地球自转一天需要一天,因此在同一个时刻各个地方时间的表达方式是不同的,比如写这篇文章时北京时间是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-DDYYYY-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等工具用于解析带时区的日期字符串。

2. 时间校验

由于可以手动修改系统时间,因此用户本地的时间不一定是准确的。换言之,我们需要对用户的本地时间进行校验。

由于时间戳在同一时间各个时区的值是一样的,那么理论上在不修改本地时间的情况下,服务端和客户端应该获取到相同的时间戳。

因此,只需要服务端返回接口返回一个当前时间戳,然后与用户端本地的当前时间戳进行比较即可,考虑到网络传输带来的延时,可能需要提供一个误差精度范围。

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
}
// 然后执行上面的校验流程

总是,如果需要校验用户是否修改了本地时间,就可以通过对比本地时间戳与服务器时间戳进行判断。

3. 时间校准

对于需要渲染倒计时提示的业务,如活动开始、秒杀倒计时,往往需要进行时间校准,对于这种业务,一般的处理方法为:

  • 初始化时调用后台接口获取活动截止时间,与用户当前时间比较,计算剩余时长
  • 开启定时器,根据剩余时长渲染倒计时

但是深究一下,里面还有很多需要考虑的细节问题。首先要确认的是:倒计时到底是相对于哪个时间校准的倒计时? 比如产品说活动在“20号凌晨0点开始”,指的是服务器的时间(假设是北京时间),还是一个位于伦敦的用户的本地时间(19号下午5点)

  • 如果是用户的本地时间,常见的场景如8点开始早起活动打卡,接口可以返回不带时区的字符串格式YYYY-MM-DD HH:mm:ss
  • 如果是服务端的时间,常见的场景如某个商品的秒杀开始时间,接口可以返回与时区无关的时间戳,然后由前端根据时间戳展示对应本地时间的倒计时

第二个问题是:如果用户本地时间并不准确,该如何渲染正确的倒计时?一种粗暴的解决方案是直接通过上面的时间校验进行检测,并在不通过时提示用户校对时间(可能过不了产品这一关);所以接下来需要考虑一下如何解决这个问题。

3.1. 思路一

所有需要使用日期的地方均通过接口获取数据,如果有需要再转换成用户本地时区的时间,避免使用用户本地不受信任的日期。存在的问题有

  • 频繁请求可能导致服务端压力增大,在上面的倒计时场景下,每一次更新都需要从服务端拉去数据,如果是每一秒更新一下倒计时,对于1000个用户而言,服务器每秒都会接收到1000个查询当前时间请求
  • 从原本同步获取日期数据变成异步网络请求,导致倒计时可能不是平滑地更新,影响页面展示和用户体验

3.2. 通过偏移量计算服务端时间

参考:客户端秒级时间同步方案

在上面的思路中,我们需要每次都去获取服务端的时间,这一步很明显是可以进行优化的

  • 在初始化应用时调用接口获取服务器初始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,如果用户本地时间发生了调整,那么通过上面公式计算得到的服务端时间就不正确了。

因此,使用一个不随本地时间变化的维度作为校对的标准是最理想的

3.3. 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是一个与用户本地时间无关的数据,它是以一个恒定的速率慢慢增加的,因此可以用来替代上面的localInitTimelocalCurrentTime

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()

这样就能得到比较准确的服务端时间了。

3.4. 处理系统恢复休眠

当我最开使用performance.now来计算时间偏移量之后,以为从此变高枕无忧了,甚至还沾沾自喜了一段时间,直到某一天我发现了这样一个场景

  • 打开页面,会使用performance.now获取开始时间
  • 然后一通操作,没啥问题,合上电脑准备歇了,注意这里没有关闭页面,系统处于休眠状态(自动用了Mac之后就很少关过机,基本上打开电脑就能恢复上次的工作环境
  • 过了大概一个小时,重新打开电脑,继续上次打开的页面访问,突然发现,时间对不上了!!!

在系统休眠期间,performance.now应该是不会增加的,当自系统休眠之后继续操作时,localCurrentTime - localInitTime就无法用来表示服务器已经走过的时间了。实际上,当系统处于休眠状态时,不会执行任何JS代码,因此在系统恢复休眠时,我们需要重新更新localInitTimeserverInitTime等数据,查了一下,貌似使用visibilitychange事件可以达到我们的目的

// 初始化模块时就获取服务器的基准时间,服务器时区以北京时间为准
setTimeout(initAdjustTime)

// 处理系统休眠时当前performance.now滞留的问题
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') {
    initAdjustTime()
  }
})

4. 小结

时间与时区是前端业务中经常碰见的问题,在最开始都是new Date(xxx)一把梭,后来才发现里面还是有很多门道的~

本文首先整理了时间戳与时区的概念,然后探究了一些业务问题及解决方案

  • 通过时间戳校验用户本地时间
  • 通过服务端初始时间与timeOrigin校准用户本地时间

以后遇见此类问题,还是要多思考一下