如何在 JavaScript 中格式化带“或”的列表

使用 Intl.ListFormat 的 disjunction 类型,在任何语言中都能正确格式化备选项列表

引言

应用程序经常向用户展示多种选择或备选项。例如,文件上传组件支持上传“PNG、JPEG 或 SVG”文件;支付表单允许使用“信用卡、借记卡或 PayPal”作为支付方式;错误信息会建议修正“用户名、密码或邮箱地址”以解决认证失败。

这些列表使用“或”来表示备选项。如果手动用字符串拼接来格式化,在其他语言中会出现问题,因为不同语言有不同的标点规则、不同的“或”表达方式,以及不同的逗号使用习惯。使用 Intl.ListFormat API 的 disjunction 类型,可以在任何语言中正确格式化这些备选项列表。

什么是析取列表

析取列表用于展示多个备选项,通常只会选择其中一个。英文中的“disjunction”意为分离或备选。在英语中,析取列表用“or”作为连接词:

const paymentMethods = ["credit card", "debit card", "PayPal"];
// Desired output: "credit card, debit card, or PayPal"

这与用“and”表示所有项都适用的合取列表不同。析取列表表达选择,合取列表表达组合。

常见的析取列表场景包括支付方式、文件格式限制、故障排查建议、搜索筛选条件等任何需要用户从多个选项中选择一个的界面。

为什么手动格式化会失败

英语中,析取列表通常写作“A、B 或 C”,各项之间用逗号分隔,最后一项前用“or”。但这种格式在其他语言中并不适用:

// Hardcoded English pattern
const items = ["apple", "orange", "banana"];
const text = items.slice(0, -1).join(", ") + ", or " + items[items.length - 1];
// "apple, orange, or banana"

这段代码在西班牙语、法语、德语以及大多数其他语言中都会产生错误的输出。每种语言对析取列表的格式都有不同的规则。

西班牙语使用 "o",前面不加逗号:

Expected: "manzana, naranja o plátano"
English pattern produces: "manzana, naranja, or plátano"

法语使用 "ou",前面不加逗号:

Expected: "pomme, orange ou banane"
English pattern produces: "pomme, orange, or banane"

德语使用 "oder",前面不加逗号:

Expected: "Apfel, Orange oder Banane"
English pattern produces: "Apfel, Orange, or Banane"

日语使用助词 "か"(ka),标点用法不同:

Expected: "りんご、オレンジ、またはバナナ"
English pattern produces: "りんご、オレンジ、 or バナナ"

这些差异不仅仅是简单的词语替换。标点位置、空格规则和语法助词在不同语言中都有所不同。手动字符串拼接无法处理这种复杂性。

使用 Intl.ListFormat 的 disjunction 类型

Intl.ListFormat API 会根据语言规则格式化列表。将 type 选项设置为 "disjunction",即可格式化备选项列表:

const formatter = new Intl.ListFormat("en", { type: "disjunction" });

const paymentMethods = ["credit card", "debit card", "PayPal"];
console.log(formatter.format(paymentMethods));
// "credit card, debit card, or PayPal"

格式化器可以处理任意长度的数组:

const formatter = new Intl.ListFormat("en", { type: "disjunction" });

console.log(formatter.format([]));
// ""

console.log(formatter.format(["credit card"]));
// "credit card"

console.log(formatter.format(["credit card", "PayPal"]));
// "credit card or PayPal"

console.log(formatter.format(["credit card", "debit card", "PayPal"]));
// "credit card, debit card, or PayPal"

API 会自动为每种情况应用正确的标点和连词。

了解 disjunction 样式

style 选项用于控制格式化的详细程度。共有三种样式:longshortnarrowlong 样式为默认值。

const items = ["email", "phone", "SMS"];

const long = new Intl.ListFormat("en", {
  type: "disjunction",
  style: "long"
});
console.log(long.format(items));
// "email, phone, or SMS"

const short = new Intl.ListFormat("en", {
  type: "disjunction",
  style: "short"
});
console.log(short.format(items));
// "email, phone, or SMS"

const narrow = new Intl.ListFormat("en", {
  type: "disjunction",
  style: "narrow"
});
console.log(narrow.format(items));
// "email, phone, or SMS"

在英语中,三种样式下的析取列表输出相同。其他语言则有更多变化。例如德语在 long 样式下使用 "oder",而在 narrow 样式下可能会缩写。对于有多种正式程度或较长连词的语言,这些差异会更加明显。

narrow 样式通常会去除空格或使用更短的连词,以适应空间受限的布局。long 样式适用于标准文本,short 样式适用于中等紧凑的显示,narrow 样式适用于如移动端界面或紧凑表格等空间极为有限的场景。

不同语言中析取列表的表现

每种语言在格式化析取列表时都有其独特的规范。Intl.ListFormat 能自动处理这些差异。

英语使用逗号和 "or":

const en = new Intl.ListFormat("en", { type: "disjunction" });
console.log(en.format(["PNG", "JPEG", "SVG"]));
// "PNG, JPEG, or SVG"

