国际化

Intl 对象是 ECMAScript 国际化 API 的命名空间,它提供了广泛的与区域设置和文化相关的数据和操作。

概述

Intl 对象非常注重用例。它为每个需要特定区域设置逻辑的用例提供一个单独的对象。目前,它提供以下功能:

大多数 Intl API 都采用相似的设计(Intl.Locale 是唯一例外)。首先,使用所需的区域设置和选项构造一个实例。这定义了所需操作(格式化、排序、分段等)的一组规则。然后,当你在实例上调用方法时,例如 format()compare()segment(),该对象会将指定的规则应用于传入的数据。

js
// 1. Construct a formatter object, specifying the locale and formatting options:
const price = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
});

// 2. Use the `format` method of the formatter object to format a number:
console.log(price.format(5.259)); // $5.26

构造函数的一般签名是

js
new Intl.SomeObject(locales, options)
locales 可选

包含 BCP 47 语言标签的字符串或 Intl.Locale 实例,或此类区域设置标识符的数组。当传入 undefined 或未指定支持的区域设置标识符时,将使用运行时的默认区域设置。有关 locales 参数的一般形式和解释,请参阅 Intl 主页上的参数描述

options 可选

一个对象,包含自定义操作特定方面的属性,这是理解如何使用每个 Intl 对象的关键。

区域设置信息

区域设置是 Intl 所有行为的基础。区域设置是一组约定,在 Intl API 中由 Intl.Locale 对象表示。所有接受语言标签的 Intl 构造函数也接受 Intl.Locale 对象。

每个区域设置主要由四部分定义:languagescriptregion,有时还有一些variants。当它们按此顺序通过 - 连接时,它们形成一个 BCP 47 语言标签

  • 语言是区域设置中最重要的部分,并且是强制性的。当给定单一语言(如 enfr)时,有算法可以推断其余信息(参见 Intl.Locale.prototype.maximize())。
  • 然而,你通常也想指定区域,因为说相同语言的区域之间的约定可能差异很大。例如,美国的日期格式是 MM/DD/YYYY,而在英国是 DD/MM/YYYY,因此指定 en-USen-GB 很重要。
  • 你还可以指定脚本。脚本是书写系统,或者用于转录语言的字符。实际上,脚本通常是不必要的,因为特定区域使用的语言只用一种脚本书写。但是,也有例外,例如塞尔维亚语,可以用拉丁字母和西里尔字母书写(sr-Latnsr-Cyrl),或者中文,可以用简体字和繁体字书写(zh-Hanszh-Hant)。
  • 变体很少使用。通常,它们表示不同的拼写;例如,德语有 19011996 拼写变体,分别写为 de-1901de-1996
js
// These two are equivalent when passed to other Intl APIs
const locale1 = new Intl.Locale("en-US");
const locale2 = new Intl.Locale("en-Latn-US");

console.log(locale1.language, locale1.script, locale1.region); // "en", undefined, "US"
console.log(locale2.language, locale2.script, locale2.region); // "en", "Latn", "US"

区域设置还包含该特定文化使用的一组约定。

用例属性描述扩展子标签
日期/时间格式化 calendar 用于将日期分组为年、月和周,并为其命名。例如,gregory 日期 "2022-01-01" 在 hebrew 日历中变为 "28 Tevet 5782"。 ca
hourCycle 决定时间是以 12 小时制还是 24 小时制显示,以及最小小时数是 0 还是 1。 hc
数字格式化,包括日期、时间、持续时间等。 numberingSystem 将数字转换为特定于区域设置的表示法。常规的 0123456789 系统称为 latn(拉丁)。通常,每个脚本都有一个数字系统,它只是逐位翻译,但有些脚本有多个数字系统,有些可能不常用该脚本书写数字(例如,中文有自己的 hanidec 数字系统,但大多数文本使用标准 latn 系统),而另一些可能需要特殊的转换算法(例如罗马数字 — roman)。 nu
排序 collation 定义通用排序算法。例如,如果你使用德语 phonebk 排序,则 "ä" 被视为 "ae",并将在 "ad" 和 "af" 之间排序。 co
caseFirst 决定大写字母还是小写字母优先排序,或者是否忽略大小写。 kf
numeric 决定数字是按数字排序还是按字符串排序。例如,如果为 true,则 "10" 将排在 "2" 之后。 kn

在构造 Intl.Locale 或将语言标签传递给其他 Intl 构造函数时,可以显式指定这些属性。有两种方法:将其附加到语言标签或将其指定为选项。

  • 要将其附加到语言标签,你首先附加字符串 -u(表示“Unicode 扩展”),然后是上面给出的扩展子标签,然后是值。
  • 要将它们指定为选项,只需将上面给出的属性名称及其值添加到 options 对象中。

Intl.DateTimeFormat 为例,以下两行都创建了一个用于格式化希伯来日历中的日期的格式化程序:

js
const df1 = new Intl.DateTimeFormat("en-US-u-ca-hebrew");
const df2 = new Intl.DateTimeFormat("en-US", { calendar: "hebrew" });

