日期时间

GMT、UTC、CST

  • GMT

GMT (Greenwich Mean Time) 格林威治标准时间,指位于英国伦敦郊区的皇家格林威治天文台的标准时间,太阳每天经过家格林威治天文台的时间就是中午 12 点,本初子午线被定义为通过那里的经线。然而由于地球的不规则自转,导致 GMT 时间有误差,因此目前已不被当作标准时间使用。

1
new Date().toGMTString()
  • UTC

UTC (Coordinated Universal Time) 协调世界时间(世界标准时间、世界统一时间),是经过平均太阳时(以格林威治时间 GMT 为准)、地轴运动修正后的新时标以及以「秒」为单位的国际原子时所综合精算而成的时间。UTC 比 GMT 来得更加精准,其误差值必须保持在 0.9 秒以内,若大于 0.9 秒则由位于巴黎的国际地球自转事务中央局发布闰秒,使 UTC 与地球自转周期一致。日常使用中,GMT 与 UTC 的功能与精确度是没有差别的。

GMT 是根据地球自转计算时间,而 UTC 是根据原子钟来计算时间。

1
2
new Date().toUTCString()
new Date().toISOString() // ISO 格式的 UTC 时间,toJSON 或 JSON.stringify 结果一样
  • 地方时

整个地球分为二十四时区,每个时区都有自己的本地时间,世界各国的本地时间的都是基于 UTC 的,规则为 本地时间 = UTC + 偏移量 (时区差)。比如,北京时间(CST,China Standard Time,古巴的标准时间和美国、澳大利亚中部时间也被称为 CST)采用东八区的地方时(+0800),即 CST = UTC + 8(北京时间是东经 120° 地方的地方时间,不是北京地方东经 116.4° 的时间),而英国伦敦的本地时与 GMT 和 UTC 相同。

1
2
new Date().toLocaleString()
new Date().toString() // RFC 2822 格式的本地时间

日期时间的表示标准

日期时间的表示标准有两种,分别是 ISO 8601RFC 2822。前者是国际标准化组织的日期和时间表示方法,后者是 Internet 标准中关于电子邮件消息格式的标准,其中涵盖了日期时间标准。

ISO 8601

  • 格式

年用 4 位表示,月、日用 2 位表示,时分秒用 2 位表示,时区用 4 位表示(或者 Z)。

1
2
3
4
yyyy 年,比如:2016
yyyy-mm 年月,比如:2016-03
yyyy-mm-dd 年月日,比如:2016-03-08
YYYY-MM-DDThh:mm:ss[.s]TZD 年月日时分秒,比如:2016-03-08T02:54:17.159Z

T 用来分割日期和时间,. 后面代表毫秒,TZD time zone designator 表示时区,值可以是 Z(代表 UTC 时间,即 0 时区时间)、+hh:mm-hh:mm。如果不是 UTC 时间,比如北京时间,就需要表示为 2016-03-25T06:26:01.927+08:00

  • 生成和解析

JavaScript 中使用 toISOString()toJSON()JSON.stringify() 来生成 ISO 8601 格式的日期时间,使用 new Date(dateString) 来解析 IOS 8601 格式的时间。

1
2
3
4
// 生成
new Date().toISOString() // 2016-03-08T02:54:17.159Z,注意这是 UTC 时间,不是本地时间
// 解析
new Date('2016-01-25T09:14:10.099+00:00') // ISO 8601 时间格式是 UTC 时间(+00:00 表示 0 时区,跟 Z 一个概念),new Date 会自动处理为本地时区时间

如果日期时间格式不带时区,则会以本地时区来解析。

注意:在 Chrome 中时区也可以用 +0800 这种 RFC2822 形式表示,然而 IE 和老的 iPhone 上不支持这种混搭写法,解析时会提示 NaN 异常,对于这个问题,要么后端返回标准的格式,要么前端纠正。

1
2
3
4
5
new Date('2016-01-25T09:14:10.099+0000').getTime() // NaN

// 将 +0000 替换为 +00:00
const dateString = '2016-01-25T09:14:10.099+0000'.replace(/([+-]\d{2})(\d{2})$/, '$1:$2');
new Date(dateString)

注意:数据库应该始终存储 ISO 8601 格式的 UTC 日期时间,而不是本地时间,本地时间的转换交给客户端,以确保在不同地区的用户都能正确地看到适当的本地时间。

