获取某个语言环境下的所有复数形式

了解需要为哪些复数类别提供翻译

简介

在构建多语言应用时,你需要针对不同数量提供不同的文本形式。在英文中,你会写“1 item”和“2 items”。这看起来很简单,但当你开始支持其他语言时,情况就变得复杂了。

俄语根据数量有三种不同的形式。阿拉伯语有六种。有些语言对所有数量都使用相同的形式。在为这些形式提供翻译之前,你需要先了解每种语言中存在哪些复数形式。

JavaScript 提供了一种方法,可以发现某个语言环境使用了哪些复数类别。resolvedOptions() 方法在 PluralRules 实例上会返回一个 pluralCategories 属性,列出了该语言环境所需的所有复数形式。这样你就能准确知道需要为哪些类别提供翻译,无需猜测或维护特定语言的规则表。

什么是复数类别

复数类别是跨语言标准化的不同复数形式的名称。Unicode CLDR(通用语言环境数据存储库)定义了六个类别:zero、one、two、few、many 和 other。

并非每种语言都使用这六个类别。英语只用到两个:one 和 other。类别 one 适用于数量 1,other 适用于其他所有数量。

阿拉伯语使用全部六个类别。zero 适用于 0,one 适用于 1,two 适用于 2,few 适用于 3-10,many 适用于 11-99,other 适用于 100 及以上。

俄语有三种类别:one 适用于以 1 结尾的数量(但不包括 11),few 适用于以 2-4 结尾的数量(但不包括 12-14),many 适用于其他所有情况。

日语和中文只使用 other 类别,因为这些语言不区分单数和复数形式。

这些类别代表了每种语言的语言规则。在提供翻译时,您需要为该语言实际使用的每个类别创建一个字符串。

使用 resolvedOptions 获取复数类别

resolvedOptions() 方法在 PluralRules 实例上调用时,会返回一个包含规则信息的对象,其中包括该语言环境使用的复数类别。

const enRules = new Intl.PluralRules('en-US');
const options = enRules.resolvedOptions();

console.log(options.pluralCategories);
// Output: ["one", "other"]

pluralCategories 属性是一个字符串数组。每个字符串都是六个标准类别名称之一。该数组只包含该语言环境实际使用的类别。

对于英语,数组包含 one 和 other,因为英语区分单数和复数形式。

对于规则更复杂的语言,数组会包含更多类别:

const arRules = new Intl.PluralRules('ar-EG');
const options = arRules.resolvedOptions();

console.log(options.pluralCategories);
// Output: ["zero", "one", "two", "few", "many", "other"]

阿拉伯语使用全部六个类别,因此数组包含所有六个值。

查看不同语言环境的复数类别

不同语言有不同的复数规则,这意味着它们使用的类别集合也不同。可以对比几种语言,了解它们的差异:

const locales = ['en-US', 'ar-EG', 'ru-RU', 'pl-PL', 'ja-JP', 'zh-CN'];

locales.forEach(locale => {
  const rules = new Intl.PluralRules(locale);
  const categories = rules.resolvedOptions().pluralCategories;
  console.log(`${locale}: [${categories.join(', ')}]`);
});

// Output:
// en-US: [one, other]
// ar-EG: [zero, one, two, few, many, other]
// ru-RU: [one, few, many, other]
// pl-PL: [one, few, many, other]
// ja-JP: [other]
// zh-CN: [other]

英语有两个类别。阿拉伯语有六个。俄语和波兰语各有四个。日语和中文只有一个,因为它们完全不区分复数形式。

这种差异说明,不能假设所有语言都像英语一样。您需要检查每个语言环境实际使用的类别,并为每个类别提供合适的翻译。

理解每个语言环境中类别的含义

同一个类别名称在不同语言中含义不同。英语中的 one 类别只适用于数字 1。而在俄语中,one 适用于以 1 结尾但不包括 11 的数字,因此包括 1、21、31、101 等。

测试不同语言环境下数字对应的类别:

const enRules = new Intl.PluralRules('en-US');
const ruRules = new Intl.PluralRules('ru-RU');

const numbers = [0, 1, 2, 3, 5, 11, 21, 22, 100];

