先把定义钉死,否则后面每一句都会歪
Unix 时间戳是「从 1970-01-01T00:00:00Z 起经过的秒数」。这句话里每一个词都在发挥作用,尤其是末尾那个 Z——起点是一个 UTC 时刻,而不是某地的 1970 年元旦零点。由此可以推出一条本文会反复用到的结论:时间戳本身不含时区,一个时间戳在全世界只对应一个瞬间,不存在「北京时间的时间戳」和「纽约时间的时间戳」。
所以下面这五个坑里,时区、月份从 0、秒与毫秒这三个全都发生在时间戳与人类可读形式之间的那两道边界上:解析(字符串变成时间戳)和格式化(时间戳变成字符串)。另外两个不在边界上,病根各在别处:闰秒的病根在时间刻度本身——Unix 时间根本不给那一秒留编号,代价是同一个时间戳值可能出现两次;2038 的病根在存储宽度与整数算术——32 位容器装不下那个秒数。标题挑出的三个是最难自己想明白的,另外两个——秒与毫秒的混淆、JavaScript 月份从 0 开始——修起来便宜,但撞上的人多得多。
本文所有输出都是在 Node v22.17.1 / macOS 26.5.2 上实际跑出来的,系统时区 Asia/Shanghai(UTC+8,中国不实行夏令时)。凡是与时区有关的例子我都标注了 TZ,你换一个时区跑,数字应该跟着变——变了才对。
坑零:秒还是毫秒,以及「看位数」为什么只在 2001 到 2286 之间成立
「10 位是秒、13 位是毫秒」这条规则几乎人人都在用,包括本站的时间戳转换工具。它好用,但它的成立条件很少有人说清楚。
先说一个精确的数学事实:对同一个瞬间,毫秒时间戳的位数恒比秒时间戳多 3 位。 证明只有一行:若秒值 s 满足 10^(k-1) ≤ s < 10^k,则毫秒值落在 [1000s, 1000s+999] 区间内,而 1000s ≥ 10^(k+2),1000s+999 ≤ 1000(10^k − 1) + 999 = 10^(k+3) − 1 < 10^(k+3),所以毫秒值恰好是 k+3 位。我另外用 20 万个随机时间戳做了一次穷举验证,反例个数为 0。
let bad = 0;
for (let i = 0; i < 200000; i++) {
const s = Math.floor(Math.random() * 4e9) + 1;
const ms = s * 1000 + Math.floor(Math.random() * 1000);
if (String(ms).length !== String(s).length + 3) bad++;
}
console.log('反例个数:', bad); // 反例个数: 0
看清楚这个事实说的是什么:它是一条相对规则。它保证「毫秒比秒多 3 位」,它没有保证「秒一定是 10 位」。而「10 位是秒、13 位是毫秒」这条工程规则,恰恰是把相对规则当绝对规则用——它偷偷预设了你手上的时间戳落在某一个特定的年份区间里。这个区间的边界是可以算出来的:
| 秒时间戳 | 毫秒时间戳 | 对应的时间区间(UTC) |
|---|---|---|
| 9 位 | 12 位 | 1973-03-03T09:46:40Z 起 |
| 10 位 | 13 位 | 2001-09-09T01:46:40Z 起 |
| 11 位 | 14 位 | 2286-11-20T17:46:40Z 起 |
这张表里最值得记住的是中间那一行的临界时刻。因为 1000 × 10^9 = 10^12,所以 10^9 秒和 10^12 毫秒指的是同一个瞬间,两种表示在这一刻同时进位:
new Date(1000000000 * 1000).toISOString(); // '2001-09-09T01:46:40.000Z'
new Date(1000000000000).toISOString(); // '2001-09-09T01:46:40.000Z'
new Date(999999999 * 1000).toISOString(); // '2001-09-09T01:46:39.000Z' 9 位
new Date(999999999999).toISOString(); // '2001-09-09T01:46:39.999Z' 12 位
2001-09-09T01:46:40Z 就是「按位数猜」的生效起点。 在这一刻之前,秒时间戳至多 9 位、毫秒时间戳至多 12 位,两者都进不了 13 位这个门槛,于是位数规则会把毫秒一律误判成秒。这不是理论问题——凡是系统里存了 2001 年以前的日期,就会撞上:
Date.UTC(2000, 0, 1); // 946684800000(12 位)
new Date(946684800000 * 1000).toISOString(); // '+031969-04-01T00:00:00.000Z'
Date.UTC(1980, 4, 15); // 327196800000(12 位)
new Date(327196800000 * 1000).toISOString(); // '+012338-06-16T00:00:00.000Z'
公元 31969 年和公元 12338 年这种结果至少是吵闹的,一眼就知道错了。真正阴险的是歧义带:一个 10 位的值,按秒读落在 2001-09-09T01:46:40Z 到 2286-11-20T17:46:39Z 之间,按毫秒读落在 1970-01-12T13:46:40Z 到 1970-04-26T17:46:39.999Z 之间。两种读法都能得到一个语法上合法、语义上「像是真的」的日期——而 1970 年 1 月那个结果的形状,恰好长得像「这个字段没赋值」。你不会收到任何报错,只会在某张报表里看到一小批记录莫名其妙地落在纪元附近。
new Date(1000000000 * 1000).toISOString(); // '2001-09-09T01:46:40.000Z'
new Date(1000000000).toISOString(); // '1970-01-12T13:46:40.000Z'
负数时间戳则直接击穿位数法。-1 只有两个字符,它表示的是 1969-12-31T23:59:59Z;纪元之前的任何时刻都是负数,位数完全不再携带量级信息。
所以更稳的做法是两种都算一遍,按结果是否落在一个合理的年份窗口里取舍,而不是数位数:
function guessEpoch(n, lo = Date.UTC(1990, 0, 1), hi = Date.UTC(2100, 0, 1)) {
const okSec = n * 1000 >= lo && n * 1000 <= hi;
const okMs = n >= lo && n <= hi;
if (okSec && !okMs) return { unit: 's', date: new Date(n * 1000) };
if (okMs && !okSec) return { unit: 'ms', date: new Date(n) };
if (okMs && okSec) return { unit: 'ambiguous' };
return { unit: 'unknown' };
}
这个函数把「猜」变成了显式的「窗口假设」:窗口是你自己写下来的,歧义和越界都会被单独报出来,而不是悄悄给你一个日期。代价是它不是万能的——1972 年的秒时间戳 63072000 会返回 unknown,因为它落在窗口外。这是刻意的:与其猜错,不如承认猜不出来。
顺带说一个真实事故形状。JWT 的 exp 字段,RFC 7519 规定是 NumericDate,也就是秒。如果你顺手把 Date.now()(毫秒)塞进去,比如 1788998400000,把它当秒读出来就是公元 58661 年——这个值在 JavaScript 的 Date 范围之内,不会报错,令牌只是安静地变成了永不过期。反方向也一样糟:把秒值 1788998400 当毫秒读,得到 1970-01-21T16:56:38.400Z,令牌永远是过期状态,每个请求都 401。
坑一:时区根本不在时间戳里
这是本文最想说清楚的一条。「时间戳有时区问题」这句话本身就是说反的——时间戳恰恰是为了消除时区歧义才长成一个纯整数的。同一个毫秒时间戳 1788998400000,在五个时区里渲染出五种字符串,但 getTime() 在每一个时区里都返回同一个数:
TZ |
本地渲染(toLocaleString('sv-SE')) |
getTime() |
|---|---|---|
UTC |
2026-09-10 00:00:00 | 1788998400000 |
Asia/Shanghai |
2026-09-10 08:00:00 | 1788998400000 |
America/New_York |
2026-09-09 20:00:00 | 1788998400000 |
Pacific/Kiritimati |
2026-09-10 14:00:00 | 1788998400000 |
Pacific/Midway |
2026-09-09 13:00:00 | 1788998400000 |
五行里的日期跨了两天,时刻跨了 25 个小时,而时间戳一动没动。所以你在生产环境遇到的每一个「时区 bug」,位置必然在解析或格式化这两道边界上,往时间戳身上找原因永远找不到。
边界上最著名的一个坑是这个,而且它是 ECMAScript 规范明文规定的行为差异,不是某个引擎的怪癖:只有日期的字符串按 UTC 解析,带了时间但不带偏移量的字符串按本地时区解析。
// TZ=Asia/Shanghai node
const a = new Date('2026-09-10');
const b = new Date('2026-09-10T00:00:00');
console.log(a.getTime(), a.toISOString(), a.toString());
console.log(b.getTime(), b.toISOString(), b.toString());
console.log((a.getTime() - b.getTime()) / 3600000);
我实际跑出来的输出:
1788998400000 2026-09-10T00:00:00.000Z Thu Sep 10 2026 08:00:00 GMT+0800 (China Standard Time)
1788969600000 2026-09-09T16:00:00.000Z Thu Sep 10 2026 00:00:00 GMT+0800 (China Standard Time)
8
两个字符串在人眼里说的是同一件事,解析出来的时间戳差了 28800000 毫秒,正好是 8 小时——a 比 b 晚 8 小时,因为 a 被钉在 UTC 零点(即本地 08:00),而 b 被钉在本地零点(即 UTC 前一天 16:00)。
这里有一个非常值得注意的细节:在 UTC+8 这个时区里,两者的 getDate() 都返回 10,日期看起来是对的。差异只体现在 8 小时的偏移上,而 8 小时的偏移在很多业务逻辑里不会立刻显形。换成负偏移的时区,同一段代码立刻就露出「差一天」:
// TZ=America/New_York node
new Date('2026-09-10').toString();
// 'Wed Sep 09 2026 20:00:00 GMT-0400 (Eastern Daylight Time)'
new Date('2026-09-10').getDate(); // 9
new Date('2026-09-10T00:00:00').getDate(); // 10
同一份代码,在 UTC+8 的机器上表现为 8 小时偏移,在 UTC−4 的机器上表现为日期差一天。 这就是为什么这类 bug 常常是「本地跑好好的,上线就错了」——服务器多半跑在 UTC 上,而 UTC 的偏移量是 0,两种解析在那里恰好一致,于是它在服务器上也「好好的」,只在用户的浏览器里出错。
同一个坑还有一个反向的版本,出现在往回写的时候。前端拿到一个本地零点的 Date,想把它存成 YYYY-MM-DD,顺手用了 toISOString():
// TZ=Asia/Shanghai node
new Date('2026-09-10T00:00:00').toISOString().slice(0, 10); // '2026-09-09'
new Date('2026-09-10').toISOString().slice(0, 10); // '2026-09-10'
用户选的是 9 月 10 日,存进数据库的是 9 月 9 日。toISOString() 永远输出 UTC,本地零点在任何正偏移时区里都落在 UTC 的前一天。
把常见写法一次列全,会看到规范划的那条线:
| 字符串 | 解析出的时间戳 | 等价 UTC 时刻 |
|---|---|---|
'2026-09-10' |
1788998400000 | 2026-09-10T00:00:00Z |
'2026-09-10T00:00:00Z' |
1788998400000 | 2026-09-10T00:00:00Z |
'2026-09-10T00:00:00' |
1788969600000 | 2026-09-09T16:00:00Z |
'2026-09-10T00:00' |
1788969600000 | 2026-09-09T16:00:00Z |
'2026-09-10T00:00:00+08:00' |
1788969600000 | 2026-09-09T16:00:00Z |
'2026/09/10' |
1788969600000 | 2026-09-09T16:00:00Z |
'2026-9-10' |
1788969600000 | 2026-09-09T16:00:00Z |
'Sep 10 2026' |
1788969600000 | 2026-09-09T16:00:00Z |
前五行是规范定义的 ISO 8601 格式,行为有保证。后三行——斜杠分隔、月份不补零、英文月名——统统不是 ISO 格式,规范允许引擎自行决定怎么解析。我这台机器上它们都按本地时区处理了,这个结果不可移植,换一个引擎可以是别的解析结果,也可以直接是 Invalid Date。不要把非 ISO 格式的字符串交给 new Date(),这不是风格偏好,是可移植性问题。
处方只有三句话,但每一句都要照做。存储与传输一律用时间戳整数或带 Z 的 ISO 字符串;解析时永远让字符串自带偏移量,不要指望运行环境的时区是你以为的那个;只有在最后一步渲染给人看的时候才引入时区,而且要显式指定(toLocaleString 的 timeZone 选项),不要依赖系统默认值。
坑二:闰秒——Unix 时间干脆假装它不存在
闰秒是 IERS 为了让 UTC 跟上地球自转的快慢而插入的一秒,插入形式是让某一天的最后一分钟有 61 秒:23:59:59 之后是 23:59:60,然后才是次日 00:00:00。
而 Unix 时间对这件事的处理方式非常直接:它不计闰秒。 POSIX 规定每天恰好 86400 秒,时间戳等于「天数 × 86400 + 当天的秒数」,没有留给第 86401 秒的位置。这句话的代价可以量化:
const t1972 = Date.UTC(1972, 0, 1) / 1000; // 63072000
const t2017 = Date.UTC(2017, 0, 1) / 1000; // 1483228800
console.log(t2017 - t1972); // 1420156800
console.log((t2017 - t1972) / 86400); // 16437(整数天,一秒不多)
1972-01-01 到 2017-01-01 之间,Unix 时间刻度走了 1420156800 秒,正好是 16437 个整天。而这段时间里真实流逝的原子秒是 1420156827 秒——因为 TAI−UTC 在 1972-01-01 是 10 秒,到 2017-01-01 变成了 37 秒,期间插入了 27 个闰秒。那 27 秒在 Unix 时间轴上根本不存在,它们不是被压缩了,是被跳过了:Unix 时间轴与原子时间轴之间的换算关系不是常数,也不是任何公式,而是一张必须查表的历史记录。
具体到闰秒发生的那一瞬间,问题变成「23:59:60 该拿哪个编号」。答案是没有编号:
Date.UTC(2016, 11, 31, 23, 59, 59) / 1000; // 1483228799
Date.UTC(2017, 0, 1) / 1000; // 1483228800
new Date('2016-12-31T23:59:60Z').toString(); // 'Invalid Date'
两个相邻编号只差 1,中间那一秒无处安放。实现只有两条路可走,各有各的后果。一是让时间戳重复:1483228799 这个值出现两次,持续两秒钟。于是你依赖的三条直觉全部失效——时间戳不再单调,两条日志可能有完全相同的时间戳却不是同时发生,用两个时间戳相减测出来的间隔可能是 0 甚至为负。二是抹平(leap smear):不插入那一秒,而是在一段窗口内把本机时钟走慢一点,让它自然而然地落后一秒。Google 推荐并在自家公共 NTP 上采用的方案是以闰秒时刻为中心、从 UTC 正午到次日正午的 24 小时线性抹平,频率偏差是 1/86400,也就是约 11.57 ppm——这个量级小于多数石英振荡器本身的误差,所以对应用几乎无感。代价是抹平窗口内这台机器的时钟与真正的 UTC 并不相等:以闰秒时刻为中心的 24 小时线性抹平,累计要慢掉 1 秒,偏差在闰秒那一刻达到最大值 0.5 秒,窗口两端归零。而且同一个集群里如果有的机器抹平、有的机器跳秒,两者在那一天里就对不上。
由此得到一条比闰秒本身更通用的纪律:永远不要用两个墙钟时间戳相减来测量时间间隔。 闰秒、NTP 校正、用户手动改表都会让墙钟往回跳(夏令时不在此列——它只改本地时间的渲染,不改时间戳)。测间隔要用单调时钟——浏览器和 Node 里是 performance.now(),Linux 上是 CLOCK_MONOTONIC。
最后是闰秒的现状,这一段我在 2026 年 9 月 12 日逐条查证过:
- 最近一次闰秒是 2016-12-31T23:59:60 UTC,此后没有再插入过。
- IERS 最新的 Bulletin C 是第 72 期,日期 2026 年 7 月 6 日,内容是 2026 年底不插入闰秒,并确认「自 2017 年 1 月 1 日起直至另行通知,UTC−TAI = −37 秒」。
- 国际计量大会(CGPM)第 27 届会议(2022 年)第 4 号决议《关于 UTC 的使用与未来发展》决定:UT1−UTC 差值的上限将在 2035 年或之前被放大(原文 “decides that the maximum value for the difference (UT1-UTC) will be increased in, or before, 2035”)。放大容差就是让闰秒实际上不再需要被插入的手段——这一点值得说精确:该决议通过的不是「废除闰秒」这四个字,而是改上限这一个动作。
- 同一份决议要求 CIPM 与 ITU 等机构磋商,提出一个能保证 UTC 至少一个世纪连续的新上限值,并起草决议提交第 28 届 CGPM 表决。第 28 届 CGPM 的会期是 2026 年 10 月 13 日至 15 日——写下这段话时它还没有召开,所以具体的新上限值目前尚未确定。
对写代码的人来说,结论是:闰秒只能在 6 月底或 12 月底插入,而 2026 年 12 月底已被 Bulletin C 72 排除,所以下一次最早也要到 2027 年 6 月底;按 2022 年那份决议的方向,2035 年之后大概不会再有。但你的历史数据里已经带着 27 个闰秒留下的坑,而「时间戳单调递增」这个假设本来就不该建立在闰秒不会发生上——NTP 校正随时都在干同样的事。
坑三:2038 年 1 月 19 日 03:14:07 UTC
32 位有符号整数能表示的最大值是 2^31 − 1 = 2147483647。把它当秒时间戳解释:
new Date(2147483647 * 1000).toISOString(); // '2038-01-19T03:14:07.000Z'
这就是 2038 问题的精确时刻。再加一秒,32 位有符号容器就会回绕到 −2^31:
const i32 = new Int32Array(1);
i32[0] = 2147483647;
new Date(i32[0] * 1000).toISOString(); // '2038-01-19T03:14:07.000Z'
i32[0] = 2147483648; // 存不下,回绕
console.log(i32[0]); // -2147483648
new Date(i32[0] * 1000).toISOString(); // '1901-12-13T20:45:52.000Z'
时钟不是停住,而是跳回 1901 年 12 月 13 日——这个方向很重要:受影响的系统不会卡死在 2038,它们会突然认为现在是 20 世纪初,于是所有「已过期」「未来时间」的判断同时翻转。顺便一提,这个时刻的本地表示各地不同,在 Asia/Shanghai 是 2038-01-19 11:14:07(+0800),在 America/New_York 是 2038-01-18 22:14:07(−0500),但它们是同一个瞬间。
这个坑今天还活着的地方,主要有三类。一是数据库列类型。 MySQL 的 TIMESTAMP 官方文档明确给出的范围就是 '1970-01-01 00:00:01' 到 '2038-01-19 03:14:07' UTC,因为它内部存的正是纪元以来的秒数;DATETIME 没有这个限制,但它不带时区语义,两者不是随便能换的。二是 32 位 time_t。 在 64 位 Linux 上 time_t 早就是 64 位了,但 32 位构建、旧版嵌入式 libc、以及任何把时间存成 int 的自研协议都还在 32 位上。三是嵌入式设备本身:路由器、工控机、智能电表这类东西的生命周期常常按十年计,2038 对它们不是遥远的将来。
JavaScript 为什么不受影响?因为 Date 内部根本没有 32 位整数,它存的是一个双精度浮点的毫秒数,规范规定的合法范围是 ±8.64e15 毫秒,也就是 ±10^8 天:
new Date(8.64e15).toISOString(); // '+275760-09-13T00:00:00.000Z'
new Date(-8.64e15).toISOString(); // '-271821-04-20T00:00:00.000Z'
new Date(8.64e15 + 1).toString(); // 'Invalid Date'
+275760-09-13 和 -271821-04-20——JavaScript 的 Date 能表示的时间跨度大约是公元前 27 万年到公元 27.6 万年,2038 在这个范围里连一个零头都算不上。8.64e15 这个数字也不是随手取的:8.64e15 / 86400000 正好等于 10^8,规范选的是「纪元前后各一亿天」这个整数。
但是——JavaScript 有它自己的 2038 坑,而且它藏在一个几乎人人都写过的习惯里。 Date 本身安全,可是位运算不安全:ECMAScript 的所有位运算符都会先把操作数转成 32 位整数。于是那个用来「取整」的 ~~、| 0、>> 0,全都是 32 位截断器:
~~2147483647; // 2147483647 还对
~~2147483648; // -2147483648 回绕了
2147483648 | 0; // -2147483648
2147483648 >> 0; // -2147483648
~~2200000000; // -2094967296
new Date(~~2200000000 * 1000).toISOString(); // '1903-08-13T16:38:24.000Z'
~~(Date.now() / 1000) 这行代码在 2038-01-19T03:14:07Z 之前完全正确,越过那一刻的下一秒,它返回的值对应 1901-12-13T20:45:52Z,此后随着真实时间前进,它给出的年份从 1901 一路爬向 1970。它没有报错,也没有 NaN,就是静静地给出一个错了一百三十多年的值。改成 Math.floor() 或 Math.trunc() 即可,它们走的是浮点路径,不做 32 位截断。无符号右移 >>> 0 把范围推到 2^32 − 1,对应 2106-02-07T06:28:15Z,只是把问题推迟了 68 年,不算解决。
坑四:月份从 0 开始,而且它不报错
Date 的构造器和 Date.UTC() 的月份参数是 0 到 11,日期参数却是 1 到 31。这个不一致本身人人都知道,值得写下来的是它和时间戳换算叠在一起时的具体形状。
最典型的一个是手工拆解日期字符串:
const [y, m, d] = '2026-09-10'.split('-').map(Number);
Date.UTC(y, m, d) / 1000; // 1791590400 → 2026-10-10T00:00:00Z 错
Date.UTC(y, m - 1, d) / 1000; // 1788998400 → 2026-09-10T00:00:00Z 对
两个时间戳差 2592000 秒,整整 30 天。注意这个错误的形状:它产出的是一个完全合法的时间戳,指向一个真实存在的日期,只是月份大了一个。 没有异常,没有 Invalid Date,没有任何校验会拦住它。
更隐蔽的是「反向修正」引发的错误。有人从时间戳里取月份、打印出来、发现 9 月得到的是 8,于是「修好了它」:
const now = new Date(1788998400000); // 2026-09-10T00:00:00Z
now.getUTCMonth(); // 8
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1); // 2026-10-01T00:00:00Z 错
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1); // 2026-09-01T00:00:00Z 对
getUTCMonth() 返回的 8 和 Date.UTC() 期待的 8 用的是同一套编号,两边都是 0 起。中间加的那个 + 1 把「求本月初」变成了「求下月初」,一份按月统计的报表就此整体错位一个月——而它每个月都错,所以看不出异常波动。
还有两个相关行为值得单独记住。参数越界不会报错,只会静默进位,Date.UTC(2026, 12, 1) 得到 2027-01-01T00:00:00Z。而日期参数传 0 表示「上个月的最后一天」,这条规则和 0 起月份撞在一起会产生一个碰巧正确的结果:Date.UTC(2026, 9, 0) 得到 2026-09-30T00:00:00Z,正好是 9 月最后一天。这个惯用法很好用,但请注意它之所以对,是因为「月份 9 = 10 月」和「日期 0 = 上月末」两个偏移刚好抵消了;有人照着它推断「原来 9 就是 9 月」,下一行就会错。
为什么这些坑在控制台里「看起来是对的」
如果你照着上面的例子自己试一遍,很可能会觉得我在小题大做——因为同一个 Date 对象在两种显示口径下各把一半的坑遮住了,而两种口径都不会提到 getTime()。实测同一个 new Date('2026-09-10'):
// TZ=Asia/Shanghai node
const d = new Date('2026-09-10');
console.log(d); // 2026-09-10T00:00:00.000Z
console.log(String(d)); // Thu Sep 10 2026 08:00:00 GMT+0800 (China Standard Time)
console.log(d.getTime()); // 1788998400000
第一行走的是 Node 的 util.inspect,它按 UTC 输出 ISO 串。你看到 2026-09-10T00:00:00.000Z,脑子里读成「9 月 10 日零点」,于是很自然地以为它解析成了你所在时区的零点——而那个 Z 说的是 UTC 零点,与本地零点差了整整 8 小时。第二行走的是 toString()(字符串拼接、模板字符串、alert 走的都是这条路),它按本地时区输出。这一行的时刻是准确的,但你的眼睛先抓住的是 Sep 10——日期没变,看着没问题,而坑恰恰藏在那个被当成背景噪声的 08:00:00 里。
同样地,如果你只在 UTC+8 或者服务器的 UTC 环境里验证「日期解析」,你永远复现不出用户在美洲报告的「差一天」。不是因为代码是对的,而是因为你的偏移量让两种解析看起来一致:UTC 下偏移为 0,'2026-09-10' 与 '2026-09-10T00:00:00' 解析结果完全相同;UTC+8 下两者差 8 小时,但落在同一天之内。要看到日期整体错位,偏移量必须是负的。
所以验证时间相关的行为只有一个可靠办法:比较 getTime() 返回的整数,不要比较渲染出来的字符串。 整数相等才能证明是同一个瞬间,字符串看起来一样什么也证明不了。想复现异地行为,就在命令行上换时区跑一遍——TZ=America/New_York node yourscript.js 只多打十几个字符,却能在上线之前替你抓住整整一类 bug。