RFC 2822

  • 格式
1
2
YYYY/MM/DD HH:MM:SS TZD(时区用4位数字表示),比如:2016/03/08 02:54:17+0000
[Wdy,] DD Mon YYYY HH:MM:SS TZD,比如:Fri Mar 08 2016 02:54:17 GMT+0800 (中国标准时间)

RFC 2822 允许使用括号形式的注释。

  • 生成和解析
1
2
3
4
// 生成
new Date().toString() // Fri Jan 25 2016 17:14:10 GMT+0800 (中国标准时间),返回的是 RFC 2822 格式的 LocalDate
// 解析
new Date('Fri Jan 25 2016 17:14:10 GMT+0800')

注意:现代所有浏览器使用 toString() 生成日期时间,返回的都是 RFC 2822 格式,而 IE 返回的是 RFC-850/1036 格式,另外在解析时,IE8 只支持 RFC 2822,不支持 ISO 8601。

1
new Date().toString() // IE:Fri Mar 8 17:44:13 UTC+0800 2016

Date API

JavaScript 中对日期时间的处理可以归结为解析、生成、计算和格式化这几方面。

日期解析

使用 new Date 解析日期时间。

1
2
3
4
new Date(value) // Unix 时间戳
new Date(datString) // 符合 ISO 8601 标准或者 RFC 2822 标准的字符串时间
new Date(dateObject) // Date 对象
new Date(year, month[, day[, hour[, minutes[, seconds[, milliseconds]]]]])
1
2
3
4
5
6
7
8
// 日期验证
function isValidDate(dateString) {
const dateObject = new Date(dateString)
return !isNaN(dateObject.getTime())
}

isValidDate('2023-11-25') // true
isValidDate('invalid-date') // false

日期生成

  • 生成标准日期时间

toISOString()、toJSON()、JSON.stringify() 都能生成 ISO 8601 格式的 UTC 时间,toString() 会生成 RFC 2822 格式的本地时间。

需要说明一下 JSON.stringify 工作原理,JSON.stringify 在处理对象时,会调用该对象的 toJSON 方法,Data.prototype.toJSON 返回的是 ISO-8601 格式的 UTC 时间,而不是本地时间,结果比北京时间晚 8 小时。

1
JSON.stringify(new Date()) // 2016-03-08T02:54:17.159Z

可以重载此方法,使其返回本地时间。

1
2
3
4
5
6
7
8
Date.prototype.toJSON = function () { 
return this.toLocaleString()
}

const o = new Date()
JSON.stringify(o) // 输出自定义的本地时间:“2016年6月11日 10:57:27”,与 toString 返回的数据一致

o.toString() // 默认格式:“Wed Jun 11 2016 10:51:42 GMT+0800”

对任何 Object 实例修改 toJSON 都会影响 JSON.stringify 的输出。

1
2
3
4
5
6
function Foo() {}
Foo.prototype.toJSON = function (){
return 'this is an instance of Foo'
}

JSON.stringify(new Foo)
  • 生成本地日期时间

toLocaleString 默认返回格式受浏览器或操作系统的语言设置影响,格式不统一,ES6 基于 Intl API 扩展了 toLocaleString 方法,可设置 option 参数统一格式。

1
2
3
4
5
6
7
8
9
10
const options = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}
new Date().toLocaleString('zh-CN', options).replace(/\//g, '-')

toLocaleDateStringtoLocaleTimeString 同理。

  • 获取 Unix 时间戳

Unix 时间戳表示自 1970 年 1 月 1 日 00:00:00 UTC (Unix 纪元,Unix Epoch) 以来的毫秒数。在 JavaScript 中,有多种方法可以获取时间戳。

1
2
3
4
5
new Date().getTime()
new Date().valueOf()
+new Date()
Date.now()
Date.parse(dateString) // 从一个字符串中读取日期,并且返回 Unix 时间戳,作用等同于 new Date(dateString).getTime()

日期计算

  • 相对计算

setDate 方法根据本地时间来指定一个日期对象的天数。

1
2
3
4
5
6
7
8
9
10
// 获取指定日期之前或之后的日期
function getSubtractDate(subtractNum, date = new Date()) {
const d = new Date(date)
d.setDate(d.getDate() + subtractNum)
return d.toISOString()
}

getSubtractDate(-10) // 10 天前的日期
getSubtractDate(10) // 10 天后的日期
getSubtractDate(10, '2016-04-08') // 2016-04-08 10 天后的日期
1
2
3
4
5
6
7
8
9
// 获取一周的开始日期
function getStartOfWeek(date = new Date()) {
const today = new Date(date)
const dayOfWeek = today.getDay()
const startOfWeek = new Date(today)
startOfWeek.setDate(today.getDate() - (dayOfWeek + 6) % 7) // 调整到本周的第一天(星期一)
// startOfWeek.setDate(today.getDate() - dayOfWeek) // 调整到本周的第一天(星期日)
return startOfWeek
}
1
2
3
4
5
6
// 获取一月的开始日期
function getStartOfMonth(date = new Date()) {
const today = new Date(date)
const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1)
return startOfMonth
}

