
ECMA-262 给日期字符串单独立了一节规范,编号 21.4.1.32,名字叫 Date Time String Format。它把字符串分成两族,第一族没有时间:
This format includes date-only forms: YYYY, YYYY-MM, YYYY-MM-DD
第二族在日期后面接时间,规范原文这么写:
It also includes "date-time" forms that consist of one of the above date-only forms immediately followed by one of the following time forms with an optional UTC offset representation appended: THH:mm, THH:mm:ss, THH:mm:ss.sss
两族的形式分界只有一个字面量 T。真正决定结果是哪一天的那句话,藏在 Date.parse 的算法步骤里:
When the UTC offset representation is absent, date-only forms are interpreted as a UTC time and date-time forms are interpreted as a local time.
读到这句,少一天这种现象就有答案了。2026-09-15 没有时间,被判成 UTC 零点;2026-09-15 00:00:00 带了时间又没带偏移,被判成本地零点。东八区的这两个零点之间隔着 8 小时,取本地日期时就会落到不同的日子上。
只带日期的短横线写法,语义是 UTC 零点
按上面第一句,2026-09-15 属于 date-only 那一族,规范给它定的时间是 UTC 零点。脚本里断言过一次,返回 true:
const d = new Date("2026-09-15");
console.log(d.getTime()); // 1789430400000
console.log(d.getTime() === Date.UTC(2026, 8, 15)); // true
console.log(d.getHours()); // 东八区是 8
东八区看到的是当天早上八点,所以取 getDate() 还是 15 号,肉眼查不出毛病。
带时间却不带偏移,规范判给本地
2026-09-15T00:00:00 属于 date-time 那一族,没有偏移量,语义就是本地零点,也就是 UTC 的前一天 16:00。
空格和斜杠落在规范之外
2026-09-15 00:00:00 和 2026/09/15 都不满足上面那两族的写法,属于引擎自己兜底的解析。规范对这种情况给了许可:
If the String does not conform to that format the function may fall back to any implementation-specific heuristics or implementation-specific date formats.
V8 认这两种写法,并且当成本地时间,零点就是本地的零点,也就是 UTC 的前一天 16:00。别的引擎认不认同样的写法,我没测。
实测六种写法,落点分成两队
实测环境是 Node 26.8.1,解析器是 V8,浏览器里的表现没有逐条对照过。时区先设成东八区,把上面提到的六种写法挨个解析了一遍:
TZ=Asia/Shanghai node tools/verify-date-parsing.mjs
输入字符串 getTime() 本地日期 toISOString()
2026-09-15 1789430400000 2026-09-15 08:00:00 2026-09-15T00:00:00.000Z
2026-09-15T00:00:00 1789401600000 2026-09-15 00:00:00 2026-09-14T16:00:00.000Z
2026-09-15T00:00:00Z 1789430400000 2026-09-15 08:00:00 2026-09-15T00:00:00.000Z
2026-09-15T00:00:00+08:00 1789401600000 2026-09-15 00:00:00 2026-09-14T16:00:00.000Z
2026/09/15 1789401600000 2026-09-15 00:00:00 2026-09-14T16:00:00.000Z
2026-09-15 00:00:00 1789401600000 2026-09-15 00:00:00 2026-09-14T16:00:00.000Z
日历上都是 9 月 15 日,时间戳却分成两个队伍:1789430400000 和 1789401600000,正好差 8 小时。对上规范那条规则,第一行和第三行是 UTC 零点(第一行是 date-only,第三行显式写了 Z);第二行的写法属于 date-time 族又缺偏移,按本地零点算;第四行自带 +08:00,算出来的瞬间刚好跟本地零点重合,时间戳就落回同一个数;第五行和第六行不在规范内,V8 兜底解析时也按本地时间处理,落点还是本地的零点。
少一天会在两个方向发生
一个方向在东八区本地就能撞上。把日期框的值手动拼上时间再转 UTC,日期直接退一天:
new Date("2026-09-15 00:00:00").toISOString().slice(0, 10); // "2026-09-14"
new Date("2026-09-15").toISOString().slice(0, 10); // "2026-09-15"
后端要是按 UTC 存日期字段,第一条就存成了 09-14。这么写的理由也算充分:怕后端收到不带时间的字符串没法存。
另一个方向只在西边的时区出现。同一个 new Date("2026-09-15"),在纽约解析成 2026-09-14 20:00,本地取值就是 14 号。换个时区重跑脚本:
TZ=America/New_York node tools/verify-date-parsing.mjs
# 8) date-only 输入框回显:14 号 / toISOString().slice(0,10) 是 2026-09-15
# 9) new Date('2026-12-31') 本地 = 2026-12-30 19:00:00
toISOString 看着是对的,换成本地取值就退一天。这个差异在东八区永远测不出来,因为本地相对 UTC 是正偏移,UTC 零点落回来还是同一天。意识到方向之后才回头去翻解析规则,把时区反过来又跑了一遍。
数值构造器没有日历校验
顺手测了下非法的年月日,结果有点意外:
2026-02-30 -> 2026-03-02 被悄悄滚到了别的日期
2026-13-01 -> 2027-01-01 被悄悄滚到了别的日期
2025-02-29 -> 2025-03-01 被悄悄滚到了别的日期
规范里 MakeDay 的最后一步是 Return 𝔽(Day(tv)) + 𝔽(dayMV) - 1𝔽(𝔽 是规范标注浮点数的写法)。它把 day 直接加在当月 1 号的天序号上再减 1,中间没有查过这个月有没有 30 号。所以 new Date(2026, 1, 30) 不报错,把多出来的天数加到下个月。字符串那条路不一样,不合法就判死:规范说 Strings that are unrecognizable or contain out-of-bounds format element values shall cause this function to return NaN,所以 new Date("2026-13-40") 和 new Date("not-a-date") 都返回 Invalid Date,Number.isNaN(d.getTime()) 一判就知道。表单里手输日期的话,两个入口都要挡:先校验字符串格式,写完再回读一次,确认年月日没被改动。
落到代码上的改法
只选日期时,别构造 Date
<input type="date"> 的 value 本来就是 YYYY-MM-DD 格式,直接发给后端。中间过一次 Date 再格式化回来,除了引入时区歧义没有任何好处。
一定要 Date 对象,就用数值构造器
const pad = (n) => String(n).padStart(2, "0");
const ymd = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
console.log(ymd(new Date(2026, 8, 15))); // 2026-09-15
月份是 0 起算的,8 才是九月。格式化也别顺手写 toISOString().slice(0, 10),那是 UTC 视角,东八区下午提交的记录会被归到第二天。
后端字段的语义写清楚
日期就存 2026-09-15 这样的字符串,或者明确约定存 UTC 零点。时间点就带上时区偏移。最容易出事的是按本地零点算出时间戳,再当 UTC 用,两端哪一端取出来都会错一天。
没测到的部分
2026-9-5 这种不补零的写法,V8 能解析成 9 月 5 日的本地零点,实测有效。但它不属于标准里的 ISO 格式,各家兜底规则不一样,这套只在 V8 上试过,Safari 没环境,没测。跨时区那部分用 TZ 环境变量复现的,没有真去改系统时区。
结论:输入只是一天的话,别让 Date 参与;需要一个具体时刻的话,把时区写进字符串里。