如何格式化日期范围,如 1 月 1 日 - 1 月 5 日

使用 JavaScript 以符合本地习惯的格式显示日期范围,并智能去除冗余信息

引言

日期范围在 Web 应用中随处可见。预订系统会显示 1 月 1 日至 1 月 5 日的可用时间,活动日历展示多天的会议,分析仪表盘显示特定时间段的数据,报告则涵盖财务季度或自定义日期区间。这些范围表示某项内容适用于两个端点之间的所有日期。

如果手动将两个格式化后的日期用短横线拼接来表示日期范围,输出会变得冗长。例如,从 2024 年 1 月 1 日到 2024 年 1 月 5 日,不需要在第二个日期中重复显示月份和年份。输出“1 月 1 日 - 5 日,2024 年”通过省略重复部分,更简洁地传达了相同的信息。

JavaScript 在 formatRange() 上提供了 Intl.DateTimeFormat 方法,可自动处理日期范围的格式化。该方法会根据本地习惯自动决定每个部分应包含哪些日期组件,选择合适的分隔符和格式,并去除不必要的重复。

为什么日期范围需要智能格式化

不同的日期范围需要不同的详细程度。如果范围在同一个月内,所需信息就比跨年份的范围少。最佳格式取决于起止日期之间哪些日期组件不同。

如果范围在同一天内,只需显示时间差即可,例如“10:00 AM - 2:00 PM”。为两个时间都重复完整日期并没有增加有效信息。

如果日期范围在同一个月内,只需显示一次月份,并列出两个日期数字,例如:“1月1-5日,2024 年”。重复写“1月”会让内容更难阅读,并不会增加清晰度。

如果日期范围跨越同一年内的不同月份,则需显示两个月份,但可以省略第一个日期的年份。例如:“2024年12月25日 - 2025年1月2日”需要完整信息,但“1月15日 - 2月20日,2024年”在某些语言环境下可以省略第一个日期的年份。

如果日期范围跨越多个年份,必须为两个日期都标明年份,例如:“2023年12月1日 - 2024年3月15日”。

手动实现这些规则时,需要检查哪些日期组件不同,并据此构建格式化字符串。不同的语言环境对这些规则的应用也不同,分隔符和顺序习惯也有差异。formatRange() 方法封装了这些逻辑。

使用 formatRange 格式化日期区间

formatRange() 方法接受两个 Date 对象并返回格式化后的字符串。创建一个带有所需语言环境和选项的 Intl.DateTimeFormat 实例,然后用起始和结束日期调用 formatRange()

const formatter = new Intl.DateTimeFormat("en-US");

const start = new Date(2024, 0, 1);
const end = new Date(2024, 0, 5);

console.log(formatter.formatRange(start, end));
// Output: "1/1/24 – 1/5/24"

格式化器会对两个日期都应用美国英语的默认日期格式,并用短横线连接。即使在同一个月内,输出仍然显示两个完整日期,因为默认格式本身很简洁。

你可以通过常规日期格式化的选项,指定要包含哪些日期组件。

const formatter = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

const start = new Date(2024, 0, 1);
const end = new Date(2024, 0, 5);

console.log(formatter.formatRange(start, end));
// Output: "January 1 – 5, 2024"

现在,格式化器会智能地省略第二个日期中与第一个日期相同的月份和年份。输出只显示结束日期的日号,使区间更易读。

formatRange 如何优化日期范围的输出

formatRange() 方法会比较两个日期,判断哪些部分不同。它只在范围的每一部分中包含必要的组件。

如果日期在同一个月和同一年,第二部分只显示结束日期的日。

const formatter = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

console.log(formatter.formatRange(
  new Date(2024, 0, 1),
  new Date(2024, 0, 5)
));
// Output: "January 1 – 5, 2024"

console.log(formatter.formatRange(
  new Date(2024, 0, 15),
  new Date(2024, 0, 20)
));
// Output: "January 15 – 20, 2024"

这两部分只显示一次月份和年份,日期数字之间用范围分隔符连接。

如果日期在同一年但不同月,则会显示两个不同的月份,但年份只出现一次。

const formatter = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

console.log(formatter.formatRange(
  new Date(2024, 0, 15),
  new Date(2024, 1, 20)
));
// Output: "January 15 – February 20, 2024"

