⁣ 由浅入深,走进中级工程师都未必知道的 JavaScript 时间处理冷知识

avatar
前端工程师 @公众号:ELab团队

1 背景与基本概念

在过去,世界各地都各自订定当地时间,例如我国古代将一昼夜分为十二时辰,每一时辰相当于现代的两个小时。但随着交通和通信的发达,各地交流日益频繁,不同的地方时间给人们造成了许多困扰。于是在1884年的国际经度会议上制定了全球性的标准时,确定以英国伦敦格林威治区这个地方为零度经线的起点(本初子午线),并以地球由西向东每24小时自转一周360°,规定经度每隔15°,时差1小时,而每15°的经线则称为该时区的中央经线。全球被划分为24个时区,其中包含23个整时区及180°经线左右两侧的2个半时区。东经的时间比西经要早,也就是如果格林威治时间是中午12时,则中央经线15°E的时区为下午1时,中央经线30°E时区的时间为下午2时;反之,中央经线15°W的时区时间为上午11时,中央经线30°W时区的时间为上午10时。如果两人同时从格林威治的0°各往东、西方前进,当他们在经线180°时,就会相差24小时,所以经线180°被定为国际换日线,由西向东通过此线时日期要减去一日,反之,若由东向西则增加一日。

1.1 标准时间

  • GMT(Greenwich Mean Time),格林威治平均时间,又称为世界时,是一个天文概念
    • 十七世纪,格林威治皇家天文台为了海上霸权的扩张计划而进行天体观测。1675年格林威治皇家天文台建立。到了1884年国际经度会议上决定以通过格林威治的子午线作为划分地球东西两半球的经度零度。观测所门口墙上有一个标志24小时的时钟,显示当下的时间,对全球而言,这里所设定的时间是世界时间参考点,全球都以格林威治的时间作为标准来设定时间。事实上,世界时是并不准确的。格林威治以太阳经过格林威治天文台上空最高点位置时的时间为正午12点,但是地球的旋转速度其实是逐年减慢的,每一年都会差上零点几秒。
  • IAT(International Atomic Time),即原子时。在国际计量体系中,时间是七个基本量之一,以天文学为测量基础的格林威治时间,肯定无法满足科学精度的需要。于是,人类发明了原子钟,也就是利用原子内部电子在两个能级间跳跃时辐射出来的电磁波频率作为标准,来规定一秒的时长。GMT与IAT每年会有约0.9s的误差,主要是由地球不规则自转以及潮汐效应引起。
  • UTC(Universal Time Coordinated),即协调世界时,是世界时的一个版本,用于修正GMT
    • UTC是经过平均太阳时(以格林威治时间GMT为准)、地轴运动修正后的新时标以及以「秒」为单位的国际原子时所综合精算而成的时间。一般认为UTC和GMT是相等的。
    • 闰秒(或称为跳秒)是UTC对GMT作出加一秒或减一秒的调整。IAT的准确度为每日数纳秒,而世界时的准确度为每日数毫秒。为确保协调世界时与世界时相差不会超过0.9秒,在有需要的情况下会在协调世界时内加上正或负一整秒。这一技术措施就称为闰秒。闰秒成因原理科学上有两种时间计量系统:基于地球自转的天文测量而得出的“世界时”和以原子振荡周期确定的“原子时”。“世界时”由于地球自转的不稳定(由地球物质分布不均匀和其它星球的摄动力等引起的)会带来时间的差异,“原子时”(一种较恒定的时制,由原子钟得出)则是相对恒定不变的。这两种时间尺度速率上的差异,一般来说一至二年会差大约1秒时间,自1980年1月至今(2012年11月)已经正闰秒16次,如下图:

1.2 时区

  • 本地时间,UTC+时区。UTC或GMT与本地时区LT的换算关系:LT=UTC+时区差 。东区是加相应的时区差,西区是减时区差。如北京是东八区,则北京时间=UTC+8
  • DST,夏令时。是指夏天太阳升起比较早,将时钟拨快一个小时来提早日光的使用。欧美主要国家都引用了这个做法。如果在夏令时时区内 DST=UTC+时区+1。

2 计算机中的时间表示

以前的Unix操作系统中存储时间,是以32位有符号数来存储的。用32位来表示时间的最大间隔是68年,而最早出现的UNIX操作系统考虑到计算机产生的年代和应用的时限综合取了1970年1月1日0时0分0秒作为UNIX TIME的纪元时间(开始时间),将1970年作为中间点,向左向右偏移都可以照顾到更早或者更后的时间,因此将1970年1月1日0点作为计算机表示时间的原点,从1970年1月1日开始经过的秒数存储为一个32位整数。以后计算时间就把这个时间(1970年1月1日00:00:00)当做时间的零点。这种高效简洁的时间表示法,就被称为"Unix时间纪元"。

