案例 Intl.DateTimeFormat 的 dateStyle 与单组件互斥:容器里差 8 小时与 hourCycle 的 0/1 起点

2026-10-25 00:06:07

参考链接:MDN Intl.DateTimeFormat 构造器 · MDN resolvedOptions · TC39 proposal-intl-datetime-style · ECMA-402 DateTimeFormat Objects · WebKit Bug 216521

Intl.DateTimeFormat 排查笔记:dateStyle 互斥、容器里差 8 小时与 hourCycle 的 0/1 起点

两个先撞上的现象

// 现象一:想让日期短、还要单独控制年份,直接抛 TypeError
new Intl.DateTimeFormat('en-US', { dateStyle: 'short', year: 'numeric' });
// Uncaught TypeError

// 现象二:同一段格式化代码,本机输出 "1:31 PM",容器里差 8 小时
new Intl.DateTimeFormat('en-US', { timeStyle: 'short' }).format(Date.now());

第一个是规范层面的互斥,第二个是 timeZone 默认值取了运行时时区。两件事都不属于引擎问题,但都不会给出提示。

dateStyle / timeStyle 与单组件选项互斥

  • dateStyle 取值 full | long | medium | short,按 locale 展开成 weekday / day / month / year / era 的组合。
  • timeStyle 同样四个取值,展开成 hour / minute / second / timeZoneName。
  • 两者可以同时出现,但都不能与任何一个单组件选项(weekday、hour、month……)同时出现。

规范位置在 InitializeDateTimeFormat 第 45 步附近:若 dateStyle 或 timeStyle 非 undefined,且 hasExplicitFormatComponents 为 true,抛 TypeError。

resolvedOptions() 故意不返回 dateStyle / timeStyle 展开后的组件:

const shortDate = new Intl.DateTimeFormat('en-US', { dateStyle: 'short' });
shortDate.resolvedOptions().year; // undefined

这样设计是为了让 resolvedOptions() 的结果能直接回传给构造函数而不产生冲突——如果展开后的组件被暴露出来,回传时就会同时命中 dateStyle 和显式组件,立刻违反上面的互斥规则。

如果所有组件选项和 dateStyle / timeStyle 都不给,会按被格式化的对象取默认值:Date 下 year / month / day 为 "numeric"。

timeZone:默认取运行时时区

timeZone 的默认值是运行时的时区,和 Date.prototype.toString() 用的是同一个。Node / Docker 里通常是 UTC,于是服务器和浏览器输出对不上,8 小时差就是这么来的。

可以显式传 IANA 名称:"UTC"、"America/New_York"、"Etc/GMT+8";也支持偏移标识:"+01:00"、"-2359"、"+23"。注意 Etc/GMT+8 按 POSIX 约定表示 UTC−8,正负号与直觉相反;偏移时区写法需要较新的引擎支持。

new Intl.DateTimeFormat('en-US', { timeStyle: 'short' }).format(Date.now());          // "1:31 PM"
new Intl.DateTimeFormat('en-US', { dateStyle: 'short', timeStyle: 'medium' }).format(Date.now()); // "7/7/20, 1:31:55 PM"

hourCycle 与 hour12

0 起点还是 1 起点

hourCycle取值范围
h110–11
h121–12
h230–23
h241–24

h11 与 h23 从 0 数起,h12 与 h24 从 1 数起,所以 h24 在午夜会出现 "24:00"。h24 从不是任何 locale 的默认值。

hour12 的覆盖顺序

  • hour12: true:若 locale 默认 hourCycle 是 h11 或 h23,则取 h11,否则取 h12。
  • hour12: false:取 h23。
  • 优先级上,hour12 同时覆盖 hourCycle 选项和 Unicode 扩展键 hc(即 "en-US-u-hc-h23" 里的 hc)。options 里的属性也覆盖 locale 扩展键。
  • 规范做法是:若 hour12 非 undefined,先把 hourCycle 置为 null,再走 ResolveLocale。

只在 hour 或 timeStyle 存在时才有意义

hourCycle 只有在 hour 或 timeStyle 存在时才起作用([[HourCycle]] 仅在 [[Hour]] 不为 undefined 时使用)。resolvedOptions() 里的 hourCycle / hour12 同样只在 hour 或 timeStyle 出现时才存在。

formatMatcher 默认 "best fit"(算法由实现自定义),"basic" 由规范定义;它只在 dateStyle 与 timeStyle 都为 undefined 时使用。

dayPeriod 只在 12 小时制生效

dayPeriod 只有使用 12 小时制(h11 / h12)时才有效果。配 h23 / h24 时不会附加任何上午/下午信息。另外很多 locale 的 narrow / short / long 三种宽度输出相同,不要指望靠宽度区分。

formatRange / formatRangeToParts

区间格式使用 locale 数据里的 rangePatterns(DateTimeFormat record 的 [[rangePatterns]]),两端的公共部分会被合并。formatRangeToParts() 给出逐个 part 的来源标记(startRange / endRange / shared)。跨天、跨月具体怎么合并由 locale 数据决定。

formatRange 要求两个参数,start > end 会抛 RangeError。边界行为建议在目标引擎上自行验证,不要假设所有实现一致。

