JavaScript Date 的那些事

126 阅读11分钟

一、时间的"长相":你看到的时间有哪些形式?

在代码中,时间通常以两种形式存在:时间戳字符串

1.1 时间戳 (Timestamp)

时间戳是一个数字,表示从 1970 年 1 月 1 日 00:00:00 UTC(称为 Unix 纪元)到某个时刻经过的时间。

秒级 vs 毫秒级

  • 秒级时间戳:Unix/Linux 系统常用,如 1734345000
  • 毫秒级时间戳:JavaScript 使用的是这种,如 1734345000000

💡 提示:两者差 1000 倍,位数相差 3 位。秒级 10 位数,毫秒级 13 位数。

获取时间戳的方式

// 获取当前时间的毫秒级时间戳
Date.now() // ✅ 推荐,简洁高效
new Date().getTime() // 等价,但多创建了一个 Date 对象
+new Date() // 隐式转换,不推荐(可读性差)

// 示例
console.log(Date.now()) // 1734345000000

1.2 字符串形式

时间字符串有多种格式标准,了解它们能帮你避免很多解析问题。

ISO 8601 标准(推荐)

国际标准化组织制定的格式,跨平台、跨语言通用

2025-12-16T10:30:00.000Z
│    │  │ │ │  │  │   └── Z 表示 UTC 时区(也可以是 +08:00)
│    │  │ │ │  │  └────── 毫秒
│    │  │ │ │  └───────── 秒
│    │  │ │ └──────────── 分
│    │  │ └────────────── 时
│    │  └──────────────── 日
│    └─────────────────── 月
└──────────────────────── 年
// ISO 字符串示例
'2025-12-16' // 只有日期部分
'2025-12-16T10:30:00' // 不带时区(会被当作本地时间)
'2025-12-16T10:30:00Z' // UTC 时间
'2025-12-16T10:30:00+08:00' // 带时区偏移(东八区)

RFC 2822 标准

常见于邮件头、HTTP 响应头。

'Mon, 16 Dec 2025 10:30:00 GMT'
'Tue, 16 Dec 2025 18:30:00 +0800'

本地化字符串

因地区、语言而异,不建议用于数据传输,仅用于展示。

'2025年12月16日' // 中文
'12/16/2025' // 美式(月/日/年)
'16/12/2025' // 欧式(日/月/年)
'December 16, 2025' // 英文

各格式适用场景

格式适用场景备注
时间戳存储、计算、接口传输最通用,无歧义
ISO 8601接口传输、日志、数据库标准格式,强烈推荐
RFC 2822邮件、HTTP 头特定协议使用
本地化字符串仅用于 UI 展示展示友好,但不能用于传输

二、创建 Date 对象的几种方式

2.1 无参构造:获取当前时间

const now = new Date()
console.log(now) // Mon Dec 16 2024 18:30:00 GMT+0800 (中国标准时间)

2.2 时间戳构造

// 毫秒级时间戳
const date1 = new Date(1734345000000)

// ⚠️ 如果后端返回秒级时间戳,记得乘 1000
const backendTimestamp = 1734345000 // 秒级
const date2 = new Date(backendTimestamp * 1000)

💡 常见错误:忘记转换秒级时间戳,导致日期显示为 1970 年。

2.3 字符串构造(重点:可靠性问题)

✅ ISO 格式最可靠

// 这些在所有现代浏览器中表现一致
new Date('2025-12-16T10:30:00.000Z') // UTC 时间
new Date('2025-12-16T10:30:00+08:00') // 带时区
new Date('2025-12-16T10:30:00') // 本地时间

⚠️ 不可靠的字符串格式

// 1. 纯日期字符串 - 时区行为不一致!
new Date('2025-12-16')
// Chrome/Firefox: 当作 UTC 00:00:00,转本地时间是 08:00:00
// Safari/iOS Safari: 当作本地时间 00:00:00
// 结果: 同一个字符串,不同浏览器可能相差 8 小时!

// 2. 斜杠分隔 - 部分浏览器不支持
new Date('2025/12/16') // 大部分浏览器 OK,但不是标准
new Date('12/16/2025') // 美式格式,依赖浏览器实现

// 3. 其他格式 - 结果不可预测
new Date('16-12-2025') // ❌ 可能返回 Invalid Date
new Date('12-16-2025') // ❌ 不同浏览器解析不同
new Date('December 16, 2025') // ⚠️ 能用,但依赖英文环境