格式化器会包含两个月份名称,但将年份放在末尾,表示整个范围。

如果日期跨越不同年份,则会显示两个完整的日期。

const formatter = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

console.log(formatter.formatRange(
  new Date(2023, 11, 25),
  new Date(2024, 0, 5)
));
// Output: "December 25, 2023 – January 5, 2024"

每个日期都包含完整的年份,因为年份不同。格式化器不能省略任何年份,否则会造成歧义。

日期和时间范围的格式化

当格式中包含时间组件时,formatRange() 方法同样会对时间字段进行智能省略。

如果时间在同一天,输出中只有时间部分不同。

const formatter = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric",
  hour: "numeric",
  minute: "numeric"
});

const start = new Date(2024, 0, 1, 10, 0);
const end = new Date(2024, 0, 1, 14, 30);

console.log(formatter.formatRange(start, end));
// Output: "January 1, 2024, 10:00 AM – 2:30 PM"

日期只显示一次,后面跟着时间范围。格式化器会识别出如果结尾重复完整日期和时间并不会增加有用信息。

如果时间在不同天,则会显示两个完整的日期和时间值。

const formatter = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric",
  hour: "numeric",
  minute: "numeric"
});

const start = new Date(2024, 0, 1, 10, 0);
const end = new Date(2024, 0, 2, 14, 30);

console.log(formatter.formatRange(start, end));
// Output: "January 1, 2024, 10:00 AM – January 2, 2024, 2:30 PM"

因为是不同的日期,所以会显示完整的日期和时间。格式化器不能省略任何部分。

不同语言环境下的日期范围格式化

范围格式会根据各个语言环境的习惯,自动调整日期组件的顺序、分隔符以及省略哪些部分。

const enFormatter = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

console.log(enFormatter.formatRange(
  new Date(2024, 0, 1),
  new Date(2024, 0, 5)
));
// Output: "January 1 – 5, 2024"