无法识别的属性将被忽略,因此你可以对 Intl.NumberFormat 使用与上述相同的语法,但它不会与仅传入 en-US 有任何不同,因为数字格式化不使用 calendar 属性。

获取这些区域设置约定的默认值很棘手。new Intl.Locale("en-US").calendar 返回 undefined,因为 Locale 对象只包含你传递给它的信息。理论上,默认日历取决于你使用日历的 API,因此要获取 Intl.DateTimeFormat 使用的 en-US 的默认日历,你可以使用其 resolvedOptions() 方法。其他属性也一样。

js
const locale = new Intl.Locale("en-US");
console.log(locale.calendar); // undefined; it's not provided
console.log(new Intl.DateTimeFormat(locale).resolvedOptions().calendar); // "gregory"

Intl.Locale 对象同时做两件事:它们表示已解析的 BCP 47 语言标签(如上所示),并提供有关该区域设置的信息。它的所有属性,例如 calendar,都只从输入中提取,而无需查询任何数据源以获取默认值。另一方面,它有一组方法用于查询有关区域设置的实际信息。例如,getCalendars()getHourCycles()getNumberingSystems()getCollations() 方法补充了 calendarhourCyclenumberingSystemcollation 属性,并且每个都返回该属性的首选值数组。

js
const locale = new Intl.Locale("ar-EG");
console.log(locale.getCalendars()); // ['gregory', 'coptic', 'islamic', 'islamic-civil', 'islamic-tbla']

Intl.Locale 实例还包含其他公开有用信息的方法,例如 getTextInfo()getTimeZones()getWeekInfo()

确定区域设置

国际化有一个共同的关注点:我如何知道要使用哪个区域设置?

最明显的答案是“用户偏好”。浏览器通过 navigator.languages 属性公开用户的语言偏好。这是一个语言标识符数组,可以直接传递给格式化程序构造函数——稍后会详细介绍。用户可以在其浏览器设置中配置此列表。你也可以传递一个空数组或 undefined,这两种情况都会导致使用浏览器的默认区域设置。

js
const numberFormatter = new Intl.NumberFormat(navigator.languages);
console.log(numberFormatter.format(1234567.89));

const numberFormatter2 = new Intl.NumberFormat([]);

然而,这可能并不总是能提供最理想的结果。由 Intl 格式化程序格式化的字符串仅占你网站上显示文本的一小部分;大多数本地化内容是由你(网站开发人员)提供的。例如,假设你的网站仅提供两种语言:英语和法语。如果日本用户访问你的网站并期望以英语使用你的网站,当他们看到英语文本与日语数字和日期交织在一起时,他们会感到困惑!

通常,你不想使用浏览器的默认语言。相反,你想使用你的网站提供的其他内容相同的语言。假设你的网站有一个语言切换器,将用户的选择存储在某个地方——你可以直接使用它。

js
// Suppose this can be changed by some site-wide control
const userSettings = {
  locale: "en-US",
  colorMode: "dark",
};
const numberFormatter = new Intl.NumberFormat(userSettings.locale);
console.log(numberFormatter.format(1234567.89));

如果你的网站有一个后端根据用户的 Accept-Language 头动态选择语言并根据此发送不同的 HTML,你也可以使用 HTML 元素的 HTMLElement.lang 属性:new Intl.NumberFormat(document.documentElement.lang)

如果你的网站只提供一种语言,你也可以在代码中硬编码区域设置:new Intl.NumberFormat("en-US")

如前所述,你还可以向构造函数传递一个区域设置数组,表示一个回退选项列表。第一个使用 navigator.languages 的示例就是其中之一:如果第一个用户配置的区域设置不支持特定操作,则会尝试下一个,依此类推,直到找到运行时有数据支持的请求区域设置。你也可以手动执行此操作。在下面的示例中,我们以特异性递减的顺序指定了一系列区域设置,这些区域设置都代表香港华人可能理解的语言,因此格式化程序会选择它支持的最具特异性的区域设置。

js
const numberFormatter = new Intl.NumberFormat([
  "yue-Hant",
  "zh-Hant-HK",
  "zh-Hant",
  "zh",
]);

没有 API 可以列出所有支持的区域设置,但有几种方法可以处理区域设置列表:

  • Intl.getCanonicalLocales():此函数接受区域设置标识符列表并返回规范化区域设置标识符列表。这对于理解每个 Intl 构造函数的规范化过程很有用。
  • 每个 Intl 对象上的 supportedLocalesOf() 静态方法(例如 Intl.DateTimeFormat.supportedLocalesOf()):此方法接受与构造函数相同的参数(localesoptions),并返回与给定数据匹配的给定区域设置标签的子集。这对于理解运行时支持特定操作的区域设置很有用,例如,显示仅包含支持语言的语言切换器。

理解返回值

所有对象的第二个共同关注点是“方法返回什么?”这是一个难以回答的问题,超出了返回值的结构或类型,因为没有规范说明到底应该返回什么。大多数情况下,方法的结果是一致的。然而,输出可能因实现而异,即使在同一区域设置中也是如此——输出差异是设计使然,并由规范允许。它也可能不是你所期望的。例如,format() 返回的字符串可能使用不间断空格或被双向控制字符包围。你不应该将任何 Intl 方法的结果与硬编码常量进行比较;它们只应显示给用户。

