容器里 TZ=UTC,SSR 日期少一天:Intl.DateTimeFormat 的默认值与边界
同一个 ISO 时间串,SSR 渲染出的日期和浏览器里差一天、差 8 小时,起因是容器默认 TZ=UTC,浏览器是 Asia/Shanghai。另一种症状是 0 点被格式化成 24:00,来自 hourCycle: 'h24'。
下面按「默认值 → 选项互斥 → 引擎差异」逐条列,每条给最小可复现代码和判据。
timeZone 默认取运行时区,不是 UTC
MDN 中文原文:「默认值是运行时的时区,与 Date.prototype.toString() 使用的时区相同」。这是规范行为。
docker run --rm -e TZ=UTC node:20-alpine node -e "const d=new Date('2025-11-01T00:00:00Z');console.log(new Intl.DateTimeFormat('zh-CN',{dateStyle:'medium'}).format(d))"
输出 2025年11月1日;本机 TZ=Asia/Shanghai 时,同一个串带上 timeStyle 会是 2025年11月1日 08:00。更典型的是 2025-10-31T17:00:00Z:上海显示 11 月 1 日,UTC 显示 10 月 31 日——差一天就出在这里。
判据:Intl.DateTimeFormat().resolvedOptions().timeZone 打印的是运行时区。SSR 侧必须显式传 timeZone,不能依赖默认值。
时区名还要写对:Asia/Beijing 不存在,会抛 RangeError: Invalid time zone specified: Asia/Beijing,北京在 IANA 里是 Asia/Shanghai。
dateStyle/timeStyle 与组件选项互斥
规范:指定了任何 date-time 组件选项(weekday、year 等)时,dateStyle/timeStyle 必须为 undefined。V8 报:
TypeError: Invalid option : option dateStyle and option weekday cannot be used at the same time
二选一:
new Intl.DateTimeFormat('zh-CN', { dateStyle: 'long', timeStyle: 'short' })
new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
hourCycle:h24 下 0 点输出 24:00
取值 h11 / h12 / h23 / h24。hour12: true 映射到 h11 或 h12(视 locale),false 映射到 h23;h24 下午夜是 24:00。
new Intl.DateTimeFormat('en-US',{hour:'2-digit',minute:'2-digit',hourCycle:'h24',timeZone:'UTC'})
.format(new Date('2025-01-01T00:00:00Z')) // "24:00"
把 h24 换成 h23,得 "00:00"。排班、账单这类场景直接写 hourCycle:'h23'。
hour12:false → h23 是规范映射;早期 V8 对 hour12:false 给的是 h24,属引擎差异,需在目标 Node 版本上自行验证。另外 options 同时给 hour12 和 hourCycle 时,MDN 明确 hour12 会覆盖 hc 扩展键与 hourCycle。
small-icu 构建会静默退化成 en-US
Node 官方二进制带 full-icu,但 --with-intl=small-icu 或部分无服务端 runtime 只剩 en-US 数据。
判据两条:Intl.DateTimeFormat.supportedLocalesOf(['zh-CN']) 返回 [];process.config.variables.icu_small === true。
症状是不报错,zh-CN 静默失效,输出 11/1/2025 而不是 2025/11/1,toLocaleDateString('zh-CN') 同理。
Etc/GMT+8 的符号是反的
Etc/GMT+8 实际是 UTC−8,POSIX 遗产。固定东八区要么写 Asia/Shanghai(带 DST 历史与未来规则),要么写 +08:00 偏移标识符(无 DST,仅规范新增)。有 DST 的地区(如 America/New_York 3 月 / 11 月)只加偏移会错一小时。
没有 pattern 语法
不支持 'yyyy-MM-dd HH:mm:ss'。自定义串用 formatToParts 拼,parts 里 type 是 year/month/day/literal 等。它还能拿到 relatedYear,用于 chinese、hebrew 这类非公历日历。
日历差异也是数据层面的坑:ja-JP-u-ca-japanese 输出 24/12/19(平成/令和年号),th-TH 默认佛历,2025 年显示 2568。做年份对比、排序时不要直接比格式化后的字符串。
每行 new 一个 formatter
每次 new Intl.DateTimeFormat 都要解析 locale、建 formatter,比复用实例贵一到两个数量级(倍数需自行 benchmark)。列表页、日志页常见每行 new Intl.DateTimeFormat().format(...)。
修法是模块级 Map,按 locale + JSON.stringify(options) 缓存实例。
date-only 字符串再解析
new Date('2025-11-01') 按 UTC 解析,这是 ECMAScript 规范行为;new Date('2025-11-01 00:00:00')(空格分隔)不是规范格式,解析结果依引擎而定,老 Safari 返回 Invalid Date。只用 ISO 8601,并在格式化时显式给 timeZone。
formatRange 与 Temporal
formatRange 在某些 locale 下会合并区间(同日只写一次日期),跨月、跨年时分隔符与重复字段的处理各引擎不完全一致,账单周期显示要做快照测试。
新版规范里 format() 可接受 Temporal.PlainDate / PlainDateTime 等对象,MDN 已列出各类型的默认组件;但 Temporal 在 Node / 浏览器的可用性要看版本与开关,落地前先用 in 运算符检测。
修复顺序
- 数据层固定存 ISO 8601 UTC 字符串,不存格式化结果。
- SSR 与前端都显式写
timeZone。 - options 收敛到一个 format 模块,并缓存 formatter 实例。
- 加一条快照测试:固定 UTC 时间 + 固定 locale。
- 部署环境跑一次
supportedLocalesOf自检。