console.log('English:');
numbers.forEach(n => {
  console.log(`  ${n}: ${enRules.select(n)}`);
});

console.log('Russian:');
numbers.forEach(n => {
  console.log(`  ${n}: ${ruRules.select(n)}`);
});

// Output:
// English:
//   0: other
//   1: one
//   2: other
//   3: other
//   5: other
//   11: other
//   21: other
//   22: other
//   100: other
// Russian:
//   0: many
//   1: one
//   2: few
//   3: few
//   5: many
//   11: many
//   21: one
//   22: few
//   100: many

在 English 中,只有 1 属于 one 类别。在 Russian 中,1 和 21 都属于 one,因为它们以 1 结尾。数字 2、3 和 22 属于 few,因为它们以 2-4 结尾。数字 0、5、11 和 100 属于 many。

这说明如果不了解语言规则,就无法预测某个数字属于哪个类别。pluralCategories 数组会告诉你有哪些类别,select() 方法会告诉你每个数字对应的类别。

获取序数的类别

像 1st、2nd、3rd 这样的序数有自己不同于基数的复数规则。可以通过用 type: 'ordinal' 创建 PluralRules 实例来获取序数的类别:

const enCardinalRules = new Intl.PluralRules('en-US', { type: 'cardinal' });
const enOrdinalRules = new Intl.PluralRules('en-US', { type: 'ordinal' });

console.log('Cardinal:', enCardinalRules.resolvedOptions().pluralCategories);
// Output: Cardinal: ["one", "other"]

console.log('Ordinal:', enOrdinalRules.resolvedOptions().pluralCategories);
// Output: Ordinal: ["one", "two", "few", "other"]

English 的基数只用两个类别。English 的序数用四个类别,因为序数需要区分 1st、2nd、3rd 和其他所有情况。

序数类别对应序数词后缀:

const enOrdinalRules = new Intl.PluralRules('en-US', { type: 'ordinal' });

const numbers = [1, 2, 3, 4, 11, 21, 22, 23];

numbers.forEach(n => {
  const category = enOrdinalRules.select(n);
  console.log(`${n}: ${category}`);
});

// Output:
// 1: one
// 2: two
// 3: few
// 4: other
// 11: other
// 21: one
// 22: two
// 23: few

类别 one 对应 st 后缀(1st、21st),two 对应 nd(2nd、22nd),few 对应 rd(3rd、23rd),other 对应 th(4th、11th)。

不同语言有不同的序数类别:

const locales = ['en-US', 'es-ES', 'fr-FR'];

locales.forEach(locale => {
  const rules = new Intl.PluralRules(locale, { type: 'ordinal' });
  const categories = rules.resolvedOptions().pluralCategories;
  console.log(`${locale}: [${categories.join(', ')}]`);
});

// Output:
// en-US: [one, two, few, other]
// es-ES: [other]
// fr-FR: [one, other]

Spanish 只有一个序数类别,因为 Spanish 的序数规则更简单。French 有两个类别,用于区分第一和其他所有位置。

使用复数类别构建翻译映射

了解某个 locale 使用哪些类别后,就可以构建一个包含恰好所需条目的翻译映射:

function buildTranslationMap(locale, translations) {
  const rules = new Intl.PluralRules(locale);
  const categories = rules.resolvedOptions().pluralCategories;

  const map = new Map();

  categories.forEach(category => {
    if (translations[category]) {
      map.set(category, translations[category]);
    } else {
      console.warn(`Missing translation for category "${category}" in locale "${locale}"`);
    }
  });

  return map;
}

const enTranslations = {
  one: 'item',
  other: 'items'
};

const arTranslations = {
  zero: 'لا توجد عناصر',
  one: 'عنصر واحد',
  two: 'عنصران',
  few: 'عناصر',
  many: 'عنصرًا',
  other: 'عنصر'
};

const enMap = buildTranslationMap('en-US', enTranslations);
const arMap = buildTranslationMap('ar-EG', arTranslations);

console.log(enMap);
// Output: Map(2) { 'one' => 'item', 'other' => 'items' }