当然,这个答案似乎不能令人满意,因为大多数开发人员确实希望控制输出的外观——至少,你不希望你的用户被无意义的输出弄糊涂。如果你确实想进行测试,无论是自动化测试还是手动测试,这里有一些指导方针:

  • 测试你的用户可能使用的所有区域设置。如果你有一组固定的支持区域设置(例如通过语言切换器),这会更容易。如果你使用用户偏好的任何区域设置,你可以为你的用户选择一些常见的区域设置,但请记住用户看到的内容可能会有所不同。你通常可以通过测试运行器的配置或模拟 Intl 构造函数来模拟用户偏好。
  • 在多个 JavaScript 引擎上进行测试。Intl API 由 JavaScript 引擎直接实现,因此例如,你应该期望 Node.js 和 Chrome(都使用 V8)具有相同的输出,而 Firefox(使用 SpiderMonkey)可能具有不同的输出。尽管所有引擎都可能使用 CLDR 数据,但它们通常以不同的方式对其进行后处理。某些浏览器构建设置(例如,为了减小安装大小)也可能会影响支持的区域设置和选项。
  • 不要假设输出。这意味着你不应该手动编写输出,例如 expect(result).toBe("foo")。相反,请使用快照测试或从测试运行的输出中复制字符串值。

格式化数据

Intl 的主要用例是输出表示结构化数据的特定于区域设置的文本。这类似于翻译软件,但它不是让你翻译任意文本,而是获取日期、数字和列表等数据,并根据特定于区域设置的规则对其进行格式化。

Intl.DateTimeFormatIntl.DurationFormatIntl.ListFormatIntl.NumberFormatIntl.RelativeTimeFormat 对象各自格式化一种数据。每个实例提供两种方法:

  • format():接受一段数据并使用区域设置和选项确定的格式规则返回一个字符串。
  • formatToParts():接受相同的数据并返回相同的字符串,但分解为多个部分,每个部分都是一个包含 typevalue 的对象。这对于更高级的用例很有用,例如将格式化文本与其他文本交错。

例如,下面是 Intl.NumberFormat 对象的典型用法:

js
// 1. Construct a formatter object, specifying the locale and formatting options:
const price = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
});

// 2. Use the `format` method of the formatter object to format a number:
console.log(price.format(5.259)); // $5.26

// Or, use the `formatToParts` method to get the formatted number
// broken down into parts:
console.table(price.formatToParts(5.259));
// |   | type       | value |
// | 0 | "currency" | "$"   |
// | 1 | "integer"  | "5"   |
// | 2 | "decimal"  | "."   |
// | 3 | "fraction" | "26"  |

你并不总是必须构造格式化程序对象来格式化字符串。对于随意使用,你也可以直接在数据上调用 toLocaleString() 方法,将区域设置和选项作为参数传递。toLocaleString() 方法由 Temporal.PlainDate.prototype.toLocaleString()Temporal.Duration.prototype.toLocaleString()Number.prototype.toLocaleString() 等实现。请阅读你正在格式化的数据文档,以查看它是否支持 toLocaleString(),以及它对应的格式化程序选项。

js
console.log(
  (5.259).toLocaleString("en-US", {
    style: "currency",
    currency: "USD",
  }),
); // $5.26

请注意,toLocaleString() 可能比使用格式化程序对象效率低,因为每次调用 toLocaleString 时,它都必须在庞大的本地化字符串数据库中执行搜索。当使用相同的参数多次调用该方法时,最好创建一个格式化程序对象并使用其 format() 方法,因为格式化程序对象会记住传递给它的参数,并可能决定缓存数据库的一部分,因此未来的 format 调用可以在更受限制的上下文中搜索本地化字符串。

日期和时间格式化

Intl.DateTimeFormat 格式化日期和时间,以及日期和时间范围。DateTimeFormat 对象接受以下形式之一的日期/时间输入:DateTemporal.PlainDateTimeTemporal.PlainTimeTemporal.PlainDateTemporal.PlainYearMonthTemporal.PlainMonthDay

注意:你不能直接传递 Temporal.ZonedDateTime 对象,因为时区已在该对象中固定。你应该使用 Temporal.ZonedDateTime.prototype.toLocaleString() 或先将其转换为 Temporal.PlainDateTime 对象。

本地化日期和时间格式化的常见用例如下:

  • 以其他日历系统输出相同的日期和时间,例如伊斯兰、希伯来或中国日历。
  • 输出相同的实际时间(瞬间),但以其他时区。
  • 选择性地输出日期和时间的某些组件,例如只输出年和月,以及它们的特定表示形式(例如“Thursday”或“Thu”)。
  • 根据特定于区域设置的约定输出日期,例如美国是 MM/DD/YYYY,英国是 DD/MM/YYYY,日本是 YYYY/MM/DD。
  • 根据特定于区域设置的约定输出时间,例如 12 小时制或 24 小时制。

