我实测了前端日期的 5 个坑:差 8 小时只是开始

0 阅读7分钟

差 8 小时的问题,出在肉眼看一样的两行代码上:

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,东八区凌晨记录少一天
3Safari 白屏空格写法 Chrome 正常,iPhone 直接 Invalid Date
4加一个月跳两月1 月 31 日 setMonth(+1) 跳到 3 月 3 日
5回到 197010 位秒级时间戳被当毫秒解析

坑 1:同一日期,写法差 8 小时

三种写法,三种结果(Node,UTC+8 实测):

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() 让日期变「昨天」

存「哪一天」最顺手的一行代码,在东八区是个坑:

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
UTC2026-03-15
America/New_York2026-03-15

本地开发在 UTC+8,CI 和容器默认 UTC,海外用户在负时区,同一段代码三个结果。「我本地好好的」这句话在日期 bug 里根本不成立。

防御:给人看的日期别用 toISOString。用 Intl.DateTimeFormat,时区可以显式传:

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 不兜:

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, '/'):

new Date(str.replace(' ', 'T')); // 最低限度修法

更稳的做法是别喂字符串,字段拆开传:new Date(2026, 2, 15, 0, 0)。

坑 4:1 月 31 日加一个月,跳到 3 月 3 日

「下个月同一号」这种需求(续费提醒、账单日),setMonth 会直接溢出:

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 日就往前进位。构造函数也一样沉默:不报错、不警告,日期悄悄变成下个月。

防御:加月先跳到月初,再夹紧到目标月最后一天:

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 年

后端给秒、前端当毫秒,是跨语言协作的经典事故:

new Date(1770000000);    // 1970-01-21,秒被当毫秒
new Date(1770000000000); // 2026-02-02,正确

Java 默认给毫秒,Python 和 Unix 惯例是秒,接口文档不写清楚就各猜各的。现在的秒级时间戳是 10 位、毫秒级是 13 位,量级差 1000 倍。防御一行:

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 才能用,刚试过):

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 条留给你们补充。