Date对象

JavaScript用Date对象表示时间点;准确理解字符串解析、UTC与本地视图、格式化、日期计算、可变性和有效性检查。

难度 进阶 时长 标准深度约15分钟
版本 Node 24
what

Date用毫秒时间值保存一个时间点。它不会保留日历、区域设置或时区名称。

trap

字符串解析、从零开始的月份、可变的setter、无效日期和夏令时切换,都可能让看似合理的代码在边界条件下出错。

fix

明确输入格式和时区,验证所得时间值,不要混用UTC与本地方法,并在调用setter前复制对象。

是什么,为什么存在

JavaScript的Date表示一个 时间点(instant) ,也就是时间线上的一个位置。它的时间值是相对于 Unix纪元(Unix epoch) 1970-01-01T00:00:00.000Z的毫秒数,类型为Number。因此,有效对象能回答“发生在何时”,但自身不保存地点、区域设置或面向日历的标签。

应用需要这种中立的值来记录事件、比较截止时间、序列化时间戳,并把同一时刻转换成人类可读的不同视图。浏览器与Node边界、API载荷、数据库记录、文件元数据、日志和用户界面中都会遇到Date。同一个时间点可能是纽约的周五晚上,却是上海的周六早上。

Date并不是所有时间相关值的完整模型。不带时间的生日、指定 时区(time zone) 中每次都在09:00开始的会议,以及三小时的时长,是三种不同概念。把它们都强行表示成时间点,会造成意外的UTC偏移和夏令时错误。

应在名称和schema中明确保留领域差异:

需求必须保留的数据
已经发生的事件时间点
表单中的日期日历年、月、日
用户当地09:00的闹钟墙上时钟字段与指定时区
任务在30分钟后过期时长与合适的时钟
审计记录展示时间点,以及展示所需的区域设置与时区

只有确实得到时间点时才转换为Date。纯日历值不应只是为了适配这个API,就被迫加上午夜时间和时区。

实用的心智模型是“时间值加投影视图”。UTC getter把该值投影成UTC日历字段。本地getter使用宿主环境当前的本地时区规则,而Intl.DateTimeFormat可以把它投影成指定时区的展示文本。

工作原理

一个数值时间值

每个有效的Date都有一个时间值,范围包含-8.64e15到8.64e15毫秒的两个端点。经过规范定义的截断操作后,这个值是整数。超出范围的值、解析失败的字符串和非有限数值都会生成无效的Date,其时间值为NaN。

getTime()和valueOf()返回这个数值。Date.now()不创建Date对象,直接返回当前挂钟时间值。两个有效日期相减可行,是因为数值转换会使用其时间值;严格相等仍然比较对象标识。

操作结果涉及的时区
new Date(milliseconds)对应时间值的Date无
date.getTime()相对Unix纪元的毫秒数无
date.toISOString()规范的UTC字符串UTC
date.getFullYear()日历年份宿主本地时区
date.getUTCFullYear()日历年份UTC
formatter.format(date)本地化标签formatter的配置

构造方式决定如何解释输入

new Date()读取当前系统时钟。单个数值被解释为Unix纪元之后的毫秒数。单个字符串按语言的日期字符串解析规则处理,多个独立数值组件则按宿主的本地时区解释。

组件构造函数的月份从零开始:一月是0,十二月是11。组件可以上溢或下溢,因此日期0表示上个月的最后一天,月份12会前进到下一年的一月。组件构造函数和Date.UTC()还保留一项旧规则:年份0到99会增加1900。

对于规范定义的日期时间字符串,显式的Z表示UTC,+08:00这样的显式偏移则确定对应时间点。2026-04-05这样的纯日期形式按UTC解释。2026-04-05T09:00:00这样没有偏移的日期时间形式按本地时间解释;这种不对称是代码审查中的常见问题。