要决定格式化字符串的外观,你首先选择日历(它影响年、月、周和日的计算)和时区(它影响精确时间以及可能的日期)。这是通过上述 calendar 选项(或区域设置标识符中的 -ca- 扩展键)和 timeZone 选项完成的。

  • Date 对象表示用户时区和 ISO 8601 日历中的唯一瞬间(由 Date.prototype.getHours()Date.prototype.getMonth() 等方法报告)。它们通过保留瞬间转换为给定的 calendartimeZone,因此日期和时间组件可能会更改。
  • 各种 Temporal 对象已经内置了日历,因此 calendar 选项必须与对象的日历一致——除非日期的日历是 "iso8601",在这种情况下它会转换为请求的 calendar。这些对象没有时区,因此它们直接在给定的 timeZone 中显示,无需转换。

在这里,我们演示 calendartimeZone 配置的组合如何导致同一瞬间的不同表示。

js
// Assume that the local time zone is UTC
const targetDate = new Date(2022, 0, 1); // 2022-01-01 midnight in the local time zone
const results = [];

for (const calendar of ["gregory", "hebrew"]) {
  for (const timeZone of ["America/New_York", "Asia/Tokyo"]) {
    const df = new Intl.DateTimeFormat("en-US", {
      calendar,
      timeZone,
      // More on these later
      dateStyle: "full",
      timeStyle: "full",
    });
    results.push({ calendar, timeZone, output: df.format(targetDate) });
  }
}

console.table(results);

输出如下所示

calendar timeZone output
'gregory' 'America/New_York' 'Friday, December 31, 2021 at 7:00:00 PM Eastern Standard Time'
'gregory' 'Asia/Tokyo' 'Saturday, January 1, 2022 at 9:00:00 AM Japan Standard Time'
'hebrew' 'America/New_York' 'Friday, 27 Tevet 5782 at 7:00:00 PM Eastern Standard Time'
'hebrew' 'Asia/Tokyo' 'Saturday, 28 Tevet 5782 at 9:00:00 AM Japan Standard Time'

日期/时间由以下组件组成:weekdayerayearmonthdaydayPeriodhourminutesecondfractionalSecondDigitstimeZoneName。你的下一个决定是输出中包含哪些组件,以及它们应该采用什么形式。你有两种选择:

  • 你可以手动配置每个组件,使用与组件同名的选项。只有你指定的组件将以指定的形式包含在输出中。
  • 你可以使用快捷方式 dateStyletimeStyle,它们是预定义的一组组件。它们根据区域设置扩展为一组组件选项。

你应该选择这两种方法之一,因为它们是互斥的。同时使用两种方法将导致错误。

从根本上说,在请求组件组合后,DateTimeFormat 对象会查找与请求组件匹配的“模板”,因此它只需要逐一填充值。并非所有组件组合都有预定义的模板。DateTimeFormat 有一个 formatMatcher 选项,它决定如何通过使组件比请求的更长或更短,或通过省略或添加组件来进行协商。这非常技术化,因此你应该阅读 Intl.DateTimeFormat() 参考以更好地理解它如何处理此问题。

在这里,我们演示了一些常见的组件格式化方式:

js
const df1 = new Intl.DateTimeFormat("en-US", {
  // Include all components (usually)
  dateStyle: "full",
  timeStyle: "full",
});

const df2 = new Intl.DateTimeFormat("en-US", {
  // Display the calendar date
  era: "short",
  year: "numeric",
  month: "long",
  day: "numeric",
});

const df3 = new Intl.DateTimeFormat("en-US", {
  // Display a time like on a digital clock
  hour: "2-digit",
  minute: "2-digit",
  second: "2-digit",
  timeZoneName: "shortOffset",
});

const targetDate = new Date(2022, 0, 1, 12, 34, 56); // 2022-01-01 12:34:56 in the local time zone
console.log(df1.format(targetDate));
// Saturday, January 1, 2022 at 12:34:56 PM Coordinated Universal Time
// January 1, 2022 AD
// 12:34:56 PM GMT

还有其他自定义选项。例如,你可以使用 hourCycle 选项以 12 小时制或 24 小时制显示时间,并将午夜/中午显示为 12:00 或 0:00。你还可以使用 numberingSystem 选项以其他数字系统显示任何数字。

除了 format() 之外,还有第二个重要方法 formatRange(),它格式化日期或时间范围。它接受两个相同类型的日期时间,分别格式化它们,用范围分隔符(如 en-dash)连接它们,并删除重复的公共部分。

js
const springBreak = {
  start: new Date(2023, 2, 10),
  end: new Date(2023, 2, 26),
};

const df = new Intl.DateTimeFormat("en-US", { dateStyle: "long" });
console.log(df.formatRange(springBreak.start, springBreak.end));
// March 10 – 26, 2023

数字格式化

数字格式化使用 Intl.NumberFormat 对象完成。NumberFormat 对象接受数字、字符串或 BigInt 形式的输入。传递字符串或 BigInt 而不是数字允许你格式化太大或太小而无法精确表示为 JavaScript 数字的数字。