// 4. 带中文 - 完全不支持
new Date('2025年12月16日') // ❌ Invalid Date

💡 最佳实践

// 如果拿到非标准格式,先转成 ISO 或时间戳
const dateStr = '16/12/2025' // 欧式格式
const [day, month, year] = dateStr.split('/')
const safeDate = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`)

// 或者使用日期库(如 Day.js)处理

2.4 时间分量构造(月份坑点预告)

// new Date(year, monthIndex, day, hours, minutes, seconds, ms)
const date = new Date(2025, 11, 16, 10, 30, 0)
//                         ↑
//                    注意: 11 表示 12 月!

这就引出了下一章的重点——月份从 0 开始的问题。


三、月份从 0 开始:到底是哪里的坑?

这是 JavaScript Date 最臭名昭著的设计之一。但很多人对它有误解,让我们来澄清。

3.1 澄清误区:字符串构造不受影响

// ✅ 字符串中的 12 就是 12 月,没有任何问题
new Date('2025-12-16') // 12 月 16 日
new Date('2025-12-16T10:30') // 12 月 16 日 10:30

// ✅ ISO 字符串中的月份是正常的 1-12
new Date('2025-01-01') // 1 月 1 日
new Date('2025-12-31') // 12 月 31 日

3.2 真正的坑点:时间分量构造和 getter/setter

坑点 1:时间分量构造

// ❌ 常见错误: 第二个参数以为是月份(1-12)
new Date(2025, 12, 16) // 错误! 这是 2026 年 1 月 16 日
new Date(2025, 1, 1)   // 错误! 这是 2 月 1 日,不是 1 月

// ✅ 正确写法: 第二个参数是 monthIndex(0-11)
new Date(2025, 11, 16) // 2025 年 12 月 16 日
new Date(2025, 0, 1)   // 2025 年 1 月 1 日

坑点 2:getMonth() 返回 0-11

const date = new Date('2025-12-16')
console.log(date.getMonth()) // 11,不是 12!

// ✅ 想要得到正常月份,需要 +1
const month = date.getMonth() + 1 // 12

// ❌ 常见错误: 忘记 +1
const wrongMonth = date.getMonth() // 11 (错误!)
console.log(`当前月份是 ${wrongMonth} 月`) // "当前月份是 11 月"(实际是 12 月)

坑点 3:setMonth() 同样是 0-11

const date = new Date('2025-06-16')

// ❌ 错误: 以为是设置为 12 月
date.setMonth(12) // 实际设置为下一年 1 月!

// ✅ 正确: 设置为 12 月
date.setMonth(11)

3.3 为什么设计成这样?

这是历史遗留问题。JavaScript 的 Date 对象设计借鉴了 Java 的 java.util.Date(Java 后来也废弃了这个类)。

可能的原因:

  1. 数组索引思维:月份可以直接作为月份名称数组的索引
const months = ['January', 'February', 'March', 'April', 'May', 'June',
                'July', 'August', 'September', 'October', 'November', 'December']
const date = new Date()
console.log(months[date.getMonth()]) // 直接取月份名,无需 -1
  1. 早期设计仓促:JavaScript 只用了 10 天设计出来,很多决策没有深思熟虑

3.4 记忆口诀

字符串月份正常写,分量构造和 getter 要减一(或从 0 开始)。


四、Date 的其他常见坑

4.1 时区问题:本地时间 vs UTC 时间

const date = new Date('2025-12-16T00:00:00Z') // UTC 时间午夜

// 本地时间方法(受时区影响)
date.getHours() // 8 (北京时间 +8 小时)
date.getDate() // 16
date.getDay() // 2 (周二)

// UTC 时间方法(不受时区影响)
date.getUTCHours() // 0
date.getUTCDate() // 16
date.getUTCDay() // 2

// toString 也不同
date.toString() 
// "Tue Dec 16 2025 08:00:00 GMT+0800 (中国标准时间)"

date.toISOString() 
// "2025-12-16T00:00:00.000Z"

date.toUTCString() 
// "Tue, 16 Dec 2025 00:00:00 GMT"

💡 提示:涉及跨时区场景时,统一使用 UTC 时间,避免混乱。

4.2 月末溢出:自动进位

// Date 会自动处理溢出,这有时是 feature,有时是 bug
new Date(2025, 0, 32) // 1 月 32 日 → 2 月 1 日
new Date(2025, 1, 30) // 2 月 30 日 → 3 月 2 日(2025 非闰年)
new Date(2025, 11, 32) // 12 月 32 日 → 2026 年 1 月 1 日

// ✅ 利用这个特性获取某月最后一天
function getLastDayOfMonth(year, month) {
  // month 是 1-12,所以 month 作为 monthIndex 就是下个月
  // day 传 0 表示上个月最后一天
  return new Date(year, month, 0).getDate()
}

getLastDayOfMonth(2025, 2) // 28 (2 月最后一天)
getLastDayOfMonth(2024, 2) // 29 (2024 是闰年)
getLastDayOfMonth(2025, 12) // 31 (12 月最后一天)

更多特殊参数用法

// day 传 0: 上个月最后一天
new Date(2025, 2, 0) // 2025-02-28 (2 月最后一天)

// day 传负数: 往前推
new Date(2025, 2, -1) // 2025-02-27 (2 月倒数第二天)
new Date(2025, 0, 0) // 2024-12-31 (去年最后一天)

// 获取上个月同一天
const today = new Date(2025, 2, 15) // 3 月 15 日
const lastMonth = new Date(2025, 1, 15) // 2 月 15 日

// 获取下个月第一天
const nextMonthFirst = new Date(2025, 3, 1) // 4 月 1 日

// ✅ 判断是否为闰年
function isLeapYear(year) {
  // 2 月 29 日如果存在,就是闰年
  return new Date(year, 1, 29).getDate() === 29
}

isLeapYear(2024) // true
isLeapYear(2025) // false

4.3 Invalid Date:如何判断日期是否有效

const valid = new Date('2025-12-16')
const invalid = new Date('not a date')

// ✅ 方法 1: 检查 getTime() 是否为 NaN (推荐)
isNaN(valid.getTime()) // false
isNaN(invalid.getTime()) // true

// 方法 2: 转字符串检查
invalid.toString() // "Invalid Date"

// 方法 3: 使用 valueOf()
isNaN(invalid.valueOf()) // true

// ✅ 封装成函数
function isValidDate(date) {
  return date instanceof Date && !isNaN(date.getTime())
}

// 测试
isValidDate(new Date()) // true
isValidDate(new Date('invalid')) // false
isValidDate('2025-12-16') // false (不是 Date 对象)

4.4 Date 对象比较的陷阱

const date1 = new Date('2025-12-16')
const date2 = new Date('2025-12-16')

// ❌ 错误: 直接比较会比较引用,而非值
date1 == date2 // false
date1 === date2 // false

// ✅ 正确: 转成时间戳比较
date1.getTime() === date2.getTime() // true
+date1 === +date2 // true (隐式转换)

// ✅ 比较大小(可以直接比较,会自动转时间戳)
date1 > date2 // false
date1 < date2 // false
date1 >= date2 // true

// 实际应用示例
const deadline = new Date('2025-12-31')
const today = new Date()
if (today > deadline) {
  console.log('已过期')
}

4.5 时间戳精度问题

// JavaScript 的 Number 类型是 64 位浮点数
// 安全整数范围: -(2^53 - 1) 到 (2^53 - 1)
Number.MAX_SAFE_INTEGER // 9007199254740991

// 对于时间戳来说:
// 毫秒级时间戳在 2287 年之前都是安全的
const year2287 = 9999999999999
new Date(year2287) // Sat Nov 20 2286 17:46:39 GMT+0800

// ⚠️ 但如果后端返回微秒级或纳秒级时间戳,可能超出安全范围
const microTimestamp = 1734345000000000 // 微秒级(16位,超出安全范围)
// 这种情况需要用 BigInt 或字符串处理

// 解决方案示例
const microStr = '1734345000000000'
const millisTimestamp = Math.floor(Number(microStr) / 1000)
new Date(millisTimestamp)

4.6 格式化困难:没有内置 format 方法

const date = new Date('2025-12-16T10:30:00')

// 想要 "2025-12-16" 格式? 只能手动拼接
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0') // 别忘了 +1!
const day = String(date.getDate()).padStart(2, '0')
const formatted = `${year}-${month}-${day}` // "2025-12-16"

// 想要 "2025-12-16 10:30:00"? 继续拼...
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
const fullFormatted = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`