2.1 时间戳

  • Unix时间(戳),表示当前时间到1970年1月1日00:00:00 UTC对应的秒数
  • 时间戳,示当前时间到1970年1月1日00:00:00 UTC对应的毫秒数

2.2 2038年问题

Unix时间戳是从1970年1月1日(UTC/GMT的午夜)开始所经过的秒数,不考虑闰秒。在32位系统上,time_t能表示的最大值为0x7ffffffff,当time_t取最大值时表示系统时间为2038-01-19 03:14:07,但时间再往后走时,那time_t会溢出变成一个负值,此时系统时间会倒流回到1901年,届时操作系统和上层软件都会运行出错。

解决这个问题最简单粗暴的方法是用64位来表示时间。64位表示时间的最大值是2900亿年后的292,277,026,596年12月4日15:30:08,星期日(UTC)(北京时间292,277,026,596年12月4日23:30:08)。实际上,大部份64位操作系统已经把time_t改为64位整型,对于这些机器来说,2038年问题不复存在。然而对于嵌入式设备来说,现在还有大量32位系统在全球各地运行,谁也无法保证这些系统在2038年之前就能光荣退役。另外对于64位操作系统,上面还会运行着32位的应用程序,依旧会发生2038年问题。

2.3 两种国际时间标准:ISO8601 与 RFC2822

ISO8601,全称为《数据存储和交换形式·信息交换·日期和时间的表示方法》,规定了国际标准日期与时间表示法。

  • 只使用数字为基本格式。使用短横线"-"间隔开年、月、日为扩展格式。
  • 每个日期和时间值都有一个固定的位数,必须用前导零填充。
  • 日期时间表示只能有数字或少数特殊字符组成(如“ - ”,“:”,“T”,“W”和“Z”),不允许出现地方写法,如“1月”或“星期四”等。
  • ISO 8601使用24小时制。HH:MM:SS.sss,HH:MM:SS,HH:MM,HH为合规的时间格式。
  • 用字母T分隔日期和时间。如 20180703T224426Z2018-07-03T22:44:26Z

RFC2822:用于在 HTTP 和电子邮件标题等位置统一表示日期和时间的互联网信息格式。RFC 2822 包括星期几(短)、数字日期、月份的三字母缩写、年、时间和时区,显示为 Wed 01 Jun 2016 14:31:46 -0700

3 前端中的时间表示

后端一般返回的是时间的秒数或毫秒数,而在前端页面中的显示可能就多种多样,可能是:

  • 2021-1-25
  • 2021/1/25
  • 2021年1月25日
  • 2021年1月25日 12:35:10
  • 2021年01月25日 12时35分10秒
  • ......

在javascipt中,时间的处理需要用到内置对象Date

  1. 构造函数 var now = new Date(); 即可获取以当前时间构造的Date对象。以下方式都可以构造Date对象
new Date("month dd,yyyy hh:mm:ss");   new Date("January 1,2020 22:10:35"); 
new Date("month dd,yyyy");            new Date("July 12,2013"); 
new Date(yyyy,mth,dd,hh,mm,ss);       new Date(2006,0,12,22,19,35); 
new Date(yyyy,mth,dd);                new Date(2008,3,27); 
new Date(ms);                         new Date(1234567890000);
  1. Date.parse() var someDate = new Date(Date.parse('May 25,2004')); 解析字符串,转为时间戳(毫秒) 如果传入Data.parse()的方法的字符串不能表示日期格式,会返回NaN。实际上,如果直接将表示日期的字符串传递给Date构造函数,也会在后台调用Date.parse()方法。

  2. Date.now()获取当前时间戳 可以用Date.now()统计程序运行的时间

//取得开始时间
var start = Date.now();
//调用函数
dosomething();
//取得结束时间
var stop = Date.now(),
  1. 与其它引用类型一样,Date类型也重写了toLocaleString()、toString()和valueOf()方法。 valueOf()方法返回的不是字符串,而是返回日期的毫秒时间戳。因此可以方便使用比较操作符(大于或小于)来比较日期值。