本地化数字格式化的常见用例如下:

  • 以其他数字系统(脚本)输出数字,例如中文、阿拉伯文或罗马数字。
  • 以特定于区域设置的约定输出数字,例如小数符号(英语中是“.”,但在许多欧洲文化中是“,”),或数字分组(英语中是 3 位,但在其他文化中可能是 4 或 2 位,并且可能使用“,”、“ ”或“.”)。
  • 以指数表示法输出数字,例如“3.7 million”或“2 thousand”。
  • 将数字输出为货币,应用特定的货币符号和舍入规则。例如,在美国小于一美分或在日本小于一日元的货币值可能没有显示意义。
  • 将数字输出为百分比,应用特定于区域设置的转换和格式化规则。
  • 输出带有单位的数字,例如“米”或“升”,带有翻译的单位名称。

要决定格式化字符串的外观,你首先选择数字系统(它影响用于数字的字符)。数字系统的用途已在 区域设置信息 中讨论。你需要决定的另一个选项是 style,它设置数字所代表的上下文,并可能影响其他选项的默认值。它是 "decimal""percent""currency""unit" 之一。如果你想格式化货币,那么你还需要提供 currency 选项。如果你想格式化单位,那么你还需要提供 unit 选项。

js
const results = [];
for (const options of [
  { style: "decimal" }, // Format the number as a dimensionless decimal
  { style: "percent" }, // Format the number as a percentage; it is multiplied by 100
  { style: "currency", currency: "USD" }, // Format the number as a US dollar amount
  { style: "unit", unit: "meter" }, // Format the number as a length in meters
]) {
  const nf = new Intl.NumberFormat("en-US", options);
  results.push({ style: options.style, output: nf.format(1234567.89) });
}
console.table(results);

输出如下:

style output
'decimal' '1,234,567.89'
'percent' '123,456,789%'
'currency' '$1,234,567.89'
'unit' '1,234,567.89 m'

下一组选项都指定数字部分应该是什么样子。首先,你可能希望以更具可读性的方式表示极大的值。你可以将 notation 选项设置为 "scientific""engineering",两者都使用 1.23e+6 表示法。区别在于后者使用 3 的倍数作为指数,使 尾数e 符号之前的部分)保持在 1 到 1000 之间,而前者可以使用任何整数作为指数,使尾数保持在 1 到 10 之间。你还可以将 notation 设置为 "compact" 以使用更易于人类阅读的表示法。

js
const results = [];
for (const options of [
  { notation: "scientific" },
  { notation: "engineering" },
  { notation: "compact", compactDisplay: "short" }, // "short" is default
  { notation: "compact", compactDisplay: "long" },
]) {
  const nf = new Intl.NumberFormat("en-US", options);
  results.push({
    notation: options.compactDisplay
      ? `${options.notation}-${options.compactDisplay}`
      : options.notation,
    output: nf.format(12000),
  });
}
console.table(results);

输出如下:

notation output
'scientific' '1.2E4'
'engineering' '12E3'
'compact-short' '12K'
'compact-long' '12 thousand'

然后,你可能希望对数字进行舍入(如果你指定了 notation,则只对尾数部分),这样你就不会显示太长的数字。这些是数字选项,包括:

  • minimumIntegerDigits
  • minimumFractionDigits
  • maximumFractionDigits
  • minimumSignificantDigits
  • maximumSignificantDigits
  • roundingPriority
  • roundingIncrement
  • roundingMode

这些选项的精确交互相当复杂,不值得在此处介绍。你应该阅读 数字选项 参考以获取更多详细信息。尽管如此,一般想法是直接的:我们首先找到我们想要保留的小数位数,然后根据最后一位的值向上或向下舍入多余的小数位数。

js
const results = [];
for (const options of [
  { minimumFractionDigits: 4, maximumFractionDigits: 4 },
  { minimumSignificantDigits: 4, maximumSignificantDigits: 4 },
  { minimumFractionDigits: 0, maximumFractionDigits: 0, roundingMode: "floor" },
  {
    minimumFractionDigits: 0,
    maximumFractionDigits: 0,
    roundingMode: "floor",
    roundingIncrement: 10,
  },
]) {
  const nf = new Intl.NumberFormat("en-US", options);
  results.push({
    options,
    output: nf.format(1234.56789),
  });
}
console.table(results);

输出如下所示

options output
{ minimumFractionDigits: 4, maximumFractionDigits: 4 } '1,234.5679'
{ minimumSignificantDigits: 4, maximumSignificantDigits: 4 } '1,235'
{ minimumFractionDigits: 0, maximumFractionDigits: 0, roundingMode: "floor" } '1,234'
{ minimumFractionDigits: 0, maximumFractionDigits: 0, roundingMode: "floor", roundingIncrement: 10 } '1,230'

还有其他自定义选项。例如,你可以使用 useGroupingsignDisplay 选项来自定义是否以及如何显示分组分隔符(例如“1,234,567.89”中的“,”)和符号。但是,请注意,用于分组分隔符、小数点和符号的字符是特定于区域设置的,因此你不能直接自定义它们。

除了 format() 之外,还有第二个重要方法 formatRange(),它格式化数字范围。它接受两个数字表示形式,分别格式化它们,用范围分隔符(如 en-dash)连接它们,并可能删除重复的公共部分。

js
const heightRange = {
  min: 1.63,
  max: 1.95,
};

