同一个日期字符串,`new Date` 说是早上 8 点,dayjs 说是 0 点

今天本来只在查一个"筛选日期少了一天"的问题,结果挖出三件值得单独记一笔的事:日期字符串的解析规则、一条能问出"谁把这包拖进我项目"的 npm 命令,以及 JSON.parse 会悄悄改掉长整数这件事。所有数字都是我在本机 node v20.13.1 / npm 10.5.2 上跑出来的,不是抄的。

"2026-10-21" 是 UTC 零点,"2026-10-21T00:00:00" 才是本地零点

先看两组实测。左边是我把字符串塞进 new Date(),右边是它转成 ISO 串和"本地读出来是几点":

我写的字符串 toISOString() 本地(东八区)读出来
2026-10-21 2026-10-21T00:00:00.000Z 10-21 08:00
2026-10-21T00:00:00 2026-10-20T16:00:00.000Z 10-21 00:00
2026-10-21T00:00:00Z 2026-10-21T00:00:00.000Z 10-21 08:00
2026/10/21 2026-10-20T16:00:00.000Z 10-21 00:00
2026-10 2026-10-01T00:00:00.000Z 10-01 08:00

结论就一句话:只写日期,JavaScript 当它是 UTC 零点;写了时刻但没写时区,它当是本地的。 规范就是这么定的,V8 实测也确实是这个行为。

这事儿要命的地方在于,<input type="date"> 交给你的正好就是 2026-10-21 这种 date-only 串。用户在页面上选了"10 月 21 日起",你顺手 new Date(el.value) 一转,实际拿到的是北京时间早上 8 点,差值实测 28800000 ms------正好 8 小时。地图项目里按天筛轨迹、算"当天 0 点到现在的里程",就会从早上 8 点开始漏一段。

还有更隐蔽的一种,用 UTC 串取"今天":

js 复制代码
const mock = new Date('2026-10-21T07:00:00+08:00');  // 北京时间早上 7 点
mock.toISOString().slice(0, 10);                     // '2026-10-20'  ← 昨天

早上 0 点到 8 点之间,toISOString().slice(0, 10) 给的是昨天。这个坑平时测不到,因为你多半在上午 9 点以后写代码。

顺带记一条环境事实:我本来想用环境变量在本地复现"纽约用户看到的日期不一样",TZ=America/New_York node -e ... 在这台 Windows 上完全没用 ,Intl.DateTimeFormat().resolvedOptions().timeZone 仍然报 Asia/Shanghai。想验证跨时区行为,直接用 timeZone 选项:

js 复制代码
const d = new Date('2026-10-21T16:30:00Z');
d.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai',    hour12: false });  // 2026/10/22 00:30:00
d.toLocaleString('zh-CN', { timeZone: 'America/New_York', hour12: false });  // 2026/10/21 12:30:00
d.toLocaleString('zh-CN', { timeZone: 'Europe/London',    hour12: false });  // 2026/10/21 17:30:00

同一个时刻,三种"日期"。而 new Date('2026-10-21') 这个对象在纽约渲染出来是 10/20/2026------"显示成前一天"就是这么来的。

至于表里那个 2026/10/21(斜杠),它在 V8 里能解析、而且按本地零点算,但这是引擎的宽容,不是规范内容,换个引擎结果不保证。我只测了 V8,所以这条只当"别这么写"的提醒用。

dayjs 和原生 Date 对同一个串的答案不一样

dayjs 我是一直在用,但今天才第一次正面对比。1.11.23、MIT、dependencies 字段干脆不存在(0 依赖),最后发布时间 2026-08-17。下载量取三个有数的日子:10-16 是 15,364,956,10-17 是 7,189,629,10-18 是 7,253,846(10-19、10-20 这两天 npm 还没出数,就不算了)。

体积实测:dayjs.min.js 7,161 B,gzip 3,044 B。插件按 gzip 算:utc 969 B、timezone 1,122 B、customParseFormat 1,854 B、duration 1,703 B,locale/zh-cn.js 796 B。

关键的差异:

字符串 dayjs(s).format('YYYY-MM-DD HH:mm') new Date(s) 的本地时刻 差
2026-10-21 2026-10-21 00:00 10-21 08:00 8 小时
2026-10-21T00:00:00 2026-10-21 00:00 10-21 00:00 0
2026-10-21T00:00:00Z 2026-10-21 08:00 10-21 08:00 0

dayjs 把 date-only 当本地 零点,原生 Date 当 UTC 零点。所以老项目里 dayjs(x) 和 new Date(x) 混用,同一份数据两套理解,比单纯用哪个都错更难受。我的做法是:一个项目里日期只允许一种解析入口,接口层要么收完整带 Z/offset 的串,要么就收字符串自己比,别在中间转 Date。

extend(utc) 和 extend(timezone) 之后 .tz() 是真好使:dayjs('2026-10-21T16:30:00Z').tz('America/New_York').format('YYYY-MM-DD HH:mm') 给 2026-10-21 12:30,跟我上面用 Intl 算出来的一致。另外一个小细节:dayjs 的 utcOffset() 返回 480 ,原生 getTimezoneOffset() 返回 -480,符号是反的,别互相赋值。

