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"

推荐使用的时区值:

  • UTC
  • America/New_York
  • America/Los_Angeles
  • Europe/London
  • Europe/Berlin
  • Asia/Tokyo
  • Asia/Shanghai

不要把 ESTPSTGMT-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 支持 earlierlatercompatiblereject。能把这个选择显式写出来,才是真正的收益。

数据库里该存什么

存什么,取决于产品上要回答的是哪个问题。

产品含义 该存什么
“这笔付款发生在某个确切瞬间” 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:0002:59 根本不存在。
  • 秋季回拨的重叠:本地时间 2026-11-01 01:0001: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 自己的文档,它已经是一个处于维护模式的遗留项目。已有应用可以在谨慎迁移期间继续维护它,但新的时区代码应该换个方案。

官方参考资料

相关时间戳指南

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 已默认启用。