Date 存的是瞬间,不是时区
JavaScript 的 Date 以纪元毫秒的形式存一个瞬间。它不会记住用户当初是在哪个时区输入这个值的,所以时区处理属于解析环节、显示环节,或者另外那份描述本地日程的数据。
const date = new Date(1700000000000);
date.toISOString();
// "2023-11-14T22:13:20.000Z"
new Intl.DateTimeFormat("zh-CN", {
timeZone: "America/New_York",
dateStyle: "medium",
timeStyle: "short",
}).format(date);
// "2023年11月14日 17:13"
按这张表来选:
| 任务 | 用什么 |
|---|---|
| 存一个精确的事件时间 | Unix 毫秒、Unix 秒,或 ISO 8601 UTC |
| 显示 UTC | date.toISOString() 或 timeZone: "UTC" |
| 显示用户的本地时间 | Intl.DateTimeFormat(..., { timeZone }) |
| 检测用户浏览器的时区 | Intl.DateTimeFormat().resolvedOptions().timeZone |
| 把本地预约时间转成 UTC | Temporal、Temporal polyfill、Luxon 或 date-fns-tz |
| 处理周期性会议 | 把日期、时间和 IANA 时区名都存下来 |
心智模型:瞬间 vs 显示
JavaScript 里绝大多数时区 bug,都源于把两件不同的事混为一谈:
- 瞬间:一个确切时刻,比如
2023-11-14T22:13:20.000Z。 - 钟面显示:某地的人看到的样子,比如“纽约的下午 5:13”。
Date 代表的是那个瞬间。它不记得用户当初指的是纽约、东京、UTC,还是服务器的本地时区。
const date = new Date("2023-11-14T22:13:20Z");
date.getTime();
// 1700000000000
这个数字在哪儿都一样。选时区,影响的只是钟面表示。审查 JavaScript 时间代码的诀窍,就是把这两层始终分开看。
MDN 对 Date 的描述就是“自 Unix 纪元起的毫秒数”,而这个时间戳与时区无关。那些本地方法只有在需要日历字段或字符串时,才去读取宿主系统的时区。
在 JavaScript 里格式化成 UTC
日志、接口调试、测试、数据库快照和文档,用 UTC 显示通常最稳妥。
const date = new Date(1700000000000);
date.toISOString();
// "2023-11-14T22:13:20.000Z"
如果输入是 Unix 秒,先乘 1000:
const seconds = 1700000000;
new Date(seconds * 1000).toISOString();
// "2023-11-14T22:13:20.000Z"
如果你想要本地化的 UTC 字符串而不是 ISO 格式,用 Intl.DateTimeFormat:
const utcFormatter = new Intl.DateTimeFormat("zh-CN", {
timeZone: "UTC",
dateStyle: "medium",
timeStyle: "long",
});
utcFormatter.format(new Date(1700000000000));
// "2023年11月14日 UTC 22:13:20"
把 Date 格式化到某个 IANA 时区
面向用户的输出,请给 Intl.DateTimeFormat 传一个 IANA 时区名。
const date = new Date("2023-11-14T22:13:20Z");
const fmt = new Intl.DateTimeFormat("en-US", {
timeZone: "America/New_York",
dateStyle: "full",
timeStyle: "long",
});
fmt.format(date);
// "Tuesday, November 14, 2023 at 5:13:20 PM EST"
推荐使用的时区值:
UTCAmerica/New_YorkAmerica/Los_AngelesEurope/LondonEurope/BerlinAsia/TokyoAsia/Shanghai
不要把 EST、PST、GMT-5 或 -05:00 当成用户的时区存下来。那些是偏移量或缩写,不是一套完整的时区规则。而 America/New_York 知道一月通常是 UTC-5、七月通常是 UTC-4。
要格式化很多行时,formatter 实例只构造一次:
const fmt = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Tokyo",
dateStyle: "medium",
timeStyle: "short",
});
rows.map((row) => fmt.format(new Date(row.createdAtMs)));
拿到用户的时区
浏览器代码里用 resolvedOptions():
const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
// "America/Los_Angeles"
几个要点:
- 它是浏览器或运行时当前的默认时区。
- 用户改了操作系统或浏览器设置,它就会变。
- 在容器、CI 或受限环境里,它可能是
UTC。 - 服务端代码拿到的是服务器时区,不是用户时区。
注册表单、排期应用或报表偏好设置,可以把浏览器检测到的 IANA 名字发给你的接口,同时允许用户手动覆盖。
await fetch("/api/profile/timezone", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ timeZone: userTimeZone }),
});
要做时区选择器,现代运行时支持:
const zones = Intl.supportedValuesOf("timeZone");
但存下来的值仍然要校验,因为老浏览器和一些特殊运行时的时区数据可能不完整。
自定义输出请用 formatToParts
别拿 /、- 或逗号去切分本地化后的字符串。不同 locale 的格式化会改变顺序、标点、文字系统和空格。
需要自己排版时,用 formatToParts():
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "America/Chicago",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).formatToParts(new Date("2026-06-20T09:25:00Z"));
const byType = Object.fromEntries(parts.map((part) => [part.type, part.value]));
`${byType.year}-${byType.month}-${byType.day} ${byType.hour}:${byType.minute}`;
// "2026-06-20 04:25"
CSV 导出、后台表格和日期标签都用得上——产品设计要求固定的排版形状,但时区仍然必须正确。
别拿 getTimezoneOffset 去问别的时区
getTimezoneOffset() 经常被误解。它返回的是宿主运行时在那个瞬间的本地偏移。
const date = new Date("2026-01-15T12:00:00Z");
date.getTimezoneOffset();
// 取决于运行这段代码的机器
除非这台机器本身就设成了纽约,否则它回答不了“纽约的偏移是多少”。
它的符号也很容易读反:
- UTC-8 返回
480 - UTC 返回
0 - UTC+3 返回
-180
在实行夏令时的地区,这个值还会随日期变化。所以如果宿主机在纽约,一月和七月给出的偏移通常是不一样的。
只有当你确实需要运行时的本地偏移时,才用 getTimezoneOffset()。要处理具名时区,请用 Intl.DateTimeFormat 或 Temporal。
安全地解析字符串
解析是时区 bug 悄悄溜进来的地方。
请使用带 Z 或明确偏移的字符串:
new Date("2026-06-20T09:25:00Z"); // UTC
new Date("2026-06-20T09:25:00-04:00"); // 明确偏移
对省略了时区的日期时间字符串要格外小心:
new Date("2026-06-20T09:25:00");
// 按运行时的本地时区解释
JavaScript 日期时间字符串格式里有一条容易忽略的规则:像 2019-01-01 这样只有日期的字符串会按 UTC 解释;而 2019-01-01T00:00:00 这种带时间却不带偏移的,会按本地时间解释。
系统边界上请避开这几种:
new Date("06/20/2026");
new Date("Jun 20, 2026");
new Date("2026/06/20 09:25");
这些格式是各引擎自行定义的,在一个引擎里能跑,在另一个里行为可能完全不同。
把钟面时间转成 UTC
把一个已经存在的瞬间格式化出来,很容易:
fmt.format(new Date("2026-06-20T09:25:00Z"));
而从本地钟面时间造出一个瞬间,就难多了:
America/New_York 的 2026-03-08 02:30
这个本地时间正好落在美国春季前拨的空档里——它根本不存在。而在秋季回拨的重叠里,01:30 这样的时间会发生两次。
老的 Date API 没有干净的内置办法,把“钟面时间 + IANA 时区名”构造成一个瞬间。实际可选的方案:
| 需求 | 用什么 |
|---|---|
| 把已有瞬间显示到某个时区 | Intl.DateTimeFormat |
| 把本地钟面时间加 IANA 时区转成瞬间 | Temporal、Temporal polyfill、Luxon 或 date-fns-tz |
| 拒绝不存在或有歧义的本地时间 | Temporal 配 disambiguation: "reject" |
| 不上 polyfill 又要兼容老浏览器 | 一个感知时区的库 |
Temporal 的 ZonedDateTime 就是为这件事设计的。MDN 目前仍把 Temporal 标为有限可用,但 Node.js 26 已默认启用。面向公众的 Web 应用,在全面依赖它之前请先做特性检测或交付 polyfill。
// 需要原生 Temporal 或 Temporal polyfill。
const zdt = Temporal.ZonedDateTime.from(
{
timeZone: "America/New_York",
year: 2026,
month: 11,
day: 1,
hour: 1,
minute: 30,
},
{ disambiguation: "later" },
);
zdt.toInstant().epochMilliseconds;
对于夏令时的空档和重叠,Temporal 支持 earlier、later、compatible 和 reject。能把这个选择显式写出来,才是真正的收益。
数据库里该存什么
存什么,取决于产品上要回答的是哪个问题。
| 产品含义 | 该存什么 |
|---|---|
| “这笔付款发生在某个确切瞬间” | UTC 时间戳,或带 Z 的 ISO 字符串 |
| “按用户本地时区显示这个事件” | UTC 瞬间 + 用户的 IANA 时区 |
| “每周一纽约时间上午 9 点开会” | 本地日期时间规则 + America/New_York |
| “只有日期没有时间,比如生日” | 纯日期值,而不是一个零点的 JavaScript Date |
| “服务器的一行日志” | UTC 时间戳 + 服务器元信息 |
例子:
{
"createdAt": "2026-06-20T09:25:00Z",
"createdAtMs": 1781947500000
}
排定的本地事件:
{
"localDate": "2026-11-01",
"localTime": "01:30",
"timeZone": "America/New_York",
"disambiguation": "later"
}
未来的本地事件,别只存 -04:00 或 -05:00。时区法规是会变的,而偏移量里不含夏令时规则。
落到实处的夏令时空档与重叠
夏令时切换打破了“每个本地钟面时间都恰好对应一个瞬间”这个想当然的前提。
以 America/New_York 为例:
- 春季前拨的空档:本地时间
2026-03-08 02:00到02:59根本不存在。 - 秋季回拨的重叠:本地时间
2026-11-01 01:00到01:59会发生两次。
Unix 时间戳始终在增长,UTC 也一路向前。只有当你把那个瞬间当成本地钟面时间时,怪事才会冒出来。
请拿目标时区的切换日期去比对你的时区代码。一个在普通星期三跑得好好的调度器,可能会在夏令时那两个星期天翻车。
JavaScript 时区常见错误
| 错误做法 | 为什么会出问题 | 怎么改 |
|---|---|---|
共享代码里写 new Date("2026-06-20T09:25:00") |
没时区就等于用运行时本地时区 | 带上 Z 或明确偏移 |
服务端调 date.toLocaleString() |
用的是服务器/容器时区 | 显式传 timeZone |
把 -05:00 当成用户时区存 |
偏移量丢掉了夏令时规则 | 存 America/New_York |
用 getTimezoneOffset() 去问另一个城市 |
它只知道宿主机时区 | 用 IANA timeZone 做格式化 |
为纽约减去 5 * 60 * 60 * 1000 |
纽约并不总是 UTC-5 | 用 IANA 时区规则 |
| 把本地显示字符串当作事实来源 | 没法可靠地转换回去 | 存 UTC 瞬间 + 时区上下文 |
| 假定每个浏览器都有 Temporal | MDN 上仍是有限可用 | 做特性检测或上 polyfill |
| 新代码还在用 Moment | 该项目已进入维护模式 | 改用 Intl、Temporal、Luxon 或 date-fns-tz |
按 Moment 自己的文档,它已经是一个处于维护模式的遗留项目。已有应用可以在谨慎迁移期间继续维护它,但新的时区代码应该换个方案。
官方参考资料
- MDN Date
- MDN Intl.DateTimeFormat
- MDN resolvedOptions
- MDN getTimezoneOffset
- MDN Date.parse
- MDN Temporal
- IANA 时区数据库
相关时间戳指南
Frequent questions:
- Q: JavaScript 的 Date 里存时区吗?
- A: 不存。Date 里只有一个时间戳值:自 1970-01-01 00:00:00 UTC 起的毫秒数。本地时间、UTC 和具名时区,都是后续显示时才做的选择。
- Q: JavaScript 的时间戳一定是 UTC 吗?
- A: 这个时间戳是从 UTC 的 Unix 纪元起算的,本身与时区无关。它不是按纽约时间、东京时间或服务器本地时间存的。时区只在这个瞬间被格式化时才出现。
- Q: 怎么把 Date 格式化成 UTC?
- A: 用 date.toISOString() 得到以 Z 结尾的 UTC ISO 8601 字符串。想要本地化的 UTC 显示,就用 Intl.DateTimeFormat 并设 timeZone: 'UTC'。
- Q: 怎么把 Date 格式化到另一个时区?
- A: 用 Intl.DateTimeFormat 并传入 America/New_York、Europe/London、Asia/Tokyo 这样的 IANA 时区。运行时会为那个日期套用正确的偏移,包括夏令时规则。
- Q: JavaScript 里怎么拿到用户的时区?
- A: 用 Intl.DateTimeFormat().resolvedOptions().timeZone。只要环境暴露了默认时区,它就会返回运行时的默认 IANA 时区,比如 America/Los_Angeles。
- Q: 该存时区偏移还是 IANA 时区名?
- A: 精确瞬间存 UTC。如果还需要用户的本地语境,就存 IANA 时区名,而不是 -05:00 这样的偏移。偏移量不携带夏令时规则,也跟不上行政规则的变更。
- Q: 为什么同一个 Date 在我笔记本和服务器上输出不一样?
- A: toString() 和 toLocaleString() 这类方法在你没显式传 timeZone 时,用的是运行时的默认时区。而你的笔记本、容器、CI runner 和生产服务器,默认值可能各不相同。
- Q: getTimezoneOffset() 返回的是这个 Date 所属时区的偏移吗?
- A: 不是。getTimezoneOffset() 返回的是宿主运行时在那个瞬间的本地偏移。除非这台机器本身就设成了 America/New_York,否则它不会告诉你纽约的偏移。
- Q: 不用库,JavaScript 能把某个 IANA 时区的钟面时间转成 UTC 吗?
- A: 光靠 Date 做不干净。Intl 能把一个已经存在的瞬间格式化到某个时区,但 Date 没法直接构造出「America/New_York 的 2026-03-08 02:30」并解决夏令时歧义。请用 Temporal(原生或 polyfill),或者一个感知时区的库。
- Q: JavaScript 的时区代码该用 Temporal 吗?
- A: 在支持它、或者你能交付 polyfill 的地方,时区运算和「钟面转瞬间」都该用 Temporal。截至 2026 年 7 月 15 日,MDN 仍把 Temporal 标为有限可用,而 Node.js 26 已默认启用。