如何为区间(如 1-3 个项目)选择复数形式

使用 JavaScript 在显示数字区间时选择正确的复数形式

引言

区间用于表示一个值介于两个端点之间。用户界面中常见的区间场景包括:搜索结果显示“找到 10-15 个匹配项”、库存系统显示“1-3 个可用项目”,或筛选器显示“选择 2-5 个选项”。这些区间将两个数字与描述性文本结合,文本需要在语法上与区间保持一致。

当你显示单个计数时,需要在单数和复数形式之间选择:“1 个项目”与“2 个项目”。不同语言有各自的规则来决定使用哪种形式。英语在 1 时用单数,其他数字用复数。波兰语在 1、2-4 和 5 及以上时分别使用不同的形式。阿拉伯语则根据数量有六种不同的形式。

区间带来了不同的挑战。复数形式不仅取决于起始值,还取决于结束值,而不是单一数字。在英语中,“1-2 items”即使起始值为 1,也要用复数。不同语言对于区间使用哪种复数形式有不同的规则。selectRange() 方法在 Intl.PluralRules 上可以自动处理这些特定语言的规则。

为什么区间需要不同的复数规则

对区间中的单个数字使用 select() 方法,并不能在所有语言中得到正确的结果。你可能会考虑用区间的结束值,但在许多语言中这会导致错误的结果。

以英语的 0-1 区间为例。对结束值使用 select() 方法会返回“one”,这意味着你应该显示“0-1 item”。但这在语法上是错误的,正确的表达应为“0-1 items”,即使用复数形式。

const rules = new Intl.PluralRules("en-US");

console.log(rules.select(1));
// Output: "one"

// But "0-1 item" is incorrect
// Correct: "0-1 items"

不同语言对于数值区间有明确的规则,这些规则与单个数字的规则并不相同。例如,在斯洛文尼亚语中,区间 102-201 使用 "few" 形式,而该区间内的单个数字则使用不同的形式。

const slRules = new Intl.PluralRules("sl");

console.log(slRules.select(102));
// Output: "few"

console.log(slRules.select(201));
// Output: "few"

console.log(slRules.selectRange(102, 201));
// Output: "few"

有些语言根据区间的起始值来确定形式,有些则根据结束值,还有一些会同时考虑起止值。selectRange() 方法封装了这些特定语言的规则,因此你无需手动实现。

为区间创建 PluralRules 实例

创建 Intl.PluralRules 实例的方法与处理单个计数时相同。该实例既能为单个数字提供 select(),也能为区间提供 selectRange()

const rules = new Intl.PluralRules("en-US");

你可以在创建实例时指定选项,这些选项同时适用于单个计数和区间。

const rules = new Intl.PluralRules("en-US", {
  type: "cardinal"
});

type 选项默认为 "cardinal",用于处理对象计数。你也可以使用 "ordinal" 处理序数,尽管序数区间在用户界面中较为少见。

请在多次调用中复用同一个实例。每次复数化都新建实例会造成资源浪费。建议将实例存储在变量中或按 locale 缓存。

使用 selectRange 判断区间的复数类别

selectRange() 方法接收两个数字,分别表示区间的起始和结束。它会返回一个字符串,指示适用的复数类别:"zero"、"one"、"two"、"few"、"many" 或 "other"。

const rules = new Intl.PluralRules("en-US");

console.log(rules.selectRange(0, 1));
// Output: "other"

console.log(rules.selectRange(1, 2));
// Output: "other"

console.log(rules.selectRange(5, 10));
// Output: "other"

在英语中,区间几乎总是使用 "other" 类别,对应复数形式。这与英语使用者在表达区间时自然采用复数名词的方式一致。

具有更多复数形式的语言会根据其特定规则返回不同的类别。

const arRules = new Intl.PluralRules("ar-EG");

console.log(arRules.selectRange(0, 0));
// Output: "zero"

console.log(arRules.selectRange(1, 1));
// Output: "one"

console.log(arRules.selectRange(2, 2));
// Output: "two"

console.log(arRules.selectRange(3, 10));
// Output: "few"

返回值始终是六个标准复数类别名称之一。您的代码需要将这些类别映射到相应的本地化文本。

将区间类别映射到本地化字符串

