学习如何在 JavaScript 中可靠地格式化和转换时间,避免意外:时间戳、ISO 字符串、时区、夏令时、解析规则与稳健模式。

JavaScript 的时间错误很少看起来像“时钟坏了”。它们通常以令人困惑的小偏移出现:某个日期在你的笔记本上是正确的,但在同事的机器上是错误的;API 的响应看起来没问题,直到在不同的时区渲染;或者报告在季节性时间变更附近“差一”的情况。
你通常会注意到以下一项(或多项):
+02:00)与预期不同。一个很大的痛点是单词 时间 可以指不同的概念:
JavaScript 内置的 Date 试图覆盖这些用例,但它主要表示一个瞬时点,同时又经常把你引向本地显示,这就使得无意转换变得很容易。
本指南着眼于实用性:如何在浏览器和服务器间得到可预测的转换,如何选择更安全的格式(比如 ISO 8601),以及如何发现经典陷阱(秒与毫秒、UTC 与本地、解析差异)。目标不是更多理论,而是更少的“为什么它会偏移?”的意外。
JavaScript 的时间错误常常始于混合看似可互换但实际上并非如此的表示法。
1) 纪元毫秒(数字)
像 1735689600000 这样的纯数字通常表示“自 1970-01-01T00:00:00Z 起的毫秒数”。它表示一个瞬时点,没有格式或时区附带。
2) Date 对象(瞬时点的包装)
Date 存储与时间戳相同类型的瞬时点。令人困惑的地方在于:当你打印一个 Date 时,JavaScript 会使用运行环境的本地规则来格式化,除非你另外指定。
3) 格式化字符串(人类可读)
像 "2025-01-01"、"01/01/2025 10:00" 或 "2025-01-01T00:00:00Z" 这样的字符串并不是同一种东西。有些是明确的(带 Z 的 ISO 8601),有些依赖区域设置,还有些根本不包含时区信息。
同一个瞬时点在不同时区可以有不同的显示:
const instant = new Date("2025-01-01T00:00:00Z");
instant.toLocaleString("en-US", { timeZone: "UTC" });
// "1/1/2025, 12:00:00 AM"
instant.toLocaleString("en-US", { timeZone: "America/Los_Angeles" });
// "12/31/2024, 4:00:00 PM" (前一天)
在你的应用和 API 中选择一个统一的内部表示(通常是纪元毫秒或UTC ISO 8601),并坚持使用。仅在边界处(输入解析与 UI 显示)进行到/从 Date 与格式化字符串的转换。
“时间戳”通常指纪元时间(也称 Unix 时间):自 1970-01-01 00:00:00 UTC 起的时间计数。难点在于不同系统使用不同的单位。
JavaScript 的 Date 是大多数混淆的来源,因为它使用毫秒。许多 API、数据库与日志使用秒。
17040672001704067200000同一时刻,但毫秒版本多了三个零。
使用显式的乘除法以确保单位明确:
// seconds -> Date
const seconds = 1704067200;
const d1 = new Date(seconds * 1000);
// milliseconds -> Date
const ms = 1704067200000;
const d2 = new Date(ms);
// Date -> seconds
const secondsOut = Math.floor(d2.getTime() / 1000);
// Date -> milliseconds
const msOut = d2.getTime();
Date() 传入秒这看起来合理,但当 ts 是以秒为单位时就是错的:
const ts = 1704067200; // seconds
const d = new Date(ts); // WRONG: treated as milliseconds
结果会是 1970 年 的某个日期,因为 1,704,067,200 毫秒大约仅距纪元约 19 天。
当你不确定拥有的是哪个单位时,加入快速的保护措施:
function asDateFromUnknownEpoch(x) {
// crude heuristic: seconds are ~1e9-1e10, milliseconds are ~1e12-1e13
if (x < 1e11) return new Date(x * 1000); // assume seconds
return new Date(x); // assume milliseconds
}
const input = Number(valueFromApi);
console.log({ input, digits: String(Math.trunc(input)).length });
console.log('as ISO:', asDateFromUnknownEpoch(input).toISOString());
如果“位数”计数约为 10,通常是秒;如果约为 13,通常是毫秒。调试时还要打印 toISOString():它是明确的,可以帮助你立即发现单位错误。
JavaScript 的 Date 可能令人困惑,因为它存储一个时间瞬时点,但可以以不同时区呈现该瞬时点。
内部上,Date 本质上是“自 Unix 纪元(1970-01-01T00:00:00Z)以来的毫秒数”。这个数字表示 UTC 时刻。当你要求 JavaScript 将该时刻格式化为本地时间(基于计算机/服务器设置)与UTC时,就会发生“偏移”。
许多 Date API 都有本地与 UTC 的变体。它们对于同一瞬时点会返回不同的数值:
const d = new Date('2025-01-01T00:30:00Z');
d.getHours(); // hour in *local* time zone
d.getUTCHours(); // hour in UTC
d.toString(); // local time string
d.toISOString(); // UTC (always ends with Z)
如果你的机器在纽约(UTC-5),该 UTC 时间在本地可能显示为前一天的 “19:30”。而在设置为 UTC 的服务器上,它会显示为 “00:30”。相同的瞬时点,不同的显示。
日志通常使用 Date#toString() 或隐式插入 Date,这会使用环境的本地时区。因此相同的代码可能在你的笔记本、CI 与生产环境打印不同的时间戳。
以 UTC 存储和传输时间(例如纪元毫秒或带 Z 的 ISO 8601)。仅在显示时转换为用户本地:
toISOString() 或传递纪元毫秒Intl.DateTimeFormat 在用户时区中格式化如果你在快速构建应用(例如在 Koder.ai 的快速生成工作流中),建议在生成的 API 合同中早早固定这些约定:字段命名清晰(createdAtMs, createdAtIso),并让服务器(Go + PostgreSQL)与客户端(React)对每个字段的含义保持一致。
如果需要在浏览器、服务器与数据库之间发送日期/时间,ISO 8601 字符串是最稳妥的默认选择。它们明确、广泛支持,并且(最重要的)携带时区信息。
两种好的交换格式:
2025-03-04T12:30:00Z2025-03-04T12:30:00+02:00'Z' 代表什么?
Z 代表 Zulu 时间,也就是 UTC。所以 2025-03-04T12:30:00Z 是“UTC 的 12:30”。
何时偏移如 +02:00 很重要?
当一个事件与本地时区上下文相关(预约、预订、商店营业时间)时,偏移非常关键。2025-03-04T12:30:00+02:00 描述的是比 UTC 快两个小时的时刻,它与 2025-03-04T12:30:00Z 不是同一个瞬时点。
像 03/04/2025 这样的字符串是陷阱:是 3 月 4 日还是 4 月 3 日?不同的用户和环境会有不同解释。请偏好 2025-03-04(ISO 日期)或完整的 ISO 日期时间。
const iso = "2025-03-04T12:30:00Z";
const d = new Date(iso);
const back = d.toISOString();
console.log(iso); // 2025-03-04T12:30:00Z
console.log(back); // 2025-03-04T12:30:00.000Z
这种“往返”行为正是 API 所需要的:一致、可预测且考虑时区。
Date.parse() 看起来很方便:给它一个字符串,返回时间戳。问题在于,对于任何非明确 ISO 8601 的内容,解析可能依赖浏览器的启发式规则。这些启发式在不同引擎与版本间有所差异,意味着相同的输入可能在不同环境下解析结果不同(或根本无法解析)。
Date.parse() 会有差异JavaScript 仅对 ISO 8601 风格的字符串使解析成为标准化行为(即便如此,诸如时区这种细节也会影响结果)。对于“友好格式”——如 "03/04/2025"、"March 4, 2025" 或 "2025-3-4" ——浏览器可能会:
如果你无法预测字符串的精确形状,就无法预测解析结果。
YYYY-MM-DD一个常见陷阱是纯日期形式 "YYYY-MM-DD"(例如 "2025-01-15")。许多开发者期望它被解释为本地午夜。实际上,一些环境会将此形式视为UTC 午夜。
这个差异很重要:UTC 午夜转换为本地时间时,可能会变成(负时区)前一天,或者小时数出现意外偏移。这是造成“为什么我的日期相差一天?”错误的常见来源。
对于 服务端/API 输入:
2025-01-15T13:45:00Z 或 2025-01-15T13:45:00+02:00。"YYYY-MM-DD"),不要转换为 Date,除非你也定义了预期的时区。对于 用户输入:
03/04/2025 这样的歧义格式,除非你的 UI 强制限定其含义。不要依赖 Date.parse() 去“猜测”,选择以下模式之一:
new Date(year, monthIndex, day) 来创建本地日期)。当时间数据很关键时,“在我的机器上解析没问题”不够——要使解析规则明确且一致。
如果你的目标是“以人们期望的方式显示日期/时间”,JavaScript 中最好的工具是 Intl.DateTimeFormat。它使用用户的区域规则(顺序、分隔符、月份名称),避免手工拼接字符串(如 month + '/' + day)的脆弱做法。
手工格式化常常硬编码为美式输出,忘记前导零,或在 24/12 小时制上产生混淆。Intl.DateTimeFormat 还使你明确知道要以哪个时区显示——当数据以 UTC 存储但 UI 需反映用户本地时间时,这一点至关重要。
对于“好看地格式化”,dateStyle 与 timeStyle 是最简单的:
const d = new Date('2025-01-05T16:30:00Z');
// User’s locale + user’s local time zone
console.log(new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(d));
// Force a specific time zone (great for event times)
console.log(new Intl.DateTimeFormat('en-GB', {
dateStyle: 'full',
timeStyle: 'short',
timeZone: 'UTC'
}).format(d));
如果你需要一致的小时制(例如用户可以设置 12/24 小时制),使用 hour12:
console.log(new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
}).format(d));
为 UI 中每种“时间类型”(消息时间、日志条目、事件开始)选择一个格式化函数,并让 timeZone 的决定变得明确:
这样你就可以得到一致、符合区域习惯的输出,而无需维护脆弱的自定义格式字符串集合。
夏令时是时区在特定日期改变其 UTC 偏移(通常为一小时)的一种情况。棘手之处在于:DST 不仅改变偏移——它还改变某些本地时间的存在性。
当时钟向前跳时,一段本地时间并不存在。例如在许多地区,时钟会从 01:59 跳到 03:00,因此02:30 本地时间是“缺失的”。
当时钟向后拨时,一段本地时间会发生两次。例如 01:30 可能在切换前与切换后各出现一次,意味着相同的本地时间可以对应两个不同的瞬时点。
在 DST 边界,这两者并不等价:
如果今晚开始 DST,"明天的 9:00" 可能只比现在晚 23 小时。如果今晚结束 DST,可能晚 25 小时。
// Scenario: schedule “same local time tomorrow”
const d = new Date(2025, 2, 8, 9, 0); // Mar 8, 9:00 local
const plus24h = new Date(d.getTime() + 24 * 60 * 60 * 1000);
const nextDaySameLocal = new Date(d);
nextDaySameLocal.setDate(d.getDate() + 1);
// Around DST, plus24h and nextDaySameLocal can differ by 1 hour.
setHours 会让你吃惊如果你在“向前跳”那天对某个日期执行 date.setHours(2, 30, 0, 0),JavaScript 可能会将其规范化为另一个有效时间(通常为 03:30),因为 02:30 在本地时间中并不存在。
setDate),而不是简单相加毫秒。一个常见错误是使用 Date 来表示并非日历时刻的东西。
时间戳(timestamp) 回答“什么时候发生的?”(一个特定的瞬时点,如 2025-12-23T10:00:00Z)。持续时长(duration) 回答“持续多久?”(比如“3 分 12 秒”)。这两个概念不同,混用会造成混乱的运算和意外的时区 / DST 效果。
Date 不是持续时长的正确工具Date 总是表示相对于纪元的时间点。如果你把“90 秒”存为一个 Date,实际上你是存储了“1970-01-01 加 90 秒”在某个时区。格式化时它可能会显示为 01:01:30、被小时偏移或带上你未预期的日期。
对于持续时长,更好地使用纯数字:
HH:mm:ss这是一个适用于倒计时或媒体时长的简单格式化器:
function formatHMS(totalSeconds) {
const s = Math.max(0, Math.floor(totalSeconds));
const hh = String(Math.floor(s / 3600)).padStart(2, "0");
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, "0");
const ss = String(s % 60).padStart(2, "0");
return `${hh}:${mm}:${ss}`;
}
formatHMS(75); // "00:01:15" (倒计时)
formatHMS(5423); // "01:30:23" (媒体时长)
如果你来自分钟数进行转换,先乘以(minutes * 60),并在渲染前始终保持数值状态。
在 JavaScript 中比较时间,最安全的方式是比较数字,而不是格式化后的文本。Date 对象本质上是对纪元毫秒数字的包装,因此你希望比较最终以“数字对数字”进行。
使用 getTime()(或返回相同数字的 Date.valueOf())来可靠比较:
const a = new Date('2025-01-10T12:00:00Z');
const b = new Date('2025-01-10T12:00:01Z');
if (a.getTime() < b.getTime()) {
// a is earlier
}
// Also works:
if (+a < +b) {
// unary + calls valueOf()
}
避免比较像 "1/10/2025, 12:00 PM" 这样的格式化字符串——它们依赖区域设置并且无法正确排序。主要的例外是相同格式和时区下的 ISO 8601 字符串(例如全为 ...Z),它们可以按字典序排序。
如果按纪元毫秒排序很简单:
items.sort((x, y) => new Date(x.createdAt).getTime() - new Date(y.createdAt).getTime());
按范围过滤同样遵循该思路:
const start = new Date('2025-01-01T00:00:00Z').getTime();
const end = new Date('2025-02-01T00:00:00Z').getTime();
const inRange = items.filter(i => {
const t = new Date(i.createdAt).getTime();
return t >= start && t < end;
});
“天的开始”取决于你是指本地时间还是 UTC:
// Local start/end of day
const d = new Date(2025, 0, 10); // Jan 10 in local time
const localStart = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
const localEnd = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
// UTC start/end of day
const utcStart = new Date(Date.UTC(2025, 0, 10, 0, 0, 0, 0));
const utcEnd = new Date(Date.UTC(2025, 0, 10, 23, 59, 59, 999));
尽早选择一种定义,并在比较与范围逻辑中始终坚持它。
时间错误感觉随机,直到你弄清楚你拥有什么(时间戳?字符串?Date?)以及偏移在哪里引入(解析、时区转换、格式化)。
首先以三种不同的方式记录同一值。这能快速揭示问题是秒与毫秒、本地与 UTC,还是字符串解析的问题。
console.log('raw input:', input);
const d = new Date(input);
console.log('toISOString (UTC):', d.toISOString());
console.log('toString (local):', d.toString());
console.log('timezone offset (min):', d.getTimezoneOffset());
要注意的点:
toISOString() 非常离谱(例如 1970 年或遥远的未来),怀疑是 秒 vs 毫秒。toISOString() 看起来正确但 toString() 有“偏移”,你正在看到本地时区显示问题。getTimezoneOffset() 随日期变化,说明你跨越了夏令时。许多“在我机器上工作”的报告仅仅是环境默认值不同。
console.log(Intl.DateTimeFormat().resolvedOptions());
console.log('TZ:', process.env.TZ);
console.log(Intl.DateTimeFormat().resolvedOptions().timeZone);
如果你的服务器运行在 UTC 上而你的笔记本在本地时区,除非你显式指定 timeZone,否则格式化输出会不同。
围绕 DST 边界和“边缘”时间创建单元测试:
23:30 → 00:30 的交叉情况如果你快速迭代,考虑把这些测试作为你的脚手架的一部分。例如,在 Koder.ai 生成 React + Go 应用时,可以提前添加一个小型“时间契约”测试套件(API 负载示例 + 解析/格式化断言),以便在部署前捕获回归。
"2025-03-02 10:00"。locale,并在需要时指定 timeZone。在 JavaScript 中可靠地处理时间主要是选择一个“事实来源”并在存储到显示的整个链路中保持一致。
在 UTC 中存储与计算。把面向用户的本地时间视为展示细节。
在系统间传输日期时使用带明确偏移的 ISO 8601 字符串(最好是 Z)。如果必须发送数值纪元时间,记录字段并注明单位(毫秒是 JS 的常见默认)。
使用 Intl.DateTimeFormat(或 toLocaleString)为人类展示,并在需要确定性输出时传入显式 timeZone(例如始终以 UTC 或特定业务区域显示时间)。
Z 的 ISO 8601(例如 2025-12-23T10:15:00Z)。如果使用纪元,字段名应体现单位(例如 createdAtMs)。如果你需要重复事件、复杂时区规则、DST 安全的算术(“明天同一本地时间”)或大量来自不一致输入的解析,考虑使用专门的日期时间库。其价值体现在更清晰的 API 和更少的边缘情况错误上。
如果你想深入了解更多时间相关的指南,请访问 /blog。如果你在评估工具或支持选项,请参见 /pricing。