setDate 参数如果超出了月份的合理范围,会向上个月或下个月设置,<= 0 时,会设置上个月的日期,0 是最后一天,-1 是倒数第二天,以此类推,超出范围的正整数同理。

1
2
3
const d = new Date()
d.setDate(0)
d // 返回上个月最后一天日期

可以利用这个特性来获取月份的天数和判断是否是闰年。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 获取月份天数
function getDaysInMonth(year, month) {
// new Date 第三个参数本质也是 setDate
// JS 中 month 从 0 开始计算,n 月计算时实际上是 n-1 月,但由于 0 会往上一个月,所以此处无需 -1
return new Date(year, month, 0).getDate()
}
getDaysInMonth(2017, 10) // 31

// 获取一年中所有月份天数
function getDaysInYear(year) {
const daysInMonth = Array.from({ length: 12 }, (_, month) => {
return new Date(year, month + 1, 0).getDate()
})
return daysInMonth
}
getDaysInYear(2016) // [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]

一年中除了 2 月,其它所有月份天数都是固定的,1、3、5、7、8、10、12 月,有 31 天,4、6、9、11 月,有 30 天。2 月通常有 28 天,然而,为了与地球公转周期相匹配,每四年有一个闰年,百年不闰,四百年再闰,这时 2 月有 29 天。

1
2
3
4
5
6
7
8
9
10
11
// 是否是闰年
function isLeapYear(year) {
return (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0)
}

// 判断 2 月份天数是否为 29 来判断是否为闰年
function isLeapYear(year) {
return new Date(year, 2, 0).getDate() === 29
}

isLeapYear(2000) // true

一个闰年是能被 4 整除,但是不能被 100 整除,或者能被 400 整除的年份。例如,2016 年时闰年(能被 4 整除,但是不能被 100 整除),2000 年是闰年(能被 400 整除),而 1900年不是闰年(能被 4 整除、能被 100 整除,但不能被 400 整除)。

注意:当在同一个 Date 对象上连续执行 setDate 操作时,下一个 setDate 会在上一个 setDate 结果上进行操作。

1
2
3
4
5
6
const d = new Date()
d.setDate(0)
d // 2 月 28

d.setDate(-1)
d // 在 2 月 28 基础上 setDate,结果返回 1 月 30
  • 差异计算
1
2
3
4
5
6
7
8
const dateFrom = '2016-04-08T00:00:00Z'
const datefromTS = (new Date(dateFrom)).getTime()
const nowTS = new Date().getTime()
const diff = Math.abs(datefromTS - nowTS)

// 使用 Math.round 代替 Math.floor 来考虑某些 DST 情况
// 每天的毫秒数 = 24 小时/天 * 60 分钟/小时 * 60 秒/分钟 * 1000 毫秒/秒
Math.round(diff / (1000 * 60 * 60 * 24))
  • 比较大小

日期对象支持 <><=>= 比较,但由于引用类型的缘故,不支持 == 比较,比较日期字符串也不靠谱,同一个日期在不同日期格式下显然无法对比,所以最好的办法是对比时间戳。

1
2
3
4
new Date('Apr 08, 2016') < new Date('Apr 09, 2016') // true
new Date('Apr 08, 2016') == new Date('Apr 08, 2016') // false,理性应该返回 true
`${new Date().getFullYear()}-${new Date().getMonth() + 1}-${new Date().getDate()}` == '2016-12-25' // 判断是否今天是否是圣诞节
new Date('Apr 08, 2016').getTime() == new Date('Apr 08, 2016').getTime() // true