西班牙语使用逗号和 "o",且在最后一个连接词前不加逗号:

const es = new Intl.ListFormat("es", { type: "disjunction" });
console.log(es.format(["PNG", "JPEG", "SVG"]));
// "PNG, JPEG o SVG"

法语使用逗号和 "ou",且在最后一个连接词前不加逗号:

const fr = new Intl.ListFormat("fr", { type: "disjunction" });
console.log(fr.format(["PNG", "JPEG", "SVG"]));
// "PNG, JPEG ou SVG"

德语使用逗号和 "oder",且在最后一个连接词前不加逗号:

const de = new Intl.ListFormat("de", { type: "disjunction" });
console.log(de.format(["PNG", "JPEG", "SVG"]));
// "PNG, JPEG oder SVG"

日语使用不同的标点和助词:

const ja = new Intl.ListFormat("ja", { type: "disjunction" });
console.log(ja.format(["PNG", "JPEG", "SVG"]));
// "PNG、JPEG、またはSVG"

中文使用中文标点符号:

const zh = new Intl.ListFormat("zh", { type: "disjunction" });
console.log(zh.format(["PNG", "JPEG", "SVG"]));
// "PNG、JPEG或SVG"

这些示例展示了 API 如何适应每种语言的语法和标点规范。只要传入合适的 locale,相同的代码可在所有语言中使用。

支付选项格式化

支付表单会展示多种支付方式。可用析取列表进行格式化:

const formatter = new Intl.ListFormat("en", { type: "disjunction" });

function getPaymentMessage(methods) {
  if (methods.length === 0) {
    return "No payment methods available";
  }

  return `Pay with ${formatter.format(methods)}.`;
}

const methods = ["credit card", "debit card", "PayPal", "Apple Pay"];
console.log(getPaymentMessage(methods));
// "Pay with credit card, debit card, PayPal, or Apple Pay."

对于国际化应用,请传入用户的 locale:

const userLocale = navigator.language; // e.g., "fr-FR"
const formatter = new Intl.ListFormat(userLocale, { type: "disjunction" });

function getPaymentMessage(methods) {
  if (methods.length === 0) {
    return "No payment methods available";
  }

  return `Pay with ${formatter.format(methods)}.`;
}

这种方式适用于结账流程、支付方式选择器以及任何让用户选择支付方式的界面。

文件上传限制格式化

文件上传组件会指定系统支持的文件类型:

const formatter = new Intl.ListFormat("en", { type: "disjunction" });

function getAcceptedFormatsMessage(formats) {
  if (formats.length === 0) {
    return "No file formats accepted";
  }

  if (formats.length === 1) {
    return `Accepted format: ${formats[0]}`;
  }

  return `Accepted formats: ${formatter.format(formats)}`;
}

const imageFormats = ["PNG", "JPEG", "SVG", "WebP"];
console.log(getAcceptedFormatsMessage(imageFormats));
// "Accepted formats: PNG, JPEG, SVG, or WebP"

const documentFormats = ["PDF", "DOCX"];
console.log(getAcceptedFormatsMessage(documentFormats));
// "Accepted formats: PDF or DOCX"

此模式适用于图片上传、文档提交及任何带格式限制的文件输入。

故障排查建议格式化

错误消息通常会提供多种解决问题的方法。请将这些建议以“或”列表的形式呈现:

const formatter = new Intl.ListFormat("en", { type: "disjunction" });

function getAuthenticationError(missingFields) {
  if (missingFields.length === 0) {
    return "Authentication failed";
  }

  return `Please check your ${formatter.format(missingFields)} and try again.`;
}

console.log(getAuthenticationError(["username", "password"]));
// "Please check your username or password and try again."

console.log(getAuthenticationError(["email", "username", "password"]));
// "Please check your email, username, or password and try again."

使用“或”列表可以明确告知用户只需修复其中任意一个字段,而非全部字段。

搜索筛选条件的格式化方式

搜索界面会显示已激活的筛选条件。当筛选条件存在备选项时,请使用“或”列表:

const formatter = new Intl.ListFormat("en", { type: "disjunction" });

function getFilterSummary(filters) {
  if (filters.length === 0) {
    return "No filters applied";
  }

  if (filters.length === 1) {
    return `Showing results for: ${filters[0]}`;
  }

  return `Showing results for: ${formatter.format(filters)}`;
}

const categories = ["Electronics", "Books", "Clothing"];
console.log(getFilterSummary(categories));
// "Showing results for: Electronics, Books, or Clothing"

此方法适用于分类筛选、标签选择以及任何选中值代表备选项而非组合的筛选界面。

复用格式化器以提升性能

创建 Intl.ListFormat 实例会有一定开销。建议只创建一次并复用:

// Create once at module level
const disjunctionFormatter = new Intl.ListFormat("en", { type: "disjunction" });

// Reuse in multiple functions
function formatPaymentMethods(methods) {
  return disjunctionFormatter.format(methods);
}

function formatFileTypes(types) {
  return disjunctionFormatter.format(types);
}