其他面向人的字符串可能由实现自行定义。即使某个引擎碰巧能解析示例,也应把接受04/05/2026、月份名称或字段不全的日期视为输入契约缺陷。

输入形式属于契约

构造语法应与跨越边界的值类型匹配。带时间戳的API不应与生日字段共用解析器,因为前者标识时间点,后者可能有意不带时区。

输入需要说明的含义常见用途
1772951400000Unix纪元毫秒数内部时间戳交换
2026-03-08T06:30:00.000Z规范UTC时间点API与日志时间戳
2026-03-08T14:30:00+08:00带数值偏移的时间点感知偏移的外部输入
2026-03-08按Date解析规则得到的UTC午夜仅在确实需要时使用
new Date(2026, 2, 8, 14, 30)宿主本地字段本地用户界面行为

不要根据一个裸数值猜测单位。Unix时间戳常以秒交换,而JavaScript Date需要毫秒。验证范围,并把值命名为createdAtMs或createdAtSeconds,让相差1000倍的错误在审查中显现出来。

UTC、本地与指定时区视图

本地getter包括getFullYear()、getMonth()、getDate()和getHours()。对应的UTC方法会在名称中加入UTC。对于同一个时间值,第一组方法在不同时区的机器上可能返回不同字段;第二组会返回相同字段。

getTimezoneOffset()针对该时间点和宿主本地时区,以分钟为单位返回UTC - local。它的正负号很容易理解反;在实行 夏令时(daylight saving time) 的地区,其数值还可能随一年中的时间而变化。它不会给出时区名称。

要按指定时区展示,请创建Intl.DateTimeFormat,明确提供区域设置、timeZone和所需字段。格式化只改变表示形式,不会改变Date。如果应用之后还要重现用户设置日程时的意图,就应把时区标识符和时间点分开保存。

容易混淆的字段名称

getDate()返回一个月中的日期,范围是1到31。getDay()返回一周中的星期,范围是0到6,其中星期日为0。生成的日历代码经常把二者互换,因为名称看起来很相似。

getMonth()和getUTCMonth()返回的月份从零开始,但一个月中的日期从一开始。小时、分钟、秒和毫秒则使用从零开始的数值范围。这些看似不一致的约定是稳定的历史行为,因此可以尽量把组件访问封装在名称清晰的辅助函数后面。

setter返回的是数值,不是Date对象。因此,date.setDate(...).setHours(...)会在第一次调用后失败。每次修改都应写成独立语句,或让辅助函数显式返回复制后的Date。

序列化、计算与可变性

toISOString()返回UTC表示;无效日期调用它会抛出RangeError。对于有效日期,toJSON()通常委托给它;时间值非有限时,toJSON()返回null。因此,JSON.stringify()可能在后续流程中悄悄把无效日期属性变成null。

时间值相减得到经过的毫秒数。增加86_400_000也表示经过恰好24小时,并不等于“本地时间的明天同一时刻”。遇到夏令时切换时,这两个需求可能指向不同时间点。

所有set...方法都会修改接收者,并返回修改后的数值时间值。本地setter应用本地日历规则,UTC setter应用UTC规则。如果调用方还要保留原值,应先用new Date(original)创建副本。

存储与展示边界

对于时间点,只要外围schema明确定义,Unix纪元毫秒数或规范UTC字符串都可以作为稳定的存储形式。每个接口应选择一种表示,不要在字符串、数值和Date对象之间来回切换。JSON没有日期标量,因此解析JSON后只会得到字符串或数值,直到应用代码完成验证和转换。

面向人的输出应留在展示边界。需要一致输出时,给formatter传入明确的区域设置和指定时区;否则,宿主默认值也会成为结果的一部分。同一种配置要格式化多个值时可以缓存formatter,但未经真实工作负载测量,不应宣称性能提升。

时区标识符和数值偏移不能互相替代。偏移描述某个时间点与UTC的关系,而时区标识一组偏移可能随日期变化的规则。应用需要重建日程的本地时间时,应同时保存时间点与指定时区。

