差 8 小时的问题,出在肉眼看一样的两行代码上:
javascript
new Date('2026-03-15'); // → 本地 2026-03-15 08:00:00
new Date('2026-03-15T00:00:00'); // → 本地 2026-03-15 00:00:00
日期处理是前端少有的「每个项目都要写、每个项目都写出 bug」的领域。我把最容易踩的 5 个坑写成一个实验脚本,在 UTC+8 的 Node 里逐个跑了一遍,下面每个症状都是真实输出,可以自己复跑。先看总账:
| # | 坑 | 一句话症状 |
|---|---|---|
| 1 | 写法差 8 小时 | 两个「同一时刻」,差了整整 8 小时 |
| 2 | 日期变「昨天」 | toISOString() 一刀切到 UTC,东八区凌晨记录少一天 |
| 3 | Safari 白屏 | 空格写法 Chrome 正常,iPhone 直接 Invalid Date |
| 4 | 加一个月跳两月 | 1 月 31 日 setMonth(+1) 跳到 3 月 3 日 |
| 5 | 回到 1970 | 10 位秒级时间戳被当毫秒解析 |
坑 1:同一日期,写法差 8 小时
三种写法,三种结果(Node,UTC+8 实测):
javascript
new Date('2026-03-15');
// toISOString(): '2026-03-15T00:00:00.000Z' → 本地显示 08:00
new Date('2026-03-15T00:00:00');
// toISOString(): '2026-03-14T16:00:00.000Z' → 本地显示 00:00
new Date('2026-03-15 00:00:00');
// V8 里和第二种相同;Safari 里直接 Invalid Date(坑 3)
第一行和第二行差 8 小时。不是 bug,是规范本身:ISO 格式里只有日期的字符串按 UTC 解析,带时间但没带时区的按本地时间解析。写代码的人觉得「这不就是个日期」,解析器认为「这是两种不同的东西」。
被坑的典型场景:表单里选了活动开始日 2026-03-15,前端 new Date(dateStr) 一转再传给后端,凭空多出 8 小时;或者接口返回 2026-03-15T00:00:00Z,前端 .getDate() 直接取,东八区用户看是 15 号,换成负时区的海外用户就成 14 号了。
防御:别让「纯日期」过 Date。字符串直接存、直接传;非要算,就显式补时区('2026-03-15T00:00:00+08:00'),或者等文末的 Temporal。
坑 2:toISOString() 让日期变「昨天」
存「哪一天」最顺手的一行代码,在东八区是个坑:
javascript
const order = new Date('2026-03-15T00:30:00'); // 用户 3 月 15 日凌晨 0:30 操作
order.toISOString().slice(0, 10);
// '2026-03-14' ← 想存日期,少了一天
toISOString() 永远输出 UTC。本地 0:30(东八区)在 UTC 是前一天 16:30,slice 一刀下去,日期倒退一天。
更阴的是它挑环境。同一段代码我换了三个时区跑:
| 运行环境 | slice(0, 10) 结果 |
|---|---|
| Asia/Shanghai(我的本机) | 2026-03-14 |
| UTC | 2026-03-15 |
| America/New_York | 2026-03-15 |
本地开发在 UTC+8,CI 和容器默认 UTC,海外用户在负时区,同一段代码三个结果。「我本地好好的」这句话在日期 bug 里根本不成立。
防御:给人看的日期别用 toISOString。用 Intl.DateTimeFormat,时区可以显式传:
javascript
const fmt = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai' });
fmt.format(order); // '2026/3/15'
toISOString 留给接口和日志这种给机器看的场景。
坑 3:空格写法,Safari 直接 Invalid Date
坑 1 的第三种写法(空格分隔)在规范里根本不存在。ES 只定义了 T 分隔,空格属于实现自定义行为,于是 V8 帮你兜了,Safari 不兜:
javascript
new Date('2026-03-15 00:00:00');
// Chrome / Node:正常解析
// Safari:Invalid Date,页面直接显示 NaN-NaN-NaN
这个坑阴在测试不出来:开发机 Chrome 全绿,安卓机也绿,直到有人在 iPhone 上打开页面看到 NaN。Stack Overflow 上 2010 年就有人问,社区修法一水儿的 replace(' ', 'T') 或 replace(/-/g, '/'):
javascript
new Date(str.replace(' ', 'T')); // 最低限度修法
更稳的做法是别喂字符串,字段拆开传:new Date(2026, 2, 15, 0, 0)。
坑 4:1 月 31 日加一个月,跳到 3 月 3 日
「下个月同一号」这种需求(续费提醒、账单日),setMonth 会直接溢出:
javascript
const d = new Date(2026, 0, 31); // 1 月 31 日
d.setMonth(d.getMonth() + 1);
d.toLocaleDateString('zh-CN'); // '2026/3/3' ← 想要 2 月底,直接跳到 3 月
new Date(2026, 1, 30); // 'Mon Mar 02 2026',2 月 30 日不存在,默默滚走
月份长度不一致,setMonth 只改月份数字,2 月装不下 31 日就往前进位。构造函数也一样沉默:不报错、不警告,日期悄悄变成下个月。
防御:加月先跳到月初,再夹紧到目标月最后一天:
javascript
function addMonths(date, n) {
const d = new Date(date);
const day = d.getDate();
d.setDate(1); // 先避开月末进位
d.setMonth(d.getMonth() + n);
const last = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
d.setDate(Math.min(day, last)); // 31 日夹到目标月末
return d;
}
addMonths(new Date(2026, 0, 31), 1); // 2026/2/28
addMonths(new Date(2026, 0, 31), 2); // 2026/3/31
addMonths(new Date(2026, 7, 31), 1); // 2026/9/30
坑 5:10 位时间戳,回到 1970 年
后端给秒、前端当毫秒,是跨语言协作的经典事故:
javascript
new Date(1770000000); // 1970-01-21,秒被当毫秒
new Date(1770000000000); // 2026-02-02,正确
Java 默认给毫秒,Python 和 Unix 惯例是秒,接口文档不写清楚就各猜各的。现在的秒级时间戳是 10 位、毫秒级是 13 位,量级差 1000 倍。防御一行:
javascript
const ms = ts < 1e12 ? ts * 1000 : ts;
这条分界线(1e12,也就是 2001 年 9 月)离现在的两种时间戳都很远,够用到下个世纪。
速查表:症状 → 根因 → 修复
| 症状 | 根因 | 一行修复 |
|---|---|---|
| 日期差 8 小时 | date-only 按 UTC、带时间按本地 | 纯日期别过 Date,或显式 +08:00 |
| 日期少一天 | toISOString 切到 UTC | 用 Intl.DateTimeFormat 格式化 |
| Safari Invalid Date | 空格分隔不在规范里 | replace(' ', 'T') 或拆字段传参 |
| 加一个月跳到下下月 | setMonth 月末溢出 | setDate(1) 再夹紧月末 |
| 年份显示 1970 | 秒当毫秒 | ts < 1e12 ? ts * 1000 : ts |
| 月份对不上 | 月是 0 起 | new Date(2026, 2, 1) 是 3 月 |
| 周几几号反了 | getDay 是周几 | 周几 getDay,几号 getDate |
| offset 符号反直觉 | 东八区返回 -480 | 返回的是「UTC 减本地」 |
| 海外时长差一小时 | 夏令时当天只有 23 小时 | 跨夏令时按小时加减,别按「天」 |
最后三条是实验里顺手验证的:new Date(2026, 2, 1) 跑出来是 3 月 1 日;美国东部 2026-03-08(夏令时切换日)那天的两个本地零点间隔实测只有 23 小时,凌晨 1:30 加一小时直接落在 3:30。
Temporal 已经在路上了
回头看这 5 个坑的公共根源:Date 把「日期」和「时刻」混在一个类型里,字符串解析规则又叠了一层历史包袱。Temporal 把两件事拆开了,Node 26 起默认启用(我这台 Node 24 要加 --harmony-temporal 才能用,刚试过):
javascript
Temporal.PlainDate.from('2026-03-15');
// '2026-03-15' ------ 就是个日期,没有时区,坑 1 从类型上就不存在
Temporal.Now.zonedDateTimeISO();
// '2026-09-28T14:29:32+08:00[Asia/Shanghai]',时刻带时区,所见即所得
纯日期用 PlainDate,时刻用 ZonedDateTime,坑 1 和坑 2 在类型层面就没了。Node 工具链现在就能试,浏览器端全面铺开之前,上面的防御写法还得再扛一阵。
你被第几个坑过
这 5 个坑按「出现频率 × 隐蔽程度」挑的,实验脚本 40 行,换个 TZ 环境变量就能复跑。你踩过的是第几个、在什么场景发现的,评论区报个数。第 6 条留给你们补充。