// 😫 每次都要写这么多代码

// ✅ 封装成工具函数
function formatDate(date, format = 'YYYY-MM-DD HH:mm:ss') {
  const year = date.getFullYear()
  const month = String(date.getMonth() + 1).padStart(2, '0')
  const day = String(date.getDate()).padStart(2, '0')
  const hours = String(date.getHours()).padStart(2, '0')
  const minutes = String(date.getMinutes()).padStart(2, '0')
  const seconds = String(date.getSeconds()).padStart(2, '0')
  
  return format
    .replace('YYYY', year)
    .replace('MM', month)
    .replace('DD', day)
    .replace('HH', hours)
    .replace('mm', minutes)
    .replace('ss', seconds)
}

// 使用
formatDate(new Date()) // "2025-12-16 10:30:00"
formatDate(new Date(), 'YYYY/MM/DD') // "2025/12/16"

4.7 Date 对象是可变的

// ⚠️ Date 对象的 setter 方法会修改原对象
const date = new Date('2025-12-16')
console.log(date.toString()) // "Mon Dec 16 2025..."

date.setMonth(0) // 修改为 1 月
console.log(date.toString()) // "Thu Jan 16 2025..." (原对象被改变!)

// 这在函数传参时容易产生副作用
function addOneDay(date) {
  date.setDate(date.getDate() + 1)
  return date // ⚠️ 返回的是修改后的原对象
}