let date=new Date()
date.toString()           "Tue Jan 26 2021 18:24:40 GMT+0800 (中国标准时间)"
date.toDateString()       "Tue Jan 26 2021"
date.toGMTString()        "Tue, 26 Jan 2021 10:24:40 GMT"
date.toUTCString()        "Tue, 26 Jan 2021 10:24:40 GMT"
date.toISOString()        "2021-01-26T10:24:40.224Z"
date.toJSON()             "2021-01-26T10:24:40.224Z"
date.toLocaleString()     "2021/1/26 下午6:24:40"
date.toLocaleTimeString() "下午6:24:40"
date.toLocaleDateString() "2021/1/26"
  1. get和set
let date=new Date();
date.getFullYear() - 获取4位数年份
date.getMonth() - 获取月份,取值0~110对应1月份
date.getDay() - 获取星期,取值0~60对应星期天,1对应星期一,6对应星期六
date.getDate() - 获取一个月中的某天,取值1~3111号,3131号
date.getHours() - 获取小时数,取值0~23
date.getMinutes() - 获取分钟数,取值0~59
date.getSeconds() - 获取秒数,取值0~59
date.getMilliseconds() - 获取毫秒数,取值0~999
date.getTime() - 返回197011日至当前时间的毫秒数

Date对象还有对应的UTC方法, 包括getUTC和setUTC
> new Date().getHours()
21
> new Date().getUTCHours()
13

3.1 常见时间处理场景

JS判断某年某月有多少天

JavaScript里面的new Date("xxxx/xx/xx")这个日期的构造方法当传入的是"xxxx/xx/0"(0号)的话,得到的日期是"xx"月的前一个月的最后一天("xx"月的最大取值是69),如果传入2019/12/0"(注意month是从0开始的),会得到"2018/12/31"。而且最大的好处是当传入"xxxx/3/0",会得到xxxx年2月的最后一天,它会自动判断当年是否是闰年来返回28或29,不用自己判断。所以,我们想知道某年某月有多少天的话,只需要在构造Date函数时月份传下个月,日期传0,这样就可以得到当月最后一天的Date对象

function getDaysInMonth(year,month){
  let temp = new Date(year,month,0);
  return temp.getDate();
}
getDaysInMonth(2019,2) //28 
getDaysInMonth(2020,2) //29

JS生成倒数7天日期

比如今天是10月1号,生成的数组是["9月25号","9月26号","9月27号","9月28号","9月29号","9月30号","10月1号"]。这个难点就是需要判断这个月(或上个月份)是30天还是31天,而且还有可能遇到闰2月的29天的情况

let now = new Date(); 
let s = '';
let i = 0;
while (i < 7) {
    s += now.getFullYear() + '/' + (now.getMonth() + 1) + '/' + now.getDate() + '\n';
    now = new Date(now - 24 * 60 * 60 * 1000); 
    i++;
}
console.log(s);

JS format函数

// 对Date的扩展,将 Date 转化为指定格式的String
// 月(M)、日(d)、小时(h)、分(m)、秒(s)、季度(q) 可以用 1-2 个占位符, 
// 年(y)可以用 1-4 个占位符,毫秒(S)只能用 1 个占位符(是 1-3 位的数字) 
// 例子: 
// (new Date()).Format("YYYY-MM-DD HH:mm:ss.S") ==> 2006-07-02 08:09:04.423 
// (new Date()).Format("YYYY-M-D H:m:s.S")      ==> 2006-7-2 8:9:4.18 
Date.prototype.Format = function (fmt) {
    const o = {
        "M+": this.getMonth() + 1, //月份 
        "D+": this.getDate(), //日 
        "H+": this.getHours(), //小时 
        "m+": this.getMinutes(), //分 
        "s+": this.getSeconds(), //秒 
        "q+": Math.floor((this.getMonth() + 3) / 3), //季度 
        "S": this.getMilliseconds() //毫秒 
    };
    if (/(Y+)/.test(fmt)){
        fmt = fmt.replace(RegExp.$1, (this.getFullYear() + "").substr(4 - RegExp.$1.length));
    }
    for (const k in o){
         if (new RegExp("(" + k + ")").test(fmt)){
             fmt = fmt.replace(RegExp.$1, (RegExp.$1.length == 1) ? (o[k]) : (("00" + o[k]).substr(("" + o[k]).length)));    
         }
    }
    return fmt;
}
let date=new Date();
date.Format("YYYY-MM-DD HH:mm:ss");

3.2 实用的时间处理库--moment, dayjs,miment

