如果你写过任何涉及日期计算的JavaScript代码,大概率踩过Date对象的坑。月份从0开始算、时区转换行为飘忽不定、解析字符串在不同浏览器里结果不一样、计算时间差要自己手动换算毫秒……这些问题在社区里被吐槽了十几年,但Date作为语言内置对象一直没能被替换掉。直到TC39正式推进Temporal提案,API设计汲取了moment.js、date-fns和Java.time的经验,用一种全新的、不可变的方式来处理日期和时间。
Temporal已经进入Stage 3阶段,Chrome 99以上、Edge 99以上和Firefox Nightly都可以直接使用全局的Temporal对象,不需要任何polyfill。这篇文章会从最基础的概念讲起,把PlainDate、PlainTime、ZonedDateTime和Duration这几个核心类型串起来,最后用一个跨时区会议安排的例子收尾,让你看完就能在实际项目里用起来。
Temporal 解决了 Date 的哪些老毛病
Date的设计继承了Java 1.0的java.util.Date,那个年代只考虑UTC和本地时区两种场景,根本没预见到全球化应用的复杂性。具体来说,Date有这几个致命问题:
- 可变性。
Date对象创建后可以被修改,setHours会直接改变原实例。在多处引用同一个Date时,一改全变,调试起来非常痛苦。 - 月份索引。
new Date(2025, 0, 1)是一月一日,getMonth()返回0代表一月。这个反直觉的设计每年都在制造bug。 - 时区混乱。
Date内部存储的是UTC时间戳,但展示时默认使用本地时区,而且在不同环境下解析带时区的字符串表现不一致。ISO 8601字符串'2025-03-15T12:00:00'在不同浏览器里可能被当成UTC也可能被当成本地时间。 - 缺少纯日期和纯时间表示。 你没办法只表示一个“2025年3月15日”而不带时间和时区,也没办法只表示一个“14:30:00”而不绑定到某一天。所有东西都得塞进一个时间戳,然后用乱七八糟的方法剥离。
- 计算困难。 加一天要
date.setDate(date.getDate() + 1),减一个月要考虑边界情况,跨时区计算更是噩梦。
Temporal的设计哲学是“用对的类型做对的事”。它提供了一整套不可变对象,分别对应不同的时间概念:纯日期、纯时间、带时区的时刻、时间段等。每个类型只暴露与该概念相关的操作,不会出现拿着一个时间戳去取星期几然后发现时区错位的尴尬。
核心类型一览
先快速过一下Temporal下最重要的几个类,心里有个地图再往下看:
- Temporal.PlainDate — 纯日期,比如“2025年3月15日”。没有时间,没有时区。
- Temporal.PlainTime — 纯时间,比如“14:30:00.500”。没有日期,没有时区。
- Temporal.PlainDateTime — 日期加时间,但没有时区信息,即“壁钟时间”。
- Temporal.PlainYearMonth — 只有年和月,如“2025-03”,适合表示信用卡到期日。
- Temporal.PlainMonthDay — 只有月日,如“03-15”,适合表示生日。
- Temporal.ZonedDateTime — 带时区的时刻,定位到时间轴上的绝对瞬间。内部包含一个
PlainDateTime和一个TimeZone。 - Temporal.Instant — 时间戳,表示从Unix纪元开始的纳秒级瞬间,类似
Date.now()但精度更高。 - Temporal.Duration — 时间段,可以表示“1小时30分钟”、“3天5小时”这样的长度,支持正负值。
所有类型都是不可变的。对一个PlainDate调用add方法会返回一个新对象,原对象不受影响。这个特性消除了Date的可变性带来的副作用问题。
创建和操作 PlainDate
最常见的需求是处理纯日期——生日、节假日、截止日期等。PlainDate可以用from静态方法从一个字符串或对象创建:
const date1 = Temporal.PlainDate.from('2025-03-15');
const date2 = Temporal.PlainDate.from({ year: 2025, month: 3, day: 15 });
const today = Temporal.Now.plainDateISO(); // 今天
console.log(date1.toString()); // '2025-03-15'
console.log(date1.year); // 2025
console.log(date1.month); // 3 (注意:月份从1开始!)
月份从1开始这个改动简直救命。date1.dayOfWeek返回周几(1=周一,7=周日),date1.dayOfYear返回一年中的第几天,date1.daysInMonth返回当月天数。所有这些属性都跟着对象走,不需要额外计算。
日期的加减用add和subtract方法,传入一个Duration对象或一个表示时长的对象:
const nextWeek = date1.add({ days: 7 });
const lastMonth = date1.subtract({ months: 1 });
console.log(lastMonth.toString()); // '2025-02-15'
// 比较日期
const isAfter = date1 > date2; // false,可以直接用比较运算符
PlainDate支持直接比较,不需要调getTime()。日期之间的差值通过until和since方法得到Duration:
const start = Temporal.PlainDate.from('2025-01-01');
const end = Temporal.PlainDate.from('2025-03-15');
const diff = start.until(end);
console.log(diff.toString()); // 'P73D' (ISO 8601时间段格式)
console.log(diff.days); // 73
时区与 ZonedDateTime
处理跨时区的时间是Date最拉胯的地方。Temporal.ZonedDateTime把日期、时间和时区绑定在一起,操作时不会再出现“我到底在哪个时区”的疑惑。
// 创建一个特定时区的时刻
const zdt = Temporal.ZonedDateTime.from('2025-03-15T14:30:00[Asia/Shanghai]');
console.log(zdt.toString());
// '2025-03-15T14:30:00+08:00[Asia/Shanghai]'
// 获取对应的UTC时间
const utcTime = zdt.toInstant();
console.log(utcTime.toString()); // '2025-03-15T06:30:00Z'
// 转换到另一个时区
const nyTime = zdt.withTimeZone('America/New_York');
console.log(nyTime.toString());
// '2025-03-15T02:30:00-04:00[America/New_York]'
注意上面的转换:北京时间14:30对应纽约时间是凌晨2:30(考虑夏令时后是UTC-4)。这个计算涉及时区规则数据库,withTimeZone自动处理了夏令时偏移,不需要自己查表。
从PlainDate和PlainTime组合成ZonedDateTime也很直接:
const date = Temporal.PlainDate.from('2025-03-15');
const time = Temporal.PlainTime.from('14:30:00');
const shanghaiZone = Temporal.TimeZone.from('Asia/Shanghai');
const zdt2 = date.toZonedDateTime({ time, timeZone: shanghaiZone });
如果想要当前时刻,用Temporal.Now.zonedDateTimeISO(),这个调用会返回当前系统时区下的时刻,如果需要特定时区,传入时区参数:
const nowInTokyo = Temporal.Now.zonedDateTimeISO('Asia/Tokyo');
console.log(nowInTokyo.toString());
Duration:让时间计算不再靠毫秒
Duration表示一段时长,可以包含年、月、周、天、小时、分钟、秒、毫秒、微秒和纳秒。它最常用的场景是日期时间的加减,但也可以独立使用。
const dur1 = Temporal.Duration.from({ hours: 2, minutes: 45 });
const dur2 = Temporal.Duration.from('PT1H30M'); // ISO 8601 duration
const total = dur1.add(dur2);
console.log(total.toString()); // 'PT4H15M'
console.log(total.hours); // 4
console.log(total.minutes); // 15
// 取负数时长
const negative = Temporal.Duration.from({ hours: -1 });
console.log(negative.toString()); // '-PT1H'
Duration的round方法可以按指定单位进行舍入,比如把90分钟归整为1小时30分,或者把总分钟数换算成小数小时:
const d = Temporal.Duration.from({ minutes: 90 });
const rounded = d.round({ largestUnit: 'hours' });
console.log(rounded.toString()); // 'PT1H30M'
const inHours = d.total({ unit: 'hours' });
console.log(inHours); // 1.5
这个功能在计算工时、费用结算等场景里非常实用,终于不用手写毫秒换算了。
实战案例:跨时区会议时间安排
把上面几个类型串起来,做一个实际的需求:一位在北京的同事要在3月20日下午3点安排一场线上会议,参会者分别在旧金山、伦敦和东京。我们需要算出每个人本地时间是几点,并且判断是否在对方的工作时间(上午9点到下午6点)内。
// 1. 创建会议计划时间(北京时间)
const meetingBeijing = Temporal.ZonedDateTime.from(
'2025-03-20T15:00:00[Asia/Shanghai]'
);
// 2. 定义参会者所在的时区
const attendees = [
{ name: '旧金山同事', timeZone: 'America/Los_Angeles' },
{ name: '伦敦同事', timeZone: 'Europe/London' },
{ name: '东京同事', timeZone: 'Asia/Tokyo' },
];
// 3. 为每个参会者计算本地时间
attendees.forEach(attendee => {
const localTime = meetingBeijing.withTimeZone(attendee.timeZone);
const plainTime = localTime.toPlainTime();
// 判断是否在9:00-18:00之间
const workStart = Temporal.PlainTime.from('09:00:00');
const workEnd = Temporal.PlainTime.from('18:00:00');
const isWorkHours = plainTime >= workStart && plainTime < workEnd;
console.log(`${attendee.name}: ${localTime.toPlainDateTime().toString()}`);
console.log(` 本地时间 ${plainTime.toString()},工作时间: ${isWorkHours ? '是' : '否'}`);
});
运行结果大概是:
旧金山同事: 2025-03-19T23:00:00
本地时间 23:00:00,工作时间: 否
伦敦同事: 2025-03-20T07:00:00
本地时间 07:00:00,工作时间: 否
东京同事: 2025-03-20T16:00:00
本地时间 16:00:00,工作时间: 是
北京下午3点对旧金山是前一天的晚上11点,对伦敦是早上7点,对东京是下午4点。只有东京在工作时段内。这个结果让组织者可以直观地调整时间:比如提前到北京时间上午10点,旧金山就是前一天晚上6点,伦敦凌晨2点(还是不行),东京上午11点。需要多轮尝试才能找到一个对三方都合适的时间,或者分两场开。
接下来我们可以写一个函数自动找出所有参会者都在工作时段内的最早时间:
function findEarliestMeetingTime(
date: Temporal.PlainDate,
timeZones: string[],
workHours: { start: Temporal.PlainTime; end: Temporal.PlainTime }
): Temporal.ZonedDateTime | null {
// 从0点开始,每分钟检查一次(实际可优化步长)
for (let hour = 0; hour < 24; hour++) {
for (let minute = 0; minute {
const localTime = candidateZdt.withTimeZone(tz).toPlainTime();
return localTime >= workHours.start && localTime {
const local = bestTime.withTimeZone(tz).toPlainTime();
console.log(` ${tz}: ${local.toString()}`);
});
} else {
console.log('当天没有全在工作时段内的共同时间');
}
这段代码在现实中的复杂度比看起来要高,因为夏令时边界、时区规则变化等边缘情况都需要处理,而Temporal把所有这些都封装好了,我们只需要用withTimeZone转换然后比较时间即可。对比之前用Date和moment-timezone去拼凑这些逻辑,代码量至少减少三分之二,而且不易出错。
与现有 Date 的互操作
在过渡期,Temporal对象可能还需要和Date打交道。从Date转换到Temporal:
const legacyDate = new Date();
const instant = legacyDate.toTemporalInstant();
const zdt = instant.toZonedDateTimeISO(Temporal.Now.timeZone());
反向转换也简单:
const backToDate = new Date(zdt.epochMilliseconds);
两个方向都很直接,不需要手动处理偏移量。
目前的支持情况与使用建议
Temporal在Chrome/Edge 99以上已经默认可用,Firefox在Nightly版中提供,Safari预计很快跟进。对于需要支持旧浏览器的项目,可以用官方polyfill @js-temporal/polyfill,npm install后全局引入即可,API完全一致。生产环境中建议先用polyfill保证兼容性,等浏览器覆盖率上去再移除。
在使用上,有一点需要留意:Temporal的字符串解析比Date严格得多。必须严格遵循ISO 8601格式,任何偏差都会直接抛出RangeError。例如Temporal.PlainDate.from('2025/03/15')会报错,正确的写法是'2025-03-15'。这种严格性避免了跨环境解析不一致的问题,但也要求我们对输入格式做前置校验。
写在最后
Temporal不是对Date的小修小补,而是重新做了一遍日期时间模型。它把日期、时间、时区、时长拆成独立类型,每种类型只暴露合理的操作,加上不可变性,让时间计算这种原本容易出错的领域变得可控。从跨时区会议到定时任务调度,从年龄计算到支付系统的结算周期,几乎所有涉及时间的逻辑都能用Temporal写出更清晰、更安全的代码。
如果你的项目中还在用moment.js或手写毫秒运算,不妨在下一次需求中用Temporal试一下。初期可能需要适应新的类型体系,但这个学习成本很快会被减少的bug和更直观的代码逻辑抵消。

