如何在 JavaScript 中使用“或”格式化列表

使用 Intl.ListFormat 的析取类型以正确格式化任何语言中的替代选项

介绍

应用程序通常会向用户提供选择或替代项。例如,文件上传组件接受 "PNG、JPEG 或 SVG" 文件;支付表单允许使用 "信用卡、借记卡或 PayPal" 作为支付方式;错误消息建议修复 "用户名、密码或电子邮件地址" 以解决身份验证失败。

这些列表使用 "或" 来表示替代项。通过字符串拼接手动格式化它们在其他语言中会出错,因为不同语言有不同的标点规则、不同的 "或" 的表达方式以及不同的逗号放置习惯。使用带有 disjunction 类型的 Intl.ListFormat API,可以为任何语言正确格式化这些替代列表。

什么是析取列表

析取列表表示替代项,通常只有一个选项适用。"析取" 一词的意思是分离或替代。在英语中,析取列表使用 "or" 作为连接词:

const paymentMethods = ["credit card", "debit card", "PayPal"];
// 期望输出:"credit card, debit card, or PayPal"

这与使用 "and" 表示所有项目一起适用的合取列表不同。析取列表传达选择,合取列表传达组合。

析取列表的常见上下文包括支付选项、文件格式限制、故障排除建议、搜索过滤器替代项以及任何用户从多个可能性中选择一个选项的界面。

为什么手动格式化会失败

英语使用者将析取列表写为 "A, B, or C",在项目之间使用逗号,并在最后一个项目前使用 "or"。这种模式在其他语言中会出错:

// 硬编码的英语模式
const items = ["apple", "orange", "banana"];
const text = items.slice(0, -1).join(", ") + ", or " + items[items.length - 1];
// "apple, orange, or banana"

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

西班牙语使用 "o",且前面没有逗号:

期望:"manzana, naranja o plátano"
英语模式生成:"manzana, naranja, or plátano"

法语使用 "ou",且前面没有逗号:

期望:"pomme, orange ou banane"
英语模式生成:"pomme, orange, or banane"

德语使用 "oder",且前面没有逗号:

期望:"Apfel, Orange oder Banane"
英语模式生成:"Apfel, Orange, or Banane"

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

期望:"りんご、オレンジ、またはバナナ"
英语模式生成:"りんご、オレンジ、 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 选项控制格式化的详细程度。存在三种样式:longshortnarrow。默认样式为 long

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 如何适应每种语言的语法和标点惯例。当您提供适当的语言环境时,相同的代码可以在所有语言中正常工作。

格式化支付选项

支付表单会提供多种支付方式的选择。可以使用析取列表来格式化它们:

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."

对于国际化应用程序,可以传递用户的语言环境:

const userLocale = navigator.language; // 例如 "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 "不接受任何文件格式";
  }

  if (formats.length === 1) {
    return `接受的格式:${formats[0]}`;
  }

  return `接受的格式:${formatter.format(formats)}`;
}

const imageFormats = ["PNG", "JPEG", "SVG", "WebP"];
console.log(getAcceptedFormatsMessage(imageFormats));
// "接受的格式:PNG, JPEG, SVG 或 WebP"

const documentFormats = ["PDF", "DOCX"];
console.log(getAcceptedFormatsMessage(documentFormats));
// "接受的格式:PDF 或 DOCX"

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

格式化故障排除建议

错误消息通常会提供多种解决问题的方法。将这些建议呈现为分离列表:

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

function getAuthenticationError(missingFields) {
  if (missingFields.length === 0) {
    return "身份验证失败";
  }

  return `请检查您的 ${formatter.format(missingFields)} 并重试。`;
}

console.log(getAuthenticationError(["username", "password"]));
// "请检查您的 username 或 password 并重试。"

console.log(getAuthenticationError(["email", "username", "password"]));
// "请检查您的 email, username 或 password 并重试。"

分离列表明确表示用户需要修复提到的任意字段,而不是全部字段。

格式化搜索过滤器替代项

搜索界面显示活动过滤器。当过滤器提供替代选项时,使用分离列表:

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

function getFilterSummary(filters) {
  if (filters.length === 0) {
    return "未应用任何过滤器";
  }

  if (filters.length === 1) {
    return `显示结果:${filters[0]}`;
  }

  return `显示结果:${formatter.format(filters)}`;
}

const categories = ["Electronics", "Books", "Clothing"];
console.log(getFilterSummary(categories));
// "显示结果:Electronics, Books 或 Clothing"

此方法适用于类别过滤器、标签选择以及任何过滤器界面,其中所选值表示替代项而非组合。

复用格式化器以提升性能

创建 Intl.ListFormat 实例会有一定的开销。建议一次性创建格式化器并复用它们:

// 在模块级别创建一次
const disjunctionFormatter = new Intl.ListFormat("en", { type: "disjunction" });

// 在多个函数中复用
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>"

这种方法在保持符合语言习惯的标点符号和连接词的同时,对实际的列表项应用了自定义的展示样式。

浏览器支持和兼容性

Intl.ListFormat 自 2021 年 4 月起在所有现代浏览器中均可使用。支持的版本包括 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 {
  // 旧版浏览器的回退方案
  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。

常见错误及避免方法

使用 conjunction 类型而非 disjunction 会导致含义错误:

// 错误:表示需要所有方法
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"

// 正确:表示选择一种方法
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"

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

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

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

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

// 在其他语言中会出错
const text = items.join(", ") + ", or other options";

// 在多语言中可用
const formatter = new Intl.ListFormat(userLocale, { type: "disjunction" });
const allItems = [...items, "other options"];
const text = formatter.format(allItems);

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

// 防御性处理
function formatPaymentMethods(methods) {
  if (methods.length === 0) {
    return "没有可用的支付方式";
  }
  return formatter.format(methods);
}

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

何时使用析取列表

在呈现替代选项或选择时使用析取列表,通常仅适用于一个选项。这包括支付方式选择、文件格式限制、身份验证错误建议、搜索过滤器选项以及账户类型选择。

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

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

API 替代了手动字符串拼接模式以表示替代选项。任何时候您需要为面向用户的文本编写代码,将项目用“或”连接时,请考虑使用 Intl.ListFormat 的析取类型以提供更好的本地化支持。