排查顺序

  1. 检查 options 里是否同时写了 dateStyle / timeStyle 和任一单组件选项——有就是 TypeError。
  2. 打印 resolvedOptions().timeZone,确认是否等于预期;容器里一律显式传 timeZone。
  3. 打印 resolvedOptions().hourCycle / hour12,确认没有被 hour12 或 -u-hc- 扩展键改掉。
  4. 确认用了 dayPeriod 的格式化是否真的是 12 小时制。
  5. 检查 formatRange 的参数顺序,以及是否漏传第二个参数。
  6. 组合选项行为异常时对比引擎版本,hourCycle + timeStyle 这类组合历史上存在实现差异(WebKit Bug 216521 修的就是 hourCycle 与 timeStyle 一起用时时制未正确应用)。

可跑的断言

// node intl-datetime-assertions.js
function check(name, fn) {
  try { fn(); console.log('ok   ', name); }
  catch (e) { console.log('FAIL ', name, '->', e.message); }
}

// 1. dateStyle / timeStyle 与单组件互斥
check('dateStyle + year 抛 TypeError', () => {
  let err;
  try { new Intl.DateTimeFormat('en-US', { dateStyle: 'short', year: 'numeric' }); }
  catch (e) { err = e; }
  if (!(err instanceof TypeError)) throw new Error('没有抛 TypeError');
});

check('timeStyle + hour 抛 TypeError', () => {
  let err;
  try { new Intl.DateTimeFormat('en-US', { timeStyle: 'short', hour: 'numeric' }); }
  catch (e) { err = e; }
  if (!(err instanceof TypeError)) throw new Error('没有抛 TypeError');
});

// 2. resolvedOptions 不展开 dateStyle/timeStyle
check('dateStyle 不展开到 resolvedOptions', () => {
  const o = new Intl.DateTimeFormat('en-US', { dateStyle: 'short' }).resolvedOptions();
  if (o.year !== undefined) throw new Error('year = ' + o.year);
});

check('resolvedOptions 结果可原样回传构造器', () => {
  const f = new Intl.DateTimeFormat('en-US', { dateStyle: 'full', timeStyle: 'long' });
  new Intl.DateTimeFormat('en-US', f.resolvedOptions());
});

// 3. timeZone 默认值与显式指定
const t = Date.UTC(2020, 6, 7, 13, 31, 55);

check('显式 timeZone 覆盖运行时默认', () => {
  const s = new Intl.DateTimeFormat('en-US', { timeStyle: 'short', timeZone: 'UTC' }).format(t);
  if (s !== '1:31 PM') throw new Error(s);
});

check('resolvedOptions().timeZone 反映默认时区', () => {
  const tz = new Intl.DateTimeFormat('en-US', { timeStyle: 'short' }).resolvedOptions().timeZone;
  if (typeof tz !== 'string' || !tz) throw new Error(String(tz));
  console.log('     运行时默认时区:', tz);
});

check('timeZone 可用偏移写法', () => {
  const s = new Intl.DateTimeFormat('en-US', { timeStyle: 'short', timeZone: '+01:00' }).format(t);
  if (s !== '2:31 PM') throw new Error(s);
});

// 4. hourCycle / hour12
const hm = (opts) => new Intl.DateTimeFormat('en-US', {
  timeZone: 'UTC', hour: '2-digit', minute: '2-digit', ...opts
}).format(Date.UTC(2020, 0, 1, 0, 0)); // 00:00 UTC

check('h23 在 0 点输出 00:00', () => {
  if (hm({ hourCycle: 'h23' }) !== '00:00') throw new Error(hm({ hourCycle: 'h23' }));
});

check('h24 在 0 点输出 24:00', () => {
  const s = hm({ hourCycle: 'h24' });
  if (s !== '24:00') throw new Error(s + '(引擎/ICU 版本不同,输出可能不同)');
});

check('hourCycle 只在有 hour 或 timeStyle 时出现', () => {
  const o = new Intl.DateTimeFormat('en-US', { year: 'numeric' }).resolvedOptions();
  if (o.hourCycle !== undefined || o.hour12 !== undefined) throw new Error(JSON.stringify(o));
  const o2 = new Intl.DateTimeFormat('en-US', { timeStyle: 'short' }).resolvedOptions();
  if (!o2.hourCycle) throw new Error('timeStyle 下缺少 hourCycle');
});

check('hour12 覆盖 -u-hc- 扩展键', () => {
  const hc = new Intl.DateTimeFormat('en-US-u-hc-h23', { hour: 'numeric', hour12: true })
    .resolvedOptions().hourCycle;
  if (hc !== 'h12') throw new Error(String(hc));
});

check('hour12:true 在默认 h23 的 locale 落到 h11', () => {
  const hc = new Intl.DateTimeFormat('de-DE', { hour: 'numeric', hour12: true })
    .resolvedOptions().hourCycle;
  if (hc !== 'h11') throw new Error(String(hc));
});

// 5. formatRange / formatRangeToParts
check('formatRange start > end 抛 RangeError', () => {
  const f = new Intl.DateTimeFormat('en-US', { dateStyle: 'short', timeZone: 'UTC' });
  let err;
  try { f.formatRange(new Date(Date.UTC(2020, 0, 2)), new Date(Date.UTC(2020, 0, 1))); }
  catch (e) { err = e; }
  if (!(err instanceof RangeError)) throw new Error('没有抛 RangeError');
});

check('formatRangeToParts 逐 part 标记来源', () => {
  const f = new Intl.DateTimeFormat('en-US', { dateStyle: 'short', timeZone: 'UTC' });
  const parts = f.formatRangeToParts(new Date(Date.UTC(2020, 6, 7)), new Date(Date.UTC(2020, 8, 9)));
  const src = new Set(parts.map((p) => p.source));
  if (src.size === 0) throw new Error('无 parts');
  console.log('     source:', [...src].join(','));
});

推荐文章

程序员茄子在线接单