将每个复数类别的文本形式存储在一个数据结构中。使用 selectRange() 返回的类别来查找对应的文本。

const rules = new Intl.PluralRules("en-US");
const forms = new Map([
  ["one", "item"],
  ["other", "items"]
]);

function formatRange(start, end) {
  const category = rules.selectRange(start, end);
  const form = forms.get(category);
  return `${start}-${end} ${form}`;
}

console.log(formatRange(1, 3));
// Output: "1-3 items"

console.log(formatRange(0, 1));
// Output: "0-1 items"

console.log(formatRange(5, 10));
// Output: "5-10 items"

这种模式将复数逻辑与本地化文本分离。Intl.PluralRules 实例负责处理语言规则,Map 保存翻译,函数将两者结合。

对于拥有更多复数类别的语言,需要为该语言使用的每个类别添加条目。

const arRules = new Intl.PluralRules("ar-EG");
const arForms = new Map([
  ["zero", "عناصر"],
  ["one", "عنصر"],
  ["two", "عنصران"],
  ["few", "عناصر"],
  ["many", "عنصرًا"],
  ["other", "عنصر"]
]);

function formatRange(start, end) {
  const category = arRules.selectRange(start, end);
  const form = arForms.get(category);
  return `${start}-${end} ${form}`;
}

console.log(formatRange(0, 0));
// Output: "0-0 عناصر"

console.log(formatRange(1, 1));
// Output: "1-1 عنصر"

务必为该语言使用的每个类别提供文本。请查阅 Unicode CLDR 复数规则,或通过 API 在不同区间进行测试,以确定需要哪些类别。

不同语言环境如何处理区间复数

每种语言都有自己的区间复数形式判断规则,这些规则反映了母语者在该语言中表达区间的自然方式。

const enRules = new Intl.PluralRules("en-US");
console.log(enRules.selectRange(1, 3));
// Output: "other"

const slRules = new Intl.PluralRules("sl");
console.log(slRules.selectRange(102, 201));
// Output: "few"

const ptRules = new Intl.PluralRules("pt");
console.log(ptRules.selectRange(102, 102));
// Output: "other"

const ruRules = new Intl.PluralRules("ru");
console.log(ruRules.selectRange(1, 2));
// Output: "few"

英语对区间始终使用 "other",即区间总是复数。斯洛文尼亚语则根据区间内的具体数字应用更复杂的规则。葡萄牙语大多数区间使用 "other"。俄语在某些区间使用 "few"。

这些差异说明了为什么硬编码复数逻辑无法满足国际化应用需求。API 封装了每种语言处理区间的知识。

结合 Intl.NumberFormat 实现完整格式化

实际应用中需要同时格式化数字和文本。请使用 Intl.NumberFormat 按照本地习惯格式化区间端点,再用 selectRange() 选择正确的复数形式。

const locale = "en-US";
const numberFormat = new Intl.NumberFormat(locale);
const pluralRules = new Intl.PluralRules(locale);
const forms = new Map([
  ["one", "item"],
  ["other", "items"]
]);

function formatRange(start, end) {
  const startFormatted = numberFormat.format(start);
  const endFormatted = numberFormat.format(end);
  const category = pluralRules.selectRange(start, end);
  const form = forms.get(category);
  return `${startFormatted}-${endFormatted} ${form}`;
}

console.log(formatRange(1, 3));
// Output: "1-3 items"

console.log(formatRange(1000, 5000));
// Output: "1,000-5,000 items"

数字格式化器会添加千位分隔符。复数规则会选择正确的形式。该函数将两者结合,生成格式正确的输出。

不同的语言环境有不同的数字格式规范。

const locale = "de-DE";
const numberFormat = new Intl.NumberFormat(locale);
const pluralRules = new Intl.PluralRules(locale);
const forms = new Map([
  ["one", "Artikel"],
  ["other", "Artikel"]
]);

function formatRange(start, end) {
  const startFormatted = numberFormat.format(start);
  const endFormatted = numberFormat.format(end);
  const category = pluralRules.selectRange(start, end);
  const form = forms.get(category);
  return `${startFormatted}-${endFormatted} ${form}`;
}

console.log(formatRange(1000, 5000));
// Output: "1.000-5.000 Artikel"