isValid() 不等于"日期合法"

这条是今天的意外收获:

js 复制代码
dayjs('2026-13-45').isValid()               // true  ← 13 月 45 日
dayjs('2026-13-45').format('YYYY-MM-DD')    // '2027-02-14'
dayjs('2026-02-30').format('YYYY-MM-DD')    // '2026-03-02'
dayjs('2026-10-32').format('YYYY-MM-DD')    // '2026-11-01'

dayjs 默认不报错,它进位 ------13 月变成下一年 1 月,45 号继续往后加。而 new Date('2026-13-45') 是 Invalid Date(2026-02-30 那种 V8 会宽容进位成 3 月 2 日)。想让 dayjs 严格起来,得装 customParseFormat 并传第三个参数:

js 复制代码
dayjs('2026-13-45', 'YYYY-MM-DD', true).isValid()   // false
dayjs('2026-02-30', 'YYYY-MM-DD', true).isValid()   // false
dayjs('2026-10-21', 'YYYY-MM-DD', true).isValid()   // true
dayjs('2026/10/21', 'YYYY-MM-DD', true).isValid()   // false  ← 分隔符必须是 -

表单校验、URL 参数解析这种地方,isValid() 单独用是不够的,得配 strict。

npm query:问"谁把这包拖进来的",以及"谁会在我机器上跑脚本"

npm why 其实早就有,但一直没被我用起来。今天在一个 6 个直接依赖的小项目里试了下(装了 vue@2.7.16、pinia@2.1.7、axios、core-js@3、dayjs、lossless-json),输出长这样:

复制代码
$ npm why vue-demi
vue-demi@0.14.10
node_modules/pinia/node_modules/vue-demi
  vue-demi@">=0.14.5" from pinia@2.1.7
  node_modules/pinia
    pinia@"^2.1.7" from the root project

它直接给了"从根项目到这个包"的完整链路,还告诉你包被嵌套装在 pinia/node_modules 里。比 npm ls 那一大堆缩进好读太多了。同理 npm why follow-redirects 会指出它是 axios@1.20.0 拖进来的。

真正让我觉得"这条要记下来"的是 npm query,它把依赖树当 CSS 选择器查:

复制代码
$ npm query ":attr(scripts, [postinstall])"

在这棵树里命中 2 个:core-js@3.51.0 和 vue-demi@0.14.10。也就是说,npm install 的时候会在你机器上执行代码的,就这俩。两者的脚本内容我打开看了,一模一样的一句话:

json 复制代码
"postinstall": "node -e \"try{require('./postinstall')}catch(e){}\""

顺手把 vue-demi 那个 postinstall 也看了,19 行:读 vue 的版本,2.7. 开头就 switchVersion(2.7),2. 就 switchVersion(2),3. 就 switchVersion(3)。switchVersion 内部是把 index.cjs、index.mjs、index.d.ts 从 lib/v2.7/ 复制到 lib/。所以我现在 node_modules/pinia/node_modules/vue-demi/lib/index.cjs 里看到的内容是 exports.isVue2 = true; exports.isVue3 = false; 加一个手搓的 createApp 垫片。

这解释了一件我一直没细想的事:pinia 能在 Vue 2.7 上跑,靠的是安装那一刻 postinstall 复制了 Vue 2 的适配器 ,不是运行时判断。所以如果你在 CI 里加了 --ignore-scripts,或者用别的手段拦住了安装脚本,它就永远停在出厂那份入口上。我把 vue-demi-0.14.10.tgz 拉下来解开看了一眼,未跑脚本时 lib/index.cjs 的内容是 isVue3 = true 那份 Vue 3 适配器------也就是说 Vue 2.7 项目里脚本没跑成,拿到的是"以为自己在 Vue 3"的代码。症状通常是"引入就报错,但报错信息和依赖毫无关系"。它甚至自带两个可执行文件,vue-demi-switch 和 vue-demi-fix,装完出问题可以 npx vue-demi-fix 手动补一刀。vue-demi 0.14.10 的最后发布时间是 2024-07-25,两年多没动过了,这种"低频但关键"的包最容易被忽略。

一些实测细节,供你决定要不要用:

  • 默认输出是完整 package.json,2 个命中打印了 168 行。想收窄只能自己接管道,我用的这条:

    bash 复制代码
    npm query ":attr(scripts, [postinstall])" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const a=JSON.parse(s);console.log(a.map(x=>x.name+"@"+x.version).join("\n"))})'
  • 支持哪些伪选择器跟 npm 版本强相关。我这台 npm 10.5.2 实测可用::root、:private、:overridden、:path、:deduped、:semver(>=2)、[name=axios]、:attr(dependencies, [follow-redirects])、:attr(peerDependencies, [vue])(命中 pinia 和 vue-demi,正是它俩要求装 vue)。而 :dev、:peer、:optional、:bundled、:all、:external、:public、:workspace、:links 一律 npm ERR! code EQUERYNOPSEUDO。

  • 坑:[dev]、[peer]、[optional]、[deprecated] 这种方括号写法不报错但恒为 0 个,很容易被误认为"没有 dev 依赖"。

  • 一次查询 1.7 s(real 0m1.698s),当排查命令完全能忍。

  • 这棵树的规模顺手记一下:6 个直接依赖,package-lock.json 里 47 条 packages 记录,node_modules 顶层 41 个目录,占 38.9 MB。