const original = new Date('2025-12-16')
const next = addOneDay(original)
console.log(original.toString()) // 原对象也变了!

// ✅ 正确做法: 先复制再修改
function addOneDaySafe(date) {
  const newDate = new Date(date.getTime()) // 复制
  newDate.setDate(newDate.getDate() + 1)
  return newDate
}

五、Day.js 解决了哪些痛点? (对比原生 Date)

Day.js 是一个轻量级的日期处理库(仅 2KB gzip),API 设计借鉴了 Moment.js,但更加现代和轻便。

5.1 痛点对比表

痛点原生 DateDay.js
月份从 0 开始getMonth() 返回 0-11,需要手动 +1month() 也是 0-11,但 format('M') 自动输出 1-12
格式化日期无内置方法,需手动拼接十几行代码format('YYYY-MM-DD HH:mm:ss') 一行搞定
字符串解析不一致不同浏览器结果不同,非 ISO 格式不可靠统一解析,customParseFormat 插件支持任意格式
日期加减需手动计算毫秒或用 setDate() 等方法add(7, 'day')subtract(1, 'month') 语义清晰
日期比较需转时间戳比较,代码冗长isBefore()isAfter()isSame() 直观易读
不可变性setMonth() 等方法会修改原对象,易产生 bug所有操作返回新对象,原对象不变,避免副作用
时区处理只有本地和 UTC,切换麻烦timezone 插件轻松处理任意时区
相对时间无内置支持fromNow() 直接输出"3 天前"、"2 小时后"
体积内置,0 成本~2KB gzip,极轻量

5.2 Day.js 基本使用

import dayjs from 'dayjs'

// 创建日期对象
dayjs() // 当前时间
dayjs('2025-12-16') // 从字符串
dayjs(1734345000000) // 从时间戳
dayjs(new Date()) // 从 Date 对象

// 格式化 (最常用!)
dayjs().format('YYYY-MM-DD') // "2025-12-16"
dayjs().format('YYYY-MM-DD HH:mm:ss') // "2025-12-16 10:30:00"
dayjs().format('YYYY年MM月DD日') // "2025年12月16日"

// 日期加减
dayjs().add(7, 'day') // 7 天后
dayjs().subtract(1, 'month') // 1 个月前
dayjs().add(1, 'year') // 1 年后

// 日期比较
dayjs('2025-12-16').isBefore('2025-12-17') // true
dayjs('2025-12-16').isAfter('2025-12-15') // true
dayjs('2025-12-16').isSame('2025-12-16') // true

// 相对时间 (需要 relativeTime 插件)
import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/zh-cn'
dayjs.extend(relativeTime)
dayjs.locale('zh-cn')

dayjs().fromNow() // "几秒前"
dayjs().add(3, 'day').fromNow() // "3 天后"