const nf = new Intl.NumberFormat("en-US", { style: "unit", unit: "meter" });
console.log(nf.formatRange(heightRange.min, heightRange.max));
// 1.63–1.95 m

列表格式化

你可能已经编写了这样的代码:

js
const fruits = ["apple", "banana", "cherry"];
console.log(`I like ${fruits.join(", ")}.`);
// I like apple, banana, cherry.

此代码未国际化。在某些语言中,列表分隔符不是逗号。在大多数语言(包括英语)中,你需要在最后一个项目之前使用连词。但即使手动添加“and”也无法在所有讲英语的人中都正确,因为英语中存在关于 牛津逗号 的争议:“apple, banana, and cherry”与“apple, banana and cherry”。

Intl.ListFormat 对象解决了这个问题。它接受一个字符串数组,并以特定于区域设置的方式连接它们,使得结果表示连词(and)、析取词(or)或单位列表。

js
const fruits = ["apple", "banana", "cherry"];
const lf = new Intl.ListFormat("en-US", { style: "long", type: "conjunction" });
console.log(`I like ${lf.format(fruits)}.`);
// I like apple, banana, and cherry.

const lf = new Intl.ListFormat("en-US", { style: "long", type: "disjunction" });
console.log(`I can give you ${lf.format(fruits)}.`);
// I can give you apple, banana, or cherry.

查看 Intl.ListFormat() 以获取更多示例和选项。

相对时间格式化

Intl.RelativeTimeFormat 格式化时间差。RelativeTimeFormat 对象以两个参数的形式接受相对时间:一个数字(带任何符号)和一个时间单位,例如 "day""hour""minute"

它同时做了几件事:

  • 它本地化并复数化时间单位,例如“1 day”与“2 days”,类似于数字格式化。
  • 它为过去和未来的时间选择合适的短语,例如“in 1 day”与“1 day ago”。
  • 它可能会为某些时间单位选择特殊短语,例如“1 day ago”与“yesterday”。
js
const rtf = new Intl.RelativeTimeFormat("en-US", { numeric: "auto" });
console.log(rtf.format(1, "day")); // tomorrow
console.log(rtf.format(2, "day")); // in 2 days
console.log(rtf.format(-1, "hour")); // 1 hour ago

查看 Intl.RelativeTimeFormat() 以获取更多示例和选项。

持续时间格式化

Intl.DurationFormat 提供持续时间格式化,例如“3 hours, 4 minutes, 5 seconds”。它不是一个具有自己格式化程序的原始操作:它内部使用 Intl.NumberFormatIntl.ListFormat 来格式化每个持续时间组件,然后用列表分隔符连接它们。DurationFormat 对象接受 Temporal.Duration 对象形式的持续时间,或者具有相同属性的普通对象。

除了自定义数字系统之外,持续时间格式化选项还决定是否显示每个组件以及它们的长度。

js
console.log(
  new Intl.DurationFormat("en-US", {
    style: "long",
  }).format({ hours: 3, minutes: 4, seconds: 5 }),
);
// 3 hours, 4 minutes, and 5 seconds

查看 Intl.DurationFormat() 以获取更多示例和选项。

排序

Intl.Collator 对象用于比较和排序字符串。它接受两个字符串并返回一个数字,指示它们的相对顺序,其方式与 Array.prototype.sort 方法的 compareFn 参数相同。

你不应该使用 JavaScript 运算符(如 ===>)来比较面向用户的字符串,原因有很多:

  • 不相关的拼写变体:例如,在英语中,“naïve”和“naive”只是同一个词的替代拼写,应该被视为相等。
  • 忽略大小写:通常,在比较字符串时,你希望忽略大小写。例如,“apple”和“Apple”应该被视为相等。
  • Unicode 码点顺序没有意义:比较运算符(如 >)按 Unicode 码点顺序进行比较,这与字典中字符的顺序不同。例如,“ï”在码点顺序中排在“z”之后,但你希望它在字典中排在“i”旁边。
  • Unicode 规范化:同一个字符在 Unicode 中可能有多种表示形式。例如,“ñ”可以表示为单个字符,也可以表示为“n”后跟一个组合波浪号。(参见 String.prototype.normalize()。)这些应该被视为相等。
  • 数字比较:字符串中的数字应按数字而不是字符串进行比较。例如,你希望“test-10”排在“test-2”之后。

排序有两种不同的用例:排序搜索。排序是指你有一个字符串列表,并希望根据某种规则对其进行排序。搜索是指你有一个字符串列表,并希望找到与查询匹配的字符串。在搜索时,你应该只关注比较结果是否为零(相等),而不是结果的符号。

即使在同一个区域设置中,也有许多不同的排序方式。例如,德语中有两种不同的排序顺序:电话簿字典。电话簿排序强调发音——就像“ä”、“ö”等在排序前被扩展为“ae”、“oe”等。

js
const names = ["Hochberg", "Hönigswald", "Holzman"];

const germanPhonebook = new Intl.Collator("de-DE-u-co-phonebk");

// as if sorting ["Hochberg", "Hoenigswald", "Holzman"]:
console.log(names.sort(germanPhonebook.compare));
// ['Hochberg', 'Hönigswald', 'Holzman']

