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

JavaScript 时间通常出问题的地方
JavaScript 的时间错误很少看起来像“时钟坏了”。它们通常以令人困惑的小偏移出现:某个日期在你的笔记本上是正确的,但在同事的机器上是错误的;API 的响应看起来没问题,直到在不同的时区渲染;或者报告在季节性时间变更附近“差一”的情况。
最常见的症状
你通常会注意到以下一项(或多项):
- 相差一小时:尤其是在夏令时(DST)期间,或当值被无意间在本地时间与 UTC 之间转换时。
- 相差一天:只有日期的值(例如 “2025-12-23”)会根据时区显示为前一天或后一天。
- 时区错误:时间看起来正确,但偏移(例如
+02:00)与预期不同。 - 格式不一致:在 Chrome 上“工作”,但在 Safari 上看起来不同,或者服务器和浏览器在解析字符串时意见不一。
为什么会发生:"时间" 可以有不同含义
一个很大的痛点是单词 时间 可以指不同的概念:
- 一个瞬时点(instant):全球的具体时刻(例如 “2025-12-23T10:00:00Z”)。这通常是用于日志、事件和 API 存储的正确选择。
- 日历日期(calendar date):没有时区的日历日(例如生日、发票日期)。将其当作瞬时点处理可能会跨天边界移动。
- 本地时钟时间(wall-clock time):例如“柏林时间上午 9:00”,依赖于时区规则和夏令时变化。
JavaScript 内置的 Date 试图覆盖这些用例,但它主要表示一个瞬时点,同时又经常把你引向本地显示,这就使得无意转换变得很容易。
本文关注的内容
本指南着眼于实用性:如何在浏览器和服务器间得到可预测的转换,如何选择更安全的格式(比如 ISO 8601),以及如何发现经典陷阱(秒与毫秒、UTC 与本地、解析差异)。目标不是更多理论,而是更少的“为什么它会偏移?”的意外。
时间数据类型:时间戳、Date 与字符串
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),有些依赖区域设置,还有些根本不包含时区信息。
“瞬时点” 与 “面向人类的显示时间”
- 瞬时点:指“这个确切的全球时刻”(最好以纪元毫秒或以 UTC 的 ISO 字符串存储)。
- 面向人类的显示时间:指“用户应该看到的时间”(依赖于语言环境与时区)。
同一个瞬时点在不同时区可以有不同的显示:
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" (前一天)
选择一个“单一事实来源(source of truth)”
在你的应用和 API 中选择一个统一的内部表示(通常是纪元毫秒或UTC ISO 8601),并坚持使用。仅在边界处(输入解析与 UI 显示)进行到/从 Date 与格式化字符串的转换。
时间戳:秒与毫秒(容易混淆)
“时间戳”通常指纪元时间(也称 Unix 时间):自 1970-01-01 00:00:00 UTC 起的时间计数。难点在于不同系统使用不同的单位。
JavaScript 的 Date 是大多数混淆的来源,因为它使用毫秒。许多 API、数据库与日志使用秒。
经验法则
- Unix 时间戳(秒):
1704067200 - JavaScript 时间戳(毫秒):
1704067200000
同一时刻,但毫秒版本多了三个零。
安全转换(秒 ↔ 毫秒)
使用显式的乘除法以确保单位明确:
// 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():它是明确的,可以帮助你立即发现单位错误。
本地时间与 UTC:为什么你的输出会偏移
JavaScript 的 Date 可能令人困惑,因为它存储一个时间瞬时点,但可以以不同时区呈现该瞬时点。
内部上,Date 本质上是“自 Unix 纪元(1970-01-01T00:00:00Z)以来的毫秒数”。这个数字表示 UTC 时刻。当你要求 JavaScript 将该时刻格式化为本地时间(基于计算机/服务器设置)与UTC时,就会发生“偏移”。
本地 getter 与 UTC getter
许多 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)。仅在显示时转换为用户本地:
- 对于 API:优先使用
toISOString()或传递纪元毫秒 - 对于 UI:使用
Intl.DateTimeFormat在用户时区中格式化
如果你在快速构建应用(例如在 Koder.ai 的快速生成工作流中),建议在生成的 API 合同中早早固定这些约定:字段命名清晰(createdAtMs, createdAtIso),并让服务器(Go + PostgreSQL)与客户端(React)对每个字段的含义保持一致。
ISO 8601 字符串:API 的最安全格式
如果需要在浏览器、服务器与数据库之间发送日期/时间,ISO 8601 字符串是最稳妥的默认选择。它们明确、广泛支持,并且(最重要的)携带时区信息。
使用显式的 UTC 或显式偏移
两种好的交换格式:
- UTC 时间(当你不关心本地时区时推荐):
2025-03-04T12:30:00Z - 带偏移的本地时间(当“本地时钟时间”很重要时推荐):
2025-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 日期时间。
安全的往返(字符串 → Date → 字符串)
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 与浏览器差异
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 午夜转换为本地时间时,可能会变成(负时区)前一天,或者小时数出现意外偏移。这是造成“为什么我的日期相差一天?”错误的常见来源。
解析清单:用户输入 vs 服务端输入
对于 服务端/API 输入:
- 偏好带明确时区的完整 ISO 8601,例如
2025-01-15T13:45:00Z或2025-01-15T13:45:00+02:00。 - 将仅日期值视为数据,而不是时间点。如果它是生日或到期日,就把它保留为纯字符串(
"YYYY-MM-DD"),不要转换为Date,除非你也定义了预期的时区。
对于 用户输入:
- 不要接受像
03/04/2025这样的歧义格式,除非你的 UI 强制限定其含义。 - 偏好受控输入(日期选择器),使其输出已知格式。
- 如果必须解析自由文本,请预先定义并强制执行可接受格式。
使用显式规则(而非启发式)
不要依赖 Date.parse() 去“猜测”,选择以下模式之一:
- 只接受来自服务器的 ISO 8601;拒绝其他格式。
- 手动解析已知格式(拆分字符串并使用
new Date(year, monthIndex, day)来创建本地日期)。 - 存储并传输时间戳(纪元毫秒)用于精确的瞬时点,之后再格式化用于显示。
当时间数据很关键时,“在我的机器上解析没问题”不够——要使解析规则明确且一致。
使用 Intl.DateTimeFormat 为人类格式化
如果你的目标是“以人们期望的方式显示日期/时间”,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 模式
为 UI 中每种“时间类型”(消息时间、日志条目、事件开始)选择一个格式化函数,并让 timeZone 的决定变得明确:
- 对于“对我来说何时发生”的情形,使用本地时间。
- 对于审计日志、服务器事件或跨团队协调,使用固定时区(通常是 UTC)。
这样你就可以得到一致、符合区域习惯的输出,而无需维护脆弱的自定义格式字符串集合。
夏令时(DST):隐蔽的一小时偏差错误
夏令时是时区在特定日期改变其 UTC 偏移(通常为一小时)的一种情况。棘手之处在于:DST 不仅改变偏移——它还改变某些本地时间的存在性。
缺失与重复的本地时间
当时钟向前跳时,一段本地时间并不存在。例如在许多地区,时钟会从 01:59 跳到 03:00,因此02:30 本地时间是“缺失的”。
当时钟向后拨时,一段本地时间会发生两次。例如 01:30 可能在切换前与切换后各出现一次,意味着相同的本地时间可以对应两个不同的瞬时点。
加 24 小时 vs “明天同一本地时间”
在 DST 边界,这两者并不等价:
- 加 24 小时:表示“精确的 24 * 60 * 60 秒后”
- 明天同一本地时间:表示“在该时区下明天的 9:00”
如果今晚开始 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 在本地时间中并不存在。
更安全的方法
- 对于经过时间(durations)使用 UTC 算术:使用纪元毫秒和 UTC 方法。
- 对于本地日程安排,明确说明意图:“明天同一本地时间”应使用日历操作(
setDate),而不是简单相加毫秒。 - 通过 API 交换时间时,优先使用带偏移或 Z 的 ISO 8601,使瞬时点明确无疑。
持续时长与日期:不要用 Date 去表示计时器
一个常见错误是使用 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;
});
“一天开始/结束”(本地 vs UTC)
“天的开始”取决于你是指本地时间还是 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?)以及偏移在哪里引入(解析、时区转换、格式化)。
1) 捕获同一时刻的“三种视图”
首先以三种不同的方式记录同一值。这能快速揭示问题是秒与毫秒、本地与 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()随日期变化,说明你跨越了夏令时。
2) 验证环境:时区与语言环境
许多“在我机器上工作”的报告仅仅是环境默认值不同。
- 浏览器: 检查操作系统时区设置与浏览器语言。然后打印:
console.log(Intl.DateTimeFormat().resolvedOptions());
- Node.js / 服务器: 确认进程时区:
console.log('TZ:', process.env.TZ);
console.log(Intl.DateTimeFormat().resolvedOptions().timeZone);
如果你的服务器运行在 UTC 上而你的笔记本在本地时区,除非你显式指定 timeZone,否则格式化输出会不同。
3) 在最易出错处添加测试
围绕 DST 边界和“边缘”时间创建单元测试:
- 在相关时区的 DST 切换前后各一小时
- 月底/年末,以及
23:30→00:30的交叉情况 - 如果产品支持多时区,则覆盖多个时区
如果你快速迭代,考虑把这些测试作为你的脚手架的一部分。例如,在 Koder.ai 生成 React + Go 应用时,可以提前添加一个小型“时间契约”测试套件(API 负载示例 + 解析/格式化断言),以便在部署前捕获回归。
上线前快速检查清单
- 输入有明确契约:纪元毫秒或带偏移的 ISO 8601。
- 没有歧义字符串如
"2025-03-02 10:00"。 - 格式化始终指定
locale,并在需要时指定timeZone。 - 测试覆盖你目标区域的 DST 边界。
可靠处理时间的推荐模式
在 JavaScript 中可靠地处理时间主要是选择一个“事实来源”并在存储到显示的整个链路中保持一致。
一组简单的最佳实践
在 UTC 中存储与计算。把面向用户的本地时间视为展示细节。
在系统间传输日期时使用带明确偏移的 ISO 8601 字符串(最好是 Z)。如果必须发送数值纪元时间,记录字段并注明单位(毫秒是 JS 的常见默认)。
使用 Intl.DateTimeFormat(或 toLocaleString)为人类展示,并在需要确定性输出时传入显式 timeZone(例如始终以 UTC 或特定业务区域显示时间)。
决策指南:数据库 vs API vs UI
- 数据库: 将瞬时点存为 UTC(纪元毫秒或 UTC 的 datetime 类型)。除非你的领域确实存储“本地时钟时间”(如“商店在 09:00 开门”),否则避免使用本地 datetime。
- API 边界: 偏好带
Z的 ISO 8601(例如2025-12-23T10:15:00Z)。如果使用纪元,字段名应体现单位(例如createdAtMs)。 - UI: 取出存储的 UTC 瞬时点并为用户的区域格式化。在调度 UI 中,明确标注时区并保持转换显式。
什么时候值得使用库
如果你需要重复事件、复杂时区规则、DST 安全的算术(“明天同一本地时间”)或大量来自不一致输入的解析,考虑使用专门的日期时间库。其价值体现在更清晰的 API 和更少的边缘情况错误上。
如果你想深入了解更多时间相关的指南,请访问 /blog。如果你在评估工具或支持选项,请参见 /pricing。
常见问题
JavaScript 中的时间戳应该使用什么格式?
请将 Unix 时间戳乘以 1000,再传给 new Date()。JavaScript 的 Date 需要的是毫秒。
为什么我的 Unix 时间戳会变成 1970 年的日期?
JavaScript 的 Date 需要毫秒。请将以秒为单位的 Unix 时间戳乘以 1000,再传给 new Date()。
为什么同一个日期在不同电脑上显示的时间不同?
Date 存储的是同一个时间点,但 JavaScript 往往会按电脑的本地时区显示它。对比 toISOString() 和 toString(),即可判断是数据变了,还是仅显示方式不同。
如何避免日期偏移一天?
生日或发票日期等仅含日期的值,应保留为 YYYY-MM-DD。除非同时定义了预期时区,否则不要将它转换成 Date。
API 最安全的日期格式是什么?
发送带有 UTC 标记 Z 的完整 ISO 8601 值,例如 2025-03-04T12:30:00Z,或包含明确的时区偏移量。避免使用 03/04/2025 这类字符串,因为不同的人和浏览器可能会有不同解读。
应如何为用户格式化日期?
使用 Intl.DateTimeFormat,当界面需要固定时区时,请指定 timeZone。它会按用户的区域设置格式化日期,无需手动拼接字符串。
应该使用 Date.parse 解析用户输入的日期吗?
不要依赖它解析非正式或含糊的字符串。对于 API,接收已知的 ISO 格式;对于受控的用户输入,则按你自己定义的规则解析。
为什么不应使用 Date 表示倒计时或时长?
Date 表示日历上的某个时间点,而时长只是时间的数量。请以秒或毫秒存储时长,并在渲染时格式化该数值。
夏令时如何影响 JavaScript 日期?
进行经过时间计算时,请使用 Unix 纪元毫秒。对于在指定时区内的「明天上午 9:00」等日程,请使用基于日历的操作,并测试夏令时切换日期。
调试 JavaScript 时间问题最快的方法是什么?
记录原始输入、toISOString()、toString() 和 getTimezoneOffset()。错误的 ISO 值通常意味着解析或单位有问题,而不同的本地字符串通常只是时区显示不同。