console.log(arMap);
// Output: Map(6) { 'zero' => 'لا توجد عناصر', 'one' => 'عنصر واحد', ... }

此函数会检查您是否为所有必需的类别提供了翻译,并在缺少时发出警告。这可以防止在使用某个类别但没有对应翻译时出现运行时错误。

校验翻译完整性

在部署到生产环境前,使用复数类别来验证您的翻译是否包含所有必要的形式:

function validateTranslations(locale, translations) {
  const rules = new Intl.PluralRules(locale);
  const requiredCategories = rules.resolvedOptions().pluralCategories;
  const providedCategories = Object.keys(translations);

  const missing = requiredCategories.filter(cat => !providedCategories.includes(cat));
  const extra = providedCategories.filter(cat => !requiredCategories.includes(cat));

  if (missing.length > 0) {
    console.error(`Locale ${locale} is missing categories: ${missing.join(', ')}`);
    return false;
  }

  if (extra.length > 0) {
    console.warn(`Locale ${locale} has unused categories: ${extra.join(', ')}`);
  }

  return true;
}

const enTranslations = {
  one: 'item',
  other: 'items'
};

const incompleteArTranslations = {
  one: 'عنصر واحد',
  other: 'عنصر'
};

validateTranslations('en-US', enTranslations);
// Output: true

validateTranslations('ar-EG', incompleteArTranslations);
// Output: Locale ar-EG is missing categories: zero, two, few, many
// Output: false

这种校验可以在开发阶段发现缺失的翻译,而不是等到用户遇到未翻译文本时才发现。

构建动态翻译界面

为译者开发工具时,可以查询复数类别,准确显示需要翻译的所有形式:

function generateTranslationForm(locale, key) {
  const rules = new Intl.PluralRules(locale);
  const categories = rules.resolvedOptions().pluralCategories;

  const form = document.createElement('div');
  form.className = 'translation-form';

  const heading = document.createElement('h3');
  heading.textContent = `Translate "${key}" for ${locale}`;
  form.appendChild(heading);

  categories.forEach(category => {
    const label = document.createElement('label');
    label.textContent = `${category}:`;

    const input = document.createElement('input');
    input.type = 'text';
    input.name = `${key}.${category}`;
    input.placeholder = `Enter ${category} form`;

    const wrapper = document.createElement('div');
    wrapper.appendChild(label);
    wrapper.appendChild(input);
    form.appendChild(wrapper);
  });

  return form;
}

const enForm = generateTranslationForm('en-US', 'items');
const arForm = generateTranslationForm('ar-EG', 'items');

document.body.appendChild(enForm);
document.body.appendChild(arForm);

这样会为每个语言环境生成正确数量的输入框。例如,英语有两个输入框(one 和 other),而阿拉伯语有六个(zero、one、two、few、many 和 other)。

跨语言环境对比类别

在管理多语言翻译时,可以对比各语言环境使用的类别,以了解翻译的复杂度:

function compareLocalePluralCategories(locales) {
  const comparison = {};

  locales.forEach(locale => {
    const rules = new Intl.PluralRules(locale);
    const categories = rules.resolvedOptions().pluralCategories;
    comparison[locale] = categories;
  });

  return comparison;
}

const locales = ['en-US', 'es-ES', 'ar-EG', 'ru-RU', 'ja-JP'];
const comparison = compareLocalePluralCategories(locales);

console.log(comparison);
// Output:
// {
//   'en-US': ['one', 'other'],
//   'es-ES': ['one', 'other'],
//   'ar-EG': ['zero', 'one', 'two', 'few', 'many', 'other'],
//   'ru-RU': ['one', 'few', 'many', 'other'],
//   'ja-JP': ['other']
// }

这表明英语和西班牙语拥有相同的复数类别,因此可以轻松复用翻译结构。而阿拉伯语需要更多的翻译工作,因为它包含六种类别。

检查某语言环境是否使用特定类别

在代码中使用某个复数类别前,先检查该语言环境是否实际支持该类别:

function localeUsesCategory(locale, category) {
  const rules = new Intl.PluralRules(locale);
  const categories = rules.resolvedOptions().pluralCategories;
  return categories.includes(category);
}