一些德语单词会带额外的变音符号,因此在词典中,忽略变音符号排序是明智的(除非排序的单词因变音符号而异:schonschön 之前)。

js
const germanDictionary = new Intl.Collator("de-DE-u-co-dict");

// as if sorting ["Hochberg", "Honigswald", "Holzman"]:
console.log(names.sort(germanDictionary.compare).join(", "));
// "Hochberg, Holzman, Hönigswald"

复数规则

Intl.PluralRules 对象对于选择单词的正确复数形式很有用。它不会自动为你复数化单词(例如,你不能传入“apple”并期望返回“apples”),但它会根据数字告诉你使用哪种复数形式。你可能已经这样做了:

js
function formatMessage(n) {
  return `You have ${n} ${n === 1 ? "apple" : "apples"}.`;
}

但这很难推广到所有语言,尤其是那些有许多复数形式的语言。你可以查看 Intl.PluralRules 以获取复数规则的一般介绍。在这里,我们只演示一些常见的用例。

js
const prCard = new Intl.PluralRules("en-US");
const prOrd = new Intl.PluralRules("en-US", { type: "ordinal" });

const englishOrdinalSuffixes = {
  one: "st",
  two: "nd",
  few: "rd",
  other: "th",
};

const catPlural = {
  one: "cat",
  other: "cats",
};

function formatMessage(n1, n2) {
  return `The ${n1}${englishOrdinalSuffixes[prOrd.select(n1)]} U.S. president had ${n2} ${catPlural[prCard.select(n2)]}.`;
}

console.log(formatMessage(42, 1)); // The 42nd U.S. president had 1 cat.
console.log(formatMessage(45, 0)); // The 45th U.S. president had 0 cats.

分段

Intl.Segmenter 对象对于将字符串分段很有用。如果没有 Intl,你已经能够按 UTF-16 码元和 Unicode 码点 拆分字符串:

js
const str = "🇺🇸🇨🇳🇷🇺🇬🇧🇫🇷";
console.log(str.split(""));
// Array(20) ['\uD83C', '\uDDFA', '\uD83C', ...]
console.log([...str]);
// Array(10) ['🇺', '🇸', '🇨', '🇳', '🇷', '🇺', '🇬', '🇧', '🇫', '🇷']

但正如你所看到的,Unicode 码点与人类用户感知到的离散字符不同。这通常发生在表情符号中,单个表情符号可以由多个码点表示。当用户与文本交互时,字素是他们可以操作的最小文本单位,例如删除或选择。Segmenter 对象支持字素级分段,这对于计算字符、测量文本宽度等很有用。它接受一个字符串并返回一个可迭代的 segments 对象,其中每个元素都有一个 segment 属性,表示分段的文本。

js
const segmenter = new Intl.Segmenter("en-US", { granularity: "grapheme" });
console.log([...segmenter.segment("🇺🇸🇨🇳🇷🇺🇬🇧🇫🇷")].map((s) => s.segment));
// ['🇺🇸', '🇨🇳', '🇷🇺', '🇬🇧', '🇫🇷']

分段器还可以进行更高级别的分段,包括单词级和句子级拆分。这些用例必然是特定于语言的。例如,以下是一个非常糟糕的单词计数实现:

js
const wordCount = (str) => str.split(/\s+/).length;
console.log(wordCount("Hello, world!")); // 2

这存在几个问题:并非所有语言都使用空格来分隔单词,并非所有空格都分隔单词,并且并非所有单词都由空格分隔。为了解决这个问题,使用 Segmenter 并设置 granularity: "word"。结果是输入字符串,被分割成单词和非单词段。如果你正在计数单词,你应该通过检查每个段的 isWordLike 属性来过滤掉非单词。

js
const segmenter = new Intl.Segmenter("en-US", { granularity: "word" });
const str = "It can even split non-space-separated words";
console.table([...segmenter.segment(str)]);
// ┌─────────────┬───────┬────────────┐
// │ segment     │ index │ isWordLike │
// ├─────────────┼───────┼────────────┤
// │ 'It'        │ 0     │ true       │
// │ ' '         │ 2     │ false      │
// │ 'can'       │ 3     │ true       │
// │ ' '         │ 6     │ false      │
// │ 'even'      │ 7     │ true       │
// │ ' '         │ 11    │ false      │
// │ 'split'     │ 12    │ true       │
// │ ' '         │ 17    │ false      │
// │ 'non'       │ 18    │ true       │
// │ '-'         │ 21    │ false      │
// │ 'space'     │ 22    │ true       │
// │ '-'         │ 27    │ false      │
// │ 'separated' │ 28    │ true       │
// │ ' '         │ 37    │ false      │
// │ 'words'     │ 38    │ true       │
// └─────────────┴───────┴────────────┘

console.log(
  [...segmenter.segment(str)].filter((s) => s.isWordLike).map((s) => s.segment),
);
// ['It', 'can', 'even', 'split', 'non', 'space', 'separated', 'words']

单词分段也适用于基于字符的语言。例如,在中文中,几个字符可以代表一个单词,但它们之间没有空格。分段器实现与浏览器内置单词分段相同的行为,由双击单词触发。

