今天本来只在查一个"筛选日期少了一天"的问题,结果挖出三件值得单独记一笔的事:日期字符串的解析规则、一条能问出"谁把这包拖进我项目"的 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 行。想收窄只能自己接管道,我用的这条:
bashnpm 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 上不管用这种事。