格式化

虽然 ES6 中可通过 Intl.DateTimeFormat 对日期时间格式化,但这个方自定义格式选项不足,开发中还是需要手动实现格式化方法。下面简单实现格式化 YYYY-MM-DDThh:mm:ssYYYY/MM/DDThh:mm:ss 和相对时间。

1
2
3
4
5
6
7
8
9
function padZero(s) {
return ('0' + s).slice(-2)
}

const d = new Date()
let date = d.getFullYear() + '-' + padZero(d.getMonth() + 1) + '-' + padZero(d.getDate())
date += ' '
date += padZero(d.getHours()) + ':' + padZero(d.getMinutes()) + ':' + padZero(d.getSeconds())
date // 2016-04-08 09:12:53
1
2
3
4
5
6
7
8
// 同上
// 使用 join 做字符串拼接,正则补零
const d = new Date()
const date = [
[d.getFullYear(), d.getMonth() + 1, d.getDate()].join('-'),
[d.getHours(), d.getMinutes(), d.getSeconds()].join(':')
].join(' ').replace(/(?=\b\d\b)/g, '0')
date // 2016-04-08 09:25:01
1
2
3
4
5
// 使用 toISOString
const d = new Date()
d.setMinutes(d.getMinutes() - d.getTimezoneOffset()) // 处理时区偏移
const date = d.toISOString().slice(0, -5).replace(/[T]/g, ' ')
date // 2016-04-08 09:30:15
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function formatDateTime(date, format) {
const year = date.getFullYear()
const month = padZero(date.getMonth() + 1)
const day = padZero(date.getDate())
const hours = padZero(date.getHours())
const minutes = padZero(date.getMinutes())
const seconds = padZero(date.getSeconds())
format = format.replace('yyyy', year)
format = format.replace('MM', month)
format = format.replace('dd', day)
format = format.replace('HH', hours)
format = format.replace('mm', minutes)
format = format.replace('ss', seconds)
return format
}

formatDateTime(new Date(), 'yyyy-MM-dd HH:mm:ss')
formatDateTime(new Date(), 'yyyy/MM/dd HH:mm:ss')
formatDateTime(new Date(), 'HH:mm:ss')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 获取当前时间戳
const getUnix = () => {
const date = new Date()
return date.getTime()
}

// 获取今天 0 点 0 分 0 秒的时间戳
const getTodayUnix = () => {
const date = new Date()
date.setHours(0)
date.setMinutes(0)
date.setSeconds(0)
date.setMilliseconds(0)
return date.getTime()
}

// 获取年月日
const getDate = time => {
const date = new Date(time)
const month = `0${date.getMonth() + 1}`.slice(-2)
const day = `0${date.getDate()}`.slice(-2)
return date.getFullYear() + '-' + month + '-' + day
}

// 格式化相对时间
const getFormatTime = fromTS => {
const nowTS = getUnix() // 当前时间戳
const todayTS = getTodayUnix() // 今天 0 点时间戳
const diff = (nowTS - fromTS) / 1000 // 转换为秒级时间戳
let tip = ''
if (diff <= 0) {
tip = '刚刚'
} else if (Math.floor(diff / 60) <= 0) {
tip = '刚刚'
} else if (diff < 3600) {
tip = Math.floor(diff / 60) + '分钟前'
} else if (diff >= 3600 && fromTS - todayTS >= 0) {
tip = Math.floor(diff / 3600) + '小时前'
} else if (diff / 86400 <= 31) {
tip = Math.ceil(diff / 86400) + '天前'
} else {
tip = getDate(fromTS)
}
return tip
}

Temporal API

Date API 设计存在问题,计算 API 缺失,日期时间计算需要手动实现,只支持 UTC 和用户 PC 时间,不支持非公历,开发中一般需要通过 moment、dayjs 这样一些第三方库使用。为了解决 Date API 的问题,TC39 提出了新的日期时间 API Temporal

1
2
3
4
5
仅可以创建和处理不可变 Temporal 对象
提供用于日期和时间计算的简单 API
支持所有时区
从 ISO-8601 格式进行严格的日期解析
支持非公历

截止 2022 年 2 月,该 API 尚处 Stage 3 阶段。