const deFormatter = new Intl.DateTimeFormat("de-DE", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

console.log(deFormatter.formatRange(
  new Date(2024, 0, 1),
  new Date(2024, 0, 5)
));
// Output: "1.–5. Januar 2024"

const jaFormatter = new Intl.DateTimeFormat("ja-JP", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

console.log(jaFormatter.formatRange(
  new Date(2024, 0, 1),
  new Date(2024, 0, 5)
));
// Output: "2024年1月1日~5日"

英文日期格式通常将月份放在前面,年份显示在最后。德语日期格式则是先写日期数字并用句点分隔,然后是月份名称,最后是年份。日语采用年-月-日的顺序,并使用波浪线(~)作为区间分隔符。每种语言环境对于哪些组件显示一次、哪些显示两次都有各自的规范。

这些差异也体现在跨月份的日期区间上。

const enFormatter = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

console.log(enFormatter.formatRange(
  new Date(2024, 0, 15),
  new Date(2024, 1, 20)
));
// Output: "January 15 – February 20, 2024"

const deFormatter = new Intl.DateTimeFormat("de-DE", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

console.log(deFormatter.formatRange(
  new Date(2024, 0, 15),
  new Date(2024, 1, 20)
));
// Output: "15. Januar – 20. Februar 2024"

const frFormatter = new Intl.DateTimeFormat("fr-FR", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

console.log(frFormatter.formatRange(
  new Date(2024, 0, 15),
  new Date(2024, 1, 20)
));
// Output: "15 janvier – 20 février 2024"

这三种语言环境都会显示两个月份名称,但它们与日期数字的相对位置不同。格式化工具会自动处理这些差异。

使用不同样式格式化日期区间

你可以使用 dateStyle 选项来控制整体格式长度,和单个日期格式化时一样。

const shortFormatter = new Intl.DateTimeFormat("en-US", {
  dateStyle: "short"
});

console.log(shortFormatter.formatRange(
  new Date(2024, 0, 1),
  new Date(2024, 0, 5)
));
// Output: "1/1/24 – 1/5/24"

const mediumFormatter = new Intl.DateTimeFormat("en-US", {
  dateStyle: "medium"
});

console.log(mediumFormatter.formatRange(
  new Date(2024, 0, 1),
  new Date(2024, 0, 5)
));
// Output: "Jan 1 – 5, 2024"

const longFormatter = new Intl.DateTimeFormat("en-US", {
  dateStyle: "long"
});

console.log(longFormatter.formatRange(
  new Date(2024, 0, 1),
  new Date(2024, 0, 5)
));
// Output: "January 1 – 5, 2024"

const fullFormatter = new Intl.DateTimeFormat("en-US", {
  dateStyle: "full"
});

console.log(fullFormatter.formatRange(
  new Date(2024, 0, 1),
  new Date(2024, 0, 5)
));
// Output: "Monday, January 1 – Friday, January 5, 2024"

short 样式会生成数字日期,并且不会进行智能省略,因为这种格式本身已经很紧凑。mediumlong 样式会缩写或拼写月份,并省略重复的部分。full 样式会为两个日期都包含星期名称。

使用 formatRangeToParts 进行自定义样式处理

formatRangeToParts() 方法会返回一个对象数组,表示格式化区间的各个组成部分。这样你可以对区间输出的每个部分进行样式处理或操作。

const formatter = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

const parts = formatter.formatRangeToParts(
  new Date(2024, 0, 1),
  new Date(2024, 0, 5)
);

console.log(parts);

输出结果是一个对象数组,每个对象包含 typevaluesource 属性。

[
  { type: "month", value: "January", source: "startRange" },
  { type: "literal", value: " ", source: "startRange" },
  { type: "day", value: "1", source: "startRange" },
  { type: "literal", value: " – ", source: "shared" },
  { type: "day", value: "5", source: "endRange" },
  { type: "literal", value: ", ", source: "shared" },
  { type: "year", value: "2024", source: "shared" }
]

type 属性用于标识组件类型:月份、日期、年份或文本。value 属性包含格式化后的文本。source 属性表示该组件属于起始日期、结束日期还是两者共享。

你可以使用这些部分为不同组件创建带有样式的自定义 HTML。

const formatter = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

const parts = formatter.formatRangeToParts(
  new Date(2024, 0, 1),
  new Date(2024, 0, 5)
);

let html = "";

parts.forEach(part => {
  if (part.type === "month") {
    html += `<span class="month">${part.value}</span>`;
  } else if (part.type === "day") {
    html += `<span class="day">${part.value}</span>`;
  } else if (part.type === "year") {
    html += `<span class="year">${part.value}</span>`;
  } else if (part.type === "literal" && part.source === "shared" && part.value.includes("–")) {
    html += `<span class="separator">${part.value}</span>`;
  } else {
    html += part.value;
  }
});

console.log(html);
// Output: <span class="month">January</span> <span class="day">1</span><span class="separator"> – </span><span class="day">5</span>, <span class="year">2024</span>

这种技术可以在保留本地化格式的同时,实现自定义的视觉样式。

日期相等时会发生什么

当你为 start 和 end 参数传递相同的日期时,formatRange() 会输出一个格式化后的单一日期,而不是一个区间。

const formatter = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

const date = new Date(2024, 0, 1);

console.log(formatter.formatRange(date, date));
// Output: "January 1, 2024"

格式化器会识别出端点相同的区间实际上并不是区间,因此会将其格式化为单一日期。即使 Date 对象是不同的实例,只要值相同,也会应用这种行为。

const formatter = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

const start = new Date(2024, 0, 1, 10, 0);
const end = new Date(2024, 0, 1, 10, 0);

console.log(formatter.formatRange(start, end));
// Output: "January 1, 2024"

即使这些是不同的 Date 对象,它们也表示相同的日期和时间。由于区间在所选格式精度下的持续时间为零,格式化器会输出单一日期。如果格式中不包含时间部分,则时间无关紧要,日期被视为相等。

何时使用 formatRange 与手动格式化

在向用户展示日期区间时,建议使用 formatRange()。这适用于预订日期区间、事件持续时间、报告周期、可用时间窗口或其他任何时间段。该方法可确保正确的本地化格式和最佳的组件省略。

当需要展示多个无关日期时,应避免使用 formatRange()。例如,“1 月 1 日、1 月 15 日、2 月 1 日”这样的截止日期列表,应为每个日期分别调用 format(),而不是将它们视为区间。

在展示日期之间的比较或差异时,也应避免使用 formatRange()。如果你要显示某个日期比另一个日期早多少或晚多少,这属于相对时间计算,而不是区间。