示例

同一个时间点在两个指定时区中的视图

这个示例先用带明确偏移的字符串构造时间点,再输出底层数值和两个投影视图。使用formatToParts()组装标签,可以避免结果依赖区域设置的标点。

instant_views.js
function wallClock(date, timeZone) {
  const parts = new Intl.DateTimeFormat('en-CA', {
    timeZone,
    year: 'numeric', month: '2-digit', day: '2-digit',
    hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
  }).formatToParts(date);
  const value = Object.fromEntries(parts.map(({ type, value }) => [type, value]));
  return `${value.year}-${value.month}-${value.day} ${value.hour}:${value.minute}`;
}

const release = new Date('2026-03-08T06:30:00.000Z');

console.log(release.getTime());
console.log(release.toISOString());
console.log(wallClock(release, 'America/New_York'));
console.log(wallClock(release, 'Asia/Shanghai'));
1772951400000
2026-03-08T06:30:00.000Z
2026-03-08 01:30
2026-03-08 14:30

四行结果都描述同一个时间点。指定时区只影响最后两个标签。格式化过程中,release及其时间值都没有改变。

验证API的规范时间戳

对于严格的API契约,仅仅解析成功还不够,因为一些越界的日历字段会被规范化。这个验证器先检查要求的文本形状,再通过toISOString()往返转换结果,从而拒绝规范化。

strict_timestamps.js
function parseCanonicalUtc(value) {
  const shape = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
  if (typeof value !== 'string' || !shape.test(value)) {
    throw new TypeError('expected YYYY-MM-DDTHH:mm:ss.sssZ');
  }

  const date = new Date(value);
  if (Number.isNaN(date.getTime()) || date.toISOString() !== value) {
    throw new RangeError('timestamp is not a real UTC instant');
  }
  return date;
}

for (const input of [
  '2026-02-28T12:30:00.000Z',
  '2026-02-30T12:30:00.000Z',
  '02/28/2026',
]) {
  try {
    console.log('OK', parseCanonicalUtc(input).toISOString());
  } catch (error) {
    console.log(error.name, error.message);
  }
}
OK 2026-02-28T12:30:00.000Z
RangeError timestamp is not a real UTC instant
TypeError expected YYYY-MM-DDTHH:mm:ss.sssZ

第二个输入具有正确的文本形状,但二月三十日并不存在。引擎的宽松解析器可能接受第三个输入,但它不属于这个API的契约,因此在解析前就被拒绝。

区分日历重复与经过时间

在示例所表示的边界上,纽约从UTC−05:00切换到UTC−04:00。两个日历日期连续、都在01:30开始的日程实际上只相隔23小时;若增加恰好24小时,本地时间会变成02:30。

calendar_vs_elapsed.js
function clockTime(date, timeZone) {
  return new Intl.DateTimeFormat('en-US', {
    timeZone,
    hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
  }).format(date);
}

const zone = 'America/New_York';
const firstAppointment = new Date('2026-03-08T01:30:00-05:00');
const nextCalendarDay = new Date('2026-03-09T01:30:00-04:00');
const afterTwentyFourHours = new Date(firstAppointment.getTime() + 86_400_000);

const elapsedHours = (nextCalendarDay - firstAppointment) / 3_600_000;
console.log(`calendar recurrence: ${elapsedHours} elapsed hours`);
console.log(`next local time: ${clockTime(nextCalendarDay, zone)}`);
console.log(`after 24 hours: ${clockTime(afterTwentyFourHours, zone)}`);
calendar recurrence: 23 elapsed hours
next local time: 01:30
after 24 hours: 02:30

显式偏移让输入时间点具有确定含义。生产环境中的日程数据仍需保留指定时区,因为固定偏移不包含未来的夏令时规则。

增加月份时不修改原值,也不溢出