console.log(localeUsesCategory('en-US', 'zero'));
// Output: false

console.log(localeUsesCategory('ar-EG', 'zero'));
// Output: true

console.log(localeUsesCategory('ja-JP', 'one'));
// Output: false

这样可以避免假设每个语言环境都有 zero 或 one 类别。请使用此检查安全地实现类别相关逻辑。

理解 other 类别

每种语言都会用到 other 类别。当没有其他类别适用时,other 作为默认情况。

在 English 中,other 覆盖除 1 以外的所有数量。在阿拉伯语中,other 用于像 100 及以上这样的较大数字。在日语中,other 覆盖所有数量,因为日语不区分复数形式。

始终为 other 类别提供翻译。该类别在每个语言环境中都一定存在,并且会在没有更具体类别匹配时使用。

const locales = ['en-US', 'ar-EG', 'ru-RU', 'ja-JP'];

locales.forEach(locale => {
  const rules = new Intl.PluralRules(locale);
  const categories = rules.resolvedOptions().pluralCategories;
  const hasOther = categories.includes('other');
  console.log(`${locale} uses "other": ${hasOther}`);
});

// Output:
// en-US uses "other": true
// ar-EG uses "other": true
// ru-RU uses "other": true
// ja-JP uses "other": true

汇总所有已解析选项

resolvedOptions() 方法返回的不仅仅是复数类别。它还包含有关 locale、type 和数字格式化选项的信息:

const rules = new Intl.PluralRules('de-DE', {
  type: 'cardinal',
  minimumFractionDigits: 2,
  maximumFractionDigits: 2
});

const options = rules.resolvedOptions();

console.log(options);
// Output:
// {
//   locale: 'de-DE',
//   type: 'cardinal',
//   pluralCategories: ['one', 'other'],
//   minimumIntegerDigits: 1,
//   minimumFractionDigits: 2,
//   maximumFractionDigits: 2,
//   minimumSignificantDigits: undefined,
//   maximumSignificantDigits: undefined
// }

pluralCategories 属性是已解析选项对象中的一项信息。其他属性会告诉你 PluralRules 实例实际使用的配置,包括所有被设置为默认值的选项。

为性能缓存复数类别

创建 PluralRules 实例并调用 resolvedOptions() 是有开销的。请为每个 locale 缓存结果,而不是重复查询:

const categoriesCache = new Map();

function getPluralCategories(locale, type = 'cardinal') {
  const key = `${locale}:${type}`;

  if (categoriesCache.has(key)) {
    return categoriesCache.get(key);
  }

  const rules = new Intl.PluralRules(locale, { type });
  const categories = rules.resolvedOptions().pluralCategories;

  categoriesCache.set(key, categories);

  return categories;
}

const enCardinal = getPluralCategories('en-US', 'cardinal');
const enOrdinal = getPluralCategories('en-US', 'ordinal');
const arCardinal = getPluralCategories('ar-EG', 'cardinal');

console.log('en-US cardinal:', enCardinal);
console.log('en-US ordinal:', enOrdinal);
console.log('ar-EG cardinal:', arCardinal);

// Subsequent calls use cached results
const enCardinal2 = getPluralCategories('en-US', 'cardinal');
// No new PluralRules instance created

这种模式在格式化大量复数字符串或支持多语言环境的应用中尤为重要。

浏览器支持与兼容性

pluralCategories 属性在 resolvedOptions() 上于 2020 年被添加到 JavaScript 中。它在 Chrome 106+、Firefox 116+、Safari 15.4+ 和 Edge 106+ 中受支持。

支持 Intl.PluralRules 但不支持 pluralCategories 的旧版浏览器会为该属性返回 undefined。在使用前请检查其是否存在:

function getPluralCategories(locale) {
  const rules = new Intl.PluralRules(locale);
  const options = rules.resolvedOptions();

  if (options.pluralCategories) {
    return options.pluralCategories;
  }

  // Fallback for older browsers
  return ['one', 'other'];
}

这种回退假设了一个简单的两类别系统,这适用于 English 及许多欧洲语言,但对于规则更复杂的语言可能不正确。为更好的兼容性,请提供特定语言的回退方案或使用 polyfill。