function formatErrorSuggestions(suggestions) {
  return disjunctionFormatter.format(suggestions);
}

对于支持多语言的应用程序,可将格式化器存储在缓存中:

const formatters = new Map();

function getDisjunctionFormatter(locale) {
  if (!formatters.has(locale)) {
    formatters.set(
      locale,
      new Intl.ListFormat(locale, { type: "disjunction" })
    );
  }
  return formatters.get(locale);
}

const formatter = getDisjunctionFormatter("en");
console.log(formatter.format(["A", "B", "C"]));
// "A, B, or C"

这种模式可以降低初始化成本,同时支持整个应用的多语言环境。

使用 formatToParts 进行自定义渲染

formatToParts() 方法会返回一个对象数组,表示格式化列表的每个部分。这样可以实现自定义样式:

const formatter = new Intl.ListFormat("en", { type: "disjunction" });
const parts = formatter.formatToParts(["PNG", "JPEG", "SVG"]);

console.log(parts);
// [
//   { type: "element", value: "PNG" },
//   { type: "literal", value: ", " },
//   { type: "element", value: "JPEG" },
//   { type: "literal", value: ", or " },
//   { type: "element", value: "SVG" }
// ]

每个部分都包含 typevaluetype 的值为 "element"(列表项)或 "literal"(标点和连接词)。

可利用此方法为元素和文本字面量分别应用不同样式:

const formatter = new Intl.ListFormat("en", { type: "disjunction" });
const formats = ["PNG", "JPEG", "SVG"];

const html = formatter.formatToParts(formats)
  .map(part => {
    if (part.type === "element") {
      return `<code>${part.value}</code>`;
    }
    return part.value;
  })
  .join("");

console.log(html);
// "<code>PNG</code>, <code>JPEG</code>, or <code>SVG</code>"

这种方式可以在自定义展示列表项的同时,保持本地化正确的标点和连接词。

浏览器支持与兼容性

自 2021 年 4 月起,所有现代浏览器均支持 Intl.ListFormat,包括 Chrome 72+、Firefox 78+、Safari 14.1+ 和 Edge 79+。

在使用该 API 前请先检查兼容性:

if (typeof Intl.ListFormat !== "undefined") {
  const formatter = new Intl.ListFormat("en", { type: "disjunction" });
  return formatter.format(items);
} else {
  // Fallback for older browsers
  return items.join(", ");
}

为获得更广泛的兼容性,可使用如 @formatjs/intl-listformat 这样的 polyfill,仅在必要时安装:

if (typeof Intl.ListFormat === "undefined") {
  await import("@formatjs/intl-listformat/polyfill");
}

const formatter = new Intl.ListFormat("en", { type: "disjunction" });

鉴于当前浏览器的支持,大多数应用可以直接使用 Intl.ListFormat,无需 polyfill。

常见错误及避免方法

使用连词类型而非析取类型会导致含义错误:

// Wrong: suggests all methods required
const wrong = new Intl.ListFormat("en", { type: "conjunction" });
console.log(`Pay with ${wrong.format(["credit card", "debit card"])}`);
// "Pay with credit card and debit card"

// Correct: suggests choosing one method
const correct = new Intl.ListFormat("en", { type: "disjunction" });
console.log(`Pay with ${correct.format(["credit card", "debit card"])}`);
// "Pay with credit card or debit card"

重复创建新的格式化器会浪费资源:

// Inefficient
function formatOptions(options) {
  return new Intl.ListFormat("en", { type: "disjunction" }).format(options);
}

// Efficient
const formatter = new Intl.ListFormat("en", { type: "disjunction" });
function formatOptions(options) {
  return formatter.format(options);
}

在字符串中硬编码 "or" 会阻碍本地化:

// Breaks in other languages
const text = items.join(", ") + ", or other options";

// Works across languages
const formatter = new Intl.ListFormat(userLocale, { type: "disjunction" });
const allItems = [...items, "other options"];
const text = formatter.format(allItems);

未处理空数组可能导致意外输出:

// Defensive
function formatPaymentMethods(methods) {
  if (methods.length === 0) {
    return "No payment methods available";
  }
  return formatter.format(methods);
}

虽然 format([]) 会返回空字符串,但显式处理空状态能提升用户体验。

何时使用析取列表

当需要展示备选项或选择项,且通常只适用一个选项时,应使用析取列表。这包括支付方式选择、文件格式限制、认证错误建议、搜索筛选选项和账户类型选择等场景。

当所有项目都必须同时适用时,不要使用析取列表,应使用连词列表。例如,“姓名、邮箱和密码为必填项”使用连词,因为所有字段都必须填写,而不是只选一个。

对于没有选择含义的中性枚举,不要使用析取列表。测量值和技术规格通常使用单位列表,而不是析取或连词列表。

该 API 替代了手动拼接字符串的方式来处理备选项。每当你需要在面向用户的文本中用 "or" 拼接项目时,请考虑使用析取类型的 Intl.ListFormat,以获得更好的本地化支持。