这三个都是非常好用的JS时间处理库,且三个库都极易上手,连API使用方式都高度一致,后两者都借鉴了moment。在日常时间处理上dayjs和miment基本可以替代moment。

momentdayjsmiment
Github stars45k33k332
大小200kb2kb1kb
可变性
支持扩展
方法数量
是否维护

下面这一段是moment的官方声明:

“ Moment.js 宣布停止开发,进入维护状态。 这是一个大而全的时间日期库,极大方便了我们在 JavaScript 中计算时间和日期,每周下载量超过 1200 万,已成功用于数百万个项目中。但是,作为一个诞生于 2011 年的元老级明星项目,以现在的眼光来看 Moment.js 并非完美无缺,官方总结了两大问题:

3.2.1 可变对象

Moment 对象是可变对象(mutable),简单点说,任何时间上的加减,包括startOf()等操作都改变了其本身。这种设计让代码变得十分不可控,而且很容易带来各种隐蔽且难以调试的 bug。以至于我们在每步修改之前,都要先调用 .clone() 克隆一次才能放心操作。

3.2.2 包体积过大

因为 Momnet.js 将全部的功能和所有支持的语言都打到一个包里,包的大小也是到了 280.9 kB 这样一个夸张的数字,而且对于 Tree shaking 无效。如果要使用时区相关的功能,包体积更是有 467.6 kB 的大小。简单点说,我们可能只需要一个 .format 格式化时间的方法,用户就需要加载数百 kB 的库,这是十分不划算的。” 官方给了 3 种替代方案:

  1. 不使用库 对于一些简单的时间处理需求,其实 JavaScript 自带的 Date 和 Intl 对象完全可以满足。强大的 Intl 对象可以展示不同时区不同语言的时间日期格式,在多数现代浏览器上已经有很好的支持。

  2. Temporal

也许今后的某一天,我们再也不需要使用任何库。Temporal被看作是未来的全新内置的时间日期方案 Temporal 很值得期待。ECMA TC39临时提案正在努力为JavaScript语言编写更好的日期和时间API。 它目前处于TC39流程的第二阶段。这是一个 JS 语言内置的重新设计的时间和日期 API,现在可以通过实验性的 polyfill 来尝试 Temporal,但离生产上大规模可用还有很长的路要走。

  1. 其他替代库

3.2.3 以dayjs为例(2KB immutable date time library alternative to Moment.js with the same modern API)

API 分为3类

  1. 第一类是返回其他对象的,比如format(),返回的是字符串。 json()返回的是一个json对象
  • format() 接收2个参数,这2个参数都有默认值,不传就使用默认值

dayjs().format('YYYY年MM月DD日 hh:mm:ss')  // 2021-01-26 20:49:36
dayjs().format('YYYY/MM/DD hh-mm-ss SSS') // 2021/01/26 20-49-36 568
dayjs().format('YYYY年MM月DD日 星期WW')     // 2021年01月26日 星期二
dayjs().format('YYYY年MM月DD日 星期ww')     // 2021年01月26日 星期2
也可以只传一部分
dayjs().format('YYYY')   // 2021
dayjs().format('MM')     // 01
dayjs().format('DD')     // 26
  • json() 返回当前时间的json对象
var date=dayjs().json()
{
    "year": 2021,
    "month": 1,
    "date": 26,
    "hour": 19,
    "minute": 42,
    "second": 41,
    "day": 2,
    "milliSecond": 87
}
  • Unix 时间戳(毫秒) .valueOf()
  • Unix 时间戳(秒) .unix()
  • 差别 diff(compared: Dayjs, unit: string (default: 'milliseconds'), float?: boolean)
  1. 第二类是返回dayjs对象的,可以在调完一个api后面继续调用另一个api,也就是链式调用
  • 构造 dayjs(existing?:string | number | Date | Dayjs):构造一个 dayjs 实例对象

  • 克隆 clone() | dayjs(original: Dayjs):在已有 dayjs 实例对象的基础上克隆返回一个新的 dayjs 实例对象

  • 验证 isValid():验证该 dayjs 实例对象是否有效

  • 添加 add(value: number, unit: string)

  • 减少 subtract(value: number, unit: string)

  • 开始的时间 startOf(unit: string)

  • 结束的时间 endOf(unit: string)

//链式调用
dayjs()
  .startOf('month')
  .add(1, 'day')
  .subtract(1, 'year')
  1. 第三类是从Date对象继承的,也就是说Date对象有的方法,dayjs也同样有。由于是继承而来的方法,所以方法无法返回dayjs对象,无法链式调用。(不推荐使用)