德语使用句点作为千位分隔符,而不是逗号。数字格式化器会自动处理这一点。复数规则决定使用哪种 "Artikel" 形式。

单值时 selectRange 与 select 的对比

select() 方法用于处理单个数量,而 selectRange() 用于处理区间。当只显示单一数量时,使用 select();当显示两个值之间的区间时,使用 selectRange()

const rules = new Intl.PluralRules("en-US");

// Single count
console.log(rules.select(1));
// Output: "one"

console.log(rules.select(2));
// Output: "other"

// Range
console.log(rules.selectRange(1, 2));
// Output: "other"

console.log(rules.selectRange(0, 1));
// Output: "other"

对于单个数量,规则只依赖于这个数字本身。对于区间,规则会同时考虑起点和终点。在英文中,即使区间起始为 1,仍然使用复数形式,而单独的 1 则用单数形式。

有些语言在单数规则和区间规则之间的差异更加明显。

const slRules = new Intl.PluralRules("sl");

// Single counts in Slovenian
console.log(slRules.select(1));
// Output: "one"

console.log(slRules.select(2));
// Output: "two"

console.log(slRules.select(5));
// Output: "few"

// Range in Slovenian
console.log(slRules.selectRange(102, 201));
// Output: "few"

斯洛文尼亚语针对不同的单个数量使用 "one"、"two" 和 "few",其规则较为复杂。对于区间,则采用不同的逻辑,会同时考虑两个数字。

处理起止值相等的区间

当起始值和结束值相等时,表示的是宽度为零的区间。在某些应用场景下,这种方式用于在需要区间的上下文中表示一个精确值。

const rules = new Intl.PluralRules("en-US");

console.log(rules.selectRange(5, 5));
// Output: "other"

console.log(rules.selectRange(1, 1));
// Output: "one"

当两个值都为 1 时,英文会返回 "one",建议使用单数形式。当两个值为其他数字时,英文会返回 "other",建议使用复数形式。

如果你将区间显示为 "1-1 item" 或仅为 "1 item",这种行为是合理的。对于不等于 1 的值,你会显示 "5-5 items" 或 "5 items"。

在实际应用中,你可能希望检测 start 是否等于 end,并在这种情况下只显示单个值而不是区间。

const rules = new Intl.PluralRules("en-US");
const forms = new Map([
  ["one", "item"],
  ["other", "items"]
]);

function formatRange(start, end) {
  if (start === end) {
    const category = rules.select(start);
    const form = forms.get(category);
    return `${start} ${form}`;
  }

  const category = rules.selectRange(start, end);
  const form = forms.get(category);
  return `${start}-${end} ${form}`;
}

console.log(formatRange(1, 1));
// Output: "1 item"

console.log(formatRange(5, 5));
// Output: "5 items"

console.log(formatRange(1, 3));
// Output: "1-3 items"

这种方法在数值相等时使用 select(),在实际区间时使用 selectRange()。这样输出更自然,因为避免了显示 "1-1" 或 "5-5"。

使用 selectRange 处理边界情况

selectRange() 方法会校验其输入参数。如果任一参数为 undefinednull,或无法转换为有效数字,该方法会抛出错误。

const rules = new Intl.PluralRules("en-US");

try {
  console.log(rules.selectRange(1, undefined));
} catch (error) {
  console.log(error.name);
  // Output: "TypeError"
}

try {
  console.log(rules.selectRange(NaN, 5));
} catch (error) {
  console.log(error.name);
  // Output: "RangeError"
}

在将输入传递给 selectRange() 之前请先校验。处理用户输入或外部数据时,这一点尤为重要。

function formatRange(start, end) {
  if (typeof start !== "number" || typeof end !== "number") {
    throw new Error("Start and end must be numbers");
  }

  if (isNaN(start) || isNaN(end)) {
    throw new Error("Start and end must be valid numbers");
  }

  const category = rules.selectRange(start, end);
  const form = forms.get(category);
  return `${start}-${end} ${form}`;
}

该方法接受数字、BigInt 值,或可解析为数字的字符串。

const rules = new Intl.PluralRules("en-US");

console.log(rules.selectRange(1, 5));
// Output: "other"

console.log(rules.selectRange(1n, 5n));
// Output: "other"

