参考链接: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 | 取值范围 |
|---|---|
h11 | 0–11 |
h12 | 1–12 |
h23 | 0–23 |
h24 | 1–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。边界行为建议在目标引擎上自行验证,不要假设所有实现一致。
排查顺序
- 检查 options 里是否同时写了
dateStyle/timeStyle和任一单组件选项——有就是TypeError。 - 打印
resolvedOptions().timeZone,确认是否等于预期;容器里一律显式传timeZone。 - 打印
resolvedOptions().hourCycle/hour12,确认没有被hour12或-u-hc-扩展键改掉。 - 确认用了
dayPeriod的格式化是否真的是 12 小时制。 - 检查
formatRange的参数顺序,以及是否漏传第二个参数。 - 组合选项行为异常时对比引擎版本,
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(','));
});