3.2.4 dayjs部分源码解析

// d 是否为 Dayjs 的实例对象
var isDayjs = d => d instanceof Dayjs
var wrapper = (date, instance) => dayjs(date, { locale: instance.$L })
var parseDate = function parseDate(cfg) {
  var date = cfg.date,
      utc = cfg.utc;
  if (date === null) return new Date(NaN); // null is invalid
  if (Utils.u(date)) return new Date(); // today
  if (date instanceof Date) return new Date(date);
  if (typeof date === 'string' && !/Z$/i.test(date)) {
    var d = date.match(C.REGEX_PARSE);
    if (d) {
      var m = d[2] - 1 || 0;
      var ms = (d[7] || '0').substring(0, 3);
      if (utc) {
        return new Date(Date.UTC(d[1], m, d[3] || 1, d[4] || 0, d[5] || 0, d[6] || 0, ms));
      }
      return new Date(d[1], m, d[3] || 1, d[4] || 0, d[5] || 0, d[6] || 0, ms);
    }
  }
  return new Date(date); // everything else
};
// dayjs 函数,用于返回新的 Dayjs 实例对象的函数(工厂模式)
var dayjs = (date, c) => {
  // 若date 为 Dayjs 的实例对象,则返回克隆的 Dayjs 实例对象(immutable)
  if (isDayjs(date)) {
    return date.clone()
  }
  const cfg = c || {}
  cfg.date = date
  return new Dayjs(cfg)
}
// Dayjs构造函数
var Dayjs = /*#__PURE__*/function () {
     function Dayjs(cfg) {
        this.$L = parseLocale(cfg.locale, null, true);//解析本地语言
        this.parse(cfg); //核心
     }
     var _proto = Dayjs.prototype;
    _proto.parse = function parse(cfg) {
        this.$d = parseDate(cfg);
        this.init();
    };
    _proto.init = function init() {
        var $d = this.$d;
        this.$y = $d.getFullYear();
        this.$M = $d.getMonth();
        this.$D = $d.getDate();
        this.$W = $d.getDay();
        this.$H = $d.getHours();
        this.$m = $d.getMinutes();
        this.$s = $d.getSeconds();
        this.$ms = $d.getMilliseconds();
    }; // eslint-disable-next-line class-methods-use-this
    clone() {
        return wrapper(this.toDate(), this)
    }
    // 转换为新的原生的 JavaScript Date 对象
    toDate() {
        return new Date(this.$d)
    }
        ......
     return Dayjs;
}();

参数 c 其实是当 date 参数为 Dayjs 实例对象时,最后又会调用 dayjs() 函数,此时才会传入参数 c。参数 c 为一个包含 locale 属性的对象(locale 的值为上一个 Dayjs 实例对象所用的语言,是一个字符串类型)

startOf(units, startOf) { // startOf -> endOf
    const isStartOf = !Utils.isUndefined(startOf) ? startOf : true
    const unit = Utils.prettyUnit(units)
    const instanceFactory = (d, m) => {
      const ins = wrapper(new Date(this.$y, m, d), this)
      return isStartOf ? ins : ins.endOf(C.D)
    }
    const instanceFactorySet = (method, slice) => {
      const argumentStart = [0, 0, 0, 0]
      const argumentEnd = [23, 59, 59, 999]
      return wrapper(this.toDate()[method].apply( // eslint-disable-line prefer-spread
        this.toDate(),
        isStartOf ? argumentStart.slice(slice) : argumentEnd.slice(slice)
      ), this)
    }
    switch (unit) {
      case C.Y:
        return isStartOf ? instanceFactory(1, 0) :
          instanceFactory(31, 11)
      case C.M:
        return isStartOf ? instanceFactory(1, this.$M) :
          instanceFactory(0, this.$M + 1)
      case C.W:
        return isStartOf ? instanceFactory(this.$D - this.$W, this.$M) :
          instanceFactory(this.$D + (6 - this.$W), this.$M)
      case C.D:
      case C.DATE:
        return instanceFactorySet('setHours', 0)
      case C.H:
        return instanceFactorySet('setMinutes', 1)
      case C.MIN:
        return instanceFactorySet('setSeconds', 2)
      case C.S:
        return instanceFactorySet('setMilliseconds', 3)
      default:
        return this.clone()
    }
  }

❤️ 谢谢支持

以上便是本次分享的全部内容,希望对你有所帮助^_^

喜欢的话别忘了 分享、点赞、收藏 三连哦~。