AI 一角:AI(和后端)给你的 19 位 id,JSON.parse 会把它改掉

最后一件。现在让模型返回结构化数据(function call、JSON 模式)已经是常规操作了,而后端返回 int64 雪花 id 也是常规操作------这两件事在 JS 里撞同一个坑,而且它不报错。

我随手造了一个 19 位 id:

js 复制代码
JSON.parse('7430012345678901234')   // 7430012345678901000  ← 末尾 234 没了
9007199254740993 === 9007199254740992  // true

原因大家都背过:Number.MAX_SAFE_INTEGER 是 9007199254740991(2^53-1),双精度浮点在它之上就隔 2 跳一步。但"背过"和"见过自己项目的 id 被改掉"是两回事,因为整个过程静默,typeof 还是 number,日志里也看不出异常。

最容易踩的是这个:

js 复制代码
const raw = '{"id":9007199254740993}';
JSON.parse(raw, (k, v) => typeof v === 'number' ? String(v) : v);
// 得到 {"id":"9007199254740992"} ------ reviver 拿到手的时候已经丢精度了

reviver 救不回来,因为截断发生在解析阶段。我试了唯一一种不装包的兜底:先把长数字加引号再解析。

js 复制代码
raw.replace(/(:\s*)(\d{16,})(\s*[,}\]])/g, '$1"$2"$3');
// {"id":"9007199254740993"}  → JSON.parse 后 id 是字符串,值对

能用,但它对 1e999、0.1 这种没辙,也不管 key 是不是你真正关心的字段。真想稳,lossless-json 4.3.2(MIT,npm view 显示最后发布 2026-10-16,就在上周五)可以试。体积实测 umd 版 10,098 B、gzip 3,756 B。

它的行为和名字一样直白:

js 复制代码
const LJ = require('lossless-json');
LJ.stringify(LJ.parse('{"snowflake":9007199254740993}'));  // 原样输出,值没变

但也有代价,我这台机器实测三条:

js 复制代码
const v = LJ.parse('{"id":9007199254740993}');
typeof v.id        // 'object',不是 number
v.id + 0           // TypeError: Cannot mix BigInt and other types, use explicit conversions
Number(v.id)       // 9007199254740992  ← 一转回 number 又丢了
JSON.stringify(v)  // 正常(它自己处理了)
JSON.stringify({ a: 1n })  // TypeError: Do not know how to serialize a BigInt

也就是底层走 BigInt:原文保住了,但这个东西不能当数字用,一旦你把它塞进原生 JSON.stringify(比如 axios 默认那条序列化链)就直接抛错。它导出 18 个东西,我能用到的主要是 parse / stringify / parseNumberAndBigInt / isSafeNumber / getUnsafeNumberReason;getUnsafeNumberReason('9007199254740993') 返回 'truncate_integer','1e999' 返回 'overflow','0.1' 返回 undefined,UnsafeNumberReason 一共四种:underflow、overflow、truncate_integer、truncate_float。想知道哪些数字会被改写,这组函数比读规范快。

所以我现在的取向变了,而且成本更低:在 schema 和 prompt 里就把 id 声明成字符串 ------"id": "7430012345678901234",让模型和后端都按字符串给。高德的 poiId(形如 B0FFFAB6J2)本来就是字符串,反而是自家后端的订单号、埋点 id 爱用 int64。与其在前端挂一个 3.7 KB 的解析器还要小心别把它当数字传出去,不如让数据源头就不要产出 JS 装不下的数字。这一条对 AI 和对接后端都成立,因为它俩最后都过同一个 JSON.parse。

最后

三件事串起来其实是一个东西:"看起来对"的中间态最危险 。日期字符串在 V8 里解析得好好的,只是差了 8 小时;dayjs('2026-13-45').isValid() 返回 true,只是悄悄进位到 2027 年 2 月;长 id JSON.parse 不抛错,只是把尾数抹成 000。三个都不会在控制台留下任何红色,全靠你知不知道它们的存在。

所以值得养成一个小习惯:拿到一个可疑行为,先在本机把它跑出来,别靠"我记得应该是"。今天这三段没有一段是我原本就确定的,全是跑完才写下来的------包括 TZ 环境变量在这台 Windows 上不管用这种事。

相关推荐
January12078 天前
把 45 KB 的地图数据压成 1 KB,和一台不用括号的计算器
技术科技文章
January120711 天前
把 500 米的偏移转回来,和一份管住所有导航卫星的 1967 年条约
技术科技文章