console.log(rules.selectRange("1", "5"));
// Output: "other"

字符串输入会被解析为数字。这为调用方法提供了灵活性,但建议尽量传递实际的数字类型以保证代码清晰。

处理小数区间

selectRange() 方法支持小数。这在显示测量值或统计数据等分数区间时非常有用。

const rules = new Intl.PluralRules("en-US");

console.log(rules.selectRange(1.5, 2.5));
// Output: "other"

console.log(rules.selectRange(0.5, 1.0));
// Output: "other"

console.log(rules.selectRange(1.0, 1.5));
// Output: "other"

英语中所有这些小数区间都视为复数。其他语言可能对小数区间有不同规则。

格式化小数区间时,将 selectRange() 与配置了合适小数精度的 Intl.NumberFormat 结合使用。

const locale = "en-US";
const numberFormat = new Intl.NumberFormat(locale, {
  minimumFractionDigits: 1,
  maximumFractionDigits: 1
});
const pluralRules = new Intl.PluralRules(locale);
const forms = new Map([
  ["one", "kilometer"],
  ["other", "kilometers"]
]);

function formatRange(start, end) {
  const startFormatted = numberFormat.format(start);
  const endFormatted = numberFormat.format(end);
  const category = pluralRules.selectRange(start, end);
  const form = forms.get(category);
  return `${startFormatted}-${endFormatted} ${form}`;
}

console.log(formatRange(1.5, 2.5));
// Output: "1.5-2.5 kilometers"

console.log(formatRange(0.5, 1.0));
// Output: "0.5-1.0 kilometers"

数字格式化器可确保小数显示的一致性。复数规则会根据小数值确定正确的形式。

浏览器支持与兼容性

与其他 Intl API 方法相比,selectRange() 方法相对较新。它于 2023 年作为 Intl.NumberFormat v3 规范的一部分推出。

浏览器支持包括 Chrome 106 及以上版本、Firefox 116 及以上版本、Safari 15.4 及以上版本,以及 Edge 106 及以上版本。该方法在 Internet Explorer 或更早的浏览器版本中不可用。

对于面向现代浏览器的应用,可以直接使用 selectRange(),无需 polyfill。如果需要支持旧版浏览器,请在使用前检查该方法是否存在。

const rules = new Intl.PluralRules("en-US");

if (typeof rules.selectRange === "function") {
  // Use selectRange for range pluralization
  console.log(rules.selectRange(1, 3));
} else {
  // Fall back to select with the end value
  console.log(rules.select(3));
}

selectRange() 不可用时,此回退方案会对结束值使用 select()。虽然这种方式在所有语言中并不完全符合语言习惯,但对于旧版浏览器来说可以提供较为合理的近似效果。

如果需要在旧环境下获得全面支持,可以通过 @formatjs/intl-pluralrules 等包获取 polyfill。

何时使用 selectRange 与 select

当 UI 明确显示起始值和结束值的区间时,应使用 selectRange()。例如,搜索结果显示“找到 10-15 条匹配项”、库存显示“库存 1-3 件”或筛选器显示“选择 2-5 个选项”等场景。

当只需显示单个数量时,即使该数量是近似值或汇总值,也应使用 select()。例如,“约 10 条结果”使用 select(10),因为此时只显示一个数字,而不是区间。

如果你的区间数字使用 Intl.NumberFormat.formatRange() 进行显示,建议为相关文本使用 selectRange()。这样可以确保数字格式和文本复数处理的一致性。

const locale = "en-US";
const numberFormat = new Intl.NumberFormat(locale);
const pluralRules = new Intl.PluralRules(locale);
const forms = new Map([
  ["one", "result"],
  ["other", "results"]
]);

function formatSearchResults(start, end) {
  const rangeFormatted = numberFormat.formatRange(start, end);
  const category = pluralRules.selectRange(start, end);
  const form = forms.get(category);
  return `Found ${rangeFormatted} ${form}`;
}

console.log(formatSearchResults(10, 15));
// Output: "Found 10–15 results"

此模式使用 formatRange()(来自 Intl.NumberFormat)来格式化数字,并使用 selectRange()(来自 Intl.PluralRules)来选择文本。两种方法都可用于区间,确保所有语言都能正确处理。