js
const segmenter = new Intl.Segmenter("zh-Hans", { granularity: "word" });
console.log([...segmenter.segment("我是这篇文档的作者")].map((s) => s.segment));
// ['我是', '这', '篇', '文', '档', '的', '作者']

句子分段同样复杂。例如,在英语中,有许多标点符号可以标记句子的结尾(“.”、“!”、“?” 等等)。

js
const segmenter = new Intl.Segmenter("en-US", { granularity: "sentence" });
console.log(
  [...segmenter.segment("I ate a sandwich. Then I went to bed.")].map(
    (s) => s.segment,
  ),
);
// ['I ate a sandwich. ', 'Then I went to bed.']

请注意,分段器不会删除任何字符。它只是将字符串拆分成段,每个段都是一个句子。然后你可以删除标点符号。此外,分段器的当前实现不支持句子分段抑制(防止在“Mr.”或“Approx.”等句号后出现句子中断),但正在进行这方面的工作。

显示名称

在介绍了这么多选项和行为之后,你可能会想如何将它们呈现给用户。Intl 提供了两个用于构建用户界面的有用 API:Intl.supportedValuesOf()Intl.DisplayNames

Intl.supportedValuesOf() 函数返回给定选项支持的值数组。例如,你可以使用它来填充受支持日历的下拉列表,用户可以从中选择显示日期。

js
const supportedCal = Intl.supportedValuesOf("calendar");
console.log(supportedCal);
// ['buddhist', 'chinese', 'coptic', 'dangi', ...]

但通常,这些标识符对用户不友好。例如,你可能希望以用户的语言显示日历,或将其缩写展开。为此,Intl.DisplayNames 对象非常有用。它类似于格式化程序,但它不是基于模板的。相反,它是一个从语言无关标识符到本地化名称的直接映射。它支持格式化语言、区域、脚本(BCP 47 语言标签的三个子字段)、货币、日历和日期时间字段。

尝试下面的演示:

html
<select id="lang"></select>
<select id="calendar"></select>
<output id="output"></output>
js
const langSelect = document.getElementById("lang");
const calSelect = document.getElementById("calendar");
const fieldset = document.querySelector("fieldset");
const output = document.getElementById("output");

// A few examples
const langs = [
  "en-US",
  "zh-Hans-CN",
  "ja-JP",
  "ar-EG",
  "ru-RU",
  "es-ES",
  "fr-FR",
  "de-DE",
  "hi-IN",
  "pt-BR",
  "bn-BD",
  "he-IL",
];
const calendars = Intl.supportedValuesOf("calendar");

for (const lang of langs) {
  const option = document.createElement("option");
  option.value = lang;
  option.textContent = new Intl.DisplayNames(lang, { type: "language" }).of(
    lang,
  );
  langSelect.appendChild(option);
}

function renderCalSelect() {
  const lang = langSelect.value;
  calSelect.innerHTML = "";
  const dn = new Intl.DisplayNames(lang, { type: "calendar" });
  const preferredCalendars = new Intl.Locale(lang).getCalendars?.() ?? [
    "gregory",
  ];
  for (const cal of [
    ...preferredCalendars,
    ...calendars.filter((c) => !preferredCalendars.includes(c)),
  ]) {
    const option = document.createElement("option");
    option.value = cal;
    option.textContent = dn.of(cal);
    calSelect.appendChild(option);
  }
}

function renderFieldInputs() {
  const lang = langSelect.value;
  fieldset.querySelectorAll("label").forEach((label) => label.remove());
  const dn = new Intl.DisplayNames(lang, { type: "dateTimeField" });
  for (const field of fields) {
    const label = document.createElement("label");
    label.textContent = dn.of(field);
    const input = document.createElement("input");
    input.type = "checkbox";
    input.value = field;
    label.appendChild(input);
    fieldset.appendChild(label);
  }
}

function renderTime() {
  const lang = langSelect.value;
  const cal = calSelect.value;
  const df = new Intl.DateTimeFormat(lang, {
    calendar: cal,
    dateStyle: "full",
    timeStyle: "full",
  });
  const now = new Date();
  const dn = new Intl.DisplayNames(lang, { type: "dateTimeField" });
  output.innerHTML = "";
  for (const component of df.formatToParts(now)) {
    const text = document.createElement("span");
    text.textContent = component.value;
    if (
      ![
        "era",
        "year",
        "quarter",
        "month",
        "weekOfYear",
        "weekday",
        "day",
        "dayPeriod",
        "hour",
        "minute",
        "second",
        "timeZoneName",
      ].includes(component.type)
    ) {
      output.appendChild(text);
      continue;
    }
    const title = dn.of(component.type);
    const field = document.createElement("ruby");
    field.appendChild(text);
    const rt = document.createElement("rt");
    rt.textContent = title;
    field.appendChild(rt);
    output.appendChild(field);
  }
}

renderCalSelect();
renderTime();
langSelect.addEventListener("change", renderCalSelect);
langSelect.addEventListener("change", renderTime);
calSelect.addEventListener("change", renderTime);
setInterval(renderTime, 500);