直接调用月份setter会保留当前日期数字,再对溢出进行规范化。要实现截断到月末的操作,必须先移到安全日期,找出目标月的最后一天,再恢复不超过该上限的日期数字。

clamped_months.js
function addUtcMonthsClamped(date, months) {
  const result = new Date(date);
  const originalDay = result.getUTCDate();

  // 先移到 1 日,避免原日期数字溢出目标月份。
  result.setUTCDate(1);
  result.setUTCMonth(result.getUTCMonth() + months);

  const endOfTargetMonth = new Date(result);
  endOfTargetMonth.setUTCMonth(endOfTargetMonth.getUTCMonth() + 1, 0);
  result.setUTCDate(Math.min(originalDay, endOfTargetMonth.getUTCDate()));
  return result;
}

const issuedAt = new Date('2026-01-31T09:00:00.000Z');
const oneMonthLater = addUtcMonthsClamped(issuedAt, 1);
const twoMonthsLater = addUtcMonthsClamped(issuedAt, 2);

console.log(issuedAt.toISOString());
console.log(oneMonthLater.toISOString());
console.log(twoMonthsLater.toISOString());
console.log(issuedAt === oneMonthLater);
2026-01-31T09:00:00.000Z
2026-02-28T09:00:00.000Z
2026-03-31T09:00:00.000Z
false

UTC方法让这个辅助函数不受宿主本地夏令时规则影响。最后的false证明函数返回了不同对象,没有修改issuedAt。

陷阱

接受Date.parse()能接受的所有格式

修复方法: 选择有文档说明的传输格式,时间点必须带明确偏移,并同时验证输入形状和结果时间值。契约要求规范UTC文本时,应进行往返检查。不要声称仅凭正则表达式就能证明某个日历日期真实存在。

用对象的真假值判断无效日期

修复方法: 在边界处检查Number.isNaN(date.getTime()),并明确无效输入是抛出异常还是返回类型化失败。完成验证后再格式化、比较或存储该值。

混用UTC与本地访问器

修复方法: 先说清楚目标视图,在一次操作中始终使用同一组方法。展示指定时区时,使用一个配置好的Intl.DateTimeFormat,不要手动增加偏移。

修改共享的日期对象

修复方法: 使用new Date(input)复制对象,并在辅助函数名称和测试中明确溢出策略。测试应覆盖月末、闰日、负数增量;涉及本地setter时,还要覆盖夏令时切换的两侧。

把24小时当作一个日历日

修复方法: 先判断需求是经过时长还是日历重复。前者使用时间值计算;后者则要保留相关时区,并明确应用日历规则。

把对象标识当作时间值比较

修复方法: 验证两个操作数,再比较left.getTime() === right.getTime()。如果需求是“同一个本地日期”,则应在明确选择的时区中比较字段;这是另一个问题。

深入 在契约边界解析

在契约边界解析

语言保证支持自身定义的日期时间字符串格式,也就是toISOString()输出的形式。对于毫秒数为零的日期,它还要求支持由toString()和toUTCString()产生字符串的若干往返不变量。即使流行引擎目前碰巧接受某种输入,也不能把无关格式视为可移植契约。

缺少偏移时的规则值得单独测试。纯日期字符串按UTC解释,但不带Z或偏移的日期时间字符串按本地时间解释。在UTC以西的机器上,new Date('2026-01-01').getDate()可能因此返回本地日历的前一天,而new Date('2026-01-01T00:00:00')从本地午夜开始。

规范化与语法是两回事。例如,引擎可能把外形符合语法的二月三十日变成三月的某个时间点。表示民用日期的表单应把年、月、日当作日历字段验证;表示API时间点时,则可要求规范文本,并像示例那样与toISOString()比较。

明确的数值偏移确定一个时间点,却不是可长期使用的时区规则。2026-07-01T09:00:00-04:00说明本次事件与UTC的关系。它没有说明会议是否属于America/New_York,也没有说明规则变化后重复日程应使用哪个偏移。

组件构造可以避开字符串解析,却会引入另一组规则。月份从零开始,字段会规范化,年份0到99会映射为1900到1999。要构造这些早期年份,可以先创建一个有效日期,再显式调用setUTCFullYear()或setFullYear()。

跨越时区规则变化的日历计算

两个输入都通过验证后,经过时间的计算很直接:时间值相减,并让单位清晰可见。它适合有效期、延迟和“恰好30分钟后”等需求。系统时钟调整仍可能影响两次Date.now()读数,因此在同一个运行上下文中测量间隔时,performance.now()这样的单调时钟是更合适的来源。

日历计算从字段和时区开始。“本地时间的明天同一时刻”是指在该日历中推进日期,再按该时区规则解析所得墙上时钟字段。春季切换可能让某些本地时间不存在;秋季切换则可能让一个本地时间对应两个时间点,因此产品必须定义空缺和重叠策略。

本地setDate(getDate() + 1)会应用宿主本地时区,通常在推进日期时保留本地时钟字段。跨越切换点时,经过时间可能是23或25小时。UTC setter可以避开宿主时区切换,但UTC日历计算并不能实现某个指定时区的日历。

Intl.DateTimeFormat可以把时间点显示在Asia/Shanghai这样的IANA时区中。它不能解析该时区的墙上时钟时间,也不能执行指定时区的日历加法。如果日程功能需要这些操作,就应把时区建模为数据,并使用能明确定义空缺、重叠和重复行为的API。

范围、精度与时钟语义

允许的时间值范围恰好是Unix纪元前后的±8.64e15毫秒。new Date(8.64e15)有效,再多一毫秒就无效。这个截断范围还能保证JavaScript Number精确表示范围内的每个整数毫秒。

该模型把每个民用日都视为86_400_000毫秒,以此计算毫秒数。它不会把闰秒表示成独立时间点。交换更高精度时间戳的系统必须规定如何舍入,或在Date之外保留亚毫秒数据。

Date.now()读取系统挂钟,而管理员或时间同步机制可以调整它。运行环境也可能出于隐私保护降低其分辨率。需要挂钟时间戳时用它记录事件;在进程或页面内测量截止时间和性能耗时时,应使用单调计时来源。

暴露环境假设的测试

可靠的日期测试会固定所有原本来自环境的输入。使用字面量时间点,向formatter传入区域设置和时区;除非已经注入或控制时钟,否则不要对new Date()的结果作断言。这样可以避免开发者电脑、CI worker和生产容器悄悄测试不同日历。

解析测试应按契约划分,而不是随意收集格式错误的字符串。需要覆盖准确的规范形式、缺少偏移、单位错误、不可能的日历日期、刚刚超出允许范围的值,以及对非字符串输入的预期处理。既要断言失败类型,也要确认没有部分转换的值逃出边界。

日历计算测试应包括最短的目标月份、闰年二月、跨年和负向移动。如果本地或指定时区行为很重要,应从相关时区规则中选择真实的空缺与重叠切换。既要断言预期本地字段,也要断言经过的毫秒数,因为只看其中一项可能掩盖错误的语义选择。

实用的不变量包括:

  • 复制有效日期会保留时间值,但改变对象标识。
  • 在不同时区格式化不会改变原始时间值。
  • 成功通过的规范解析结果经toISOString()往返后保持不变。
  • 标明不修改输入的辅助函数,无论成功或失败,都不会改变输入的时间值。

控制环境是测试设置的一部分,不能作为未说明的假设。测试宿主本地方法时,如果运行时支持,可以为目标测试进程设置已知的TZ。测试指定时区展示时,应直接传入timeZone,并确认部署环境带有需要的国际化与时区数据。

延伸阅读

检查点

4个问题 · 1道输出预测题 · 1道找错题

复制为Markdown 面试题库 在GitHub上编辑 报告错误 讲清楚了吗?