Pull to refresh

API для интернационализации JavaScript: реализация в Firefox

Reading time11 min
Views12K
Original author: Jeff Walden

Что такое интернационализация?


Интернационализация (internationalization, а для краткости — i18n, то бишь i, ещё 18 букв и n; по-русски это получится и17я) – такой способ создания приложений, при котором их можно легко адаптировать для разных аудиторий, говорящих на разных языках. Очень легко ошибиться, предполагая, что все ваши пользователи происходят из одной местности и пользуются одним языком – особенно, если вы даже не задумываетесь о том, что предполагаете именно это.

function formatDate(d)
{
  // Все же пишут дату, как месяц/день/год. Правда ведь?
  var month = d.getMonth() + 1;
  var date = d.getDate();
  var year = d.getFullYear();
  return month + "/" + date + "/" + year;
}
 
function formatMoney(amount)
{
  // Все деньги – это доллары, с двумя знаками после запятой. Ведь так?
  return "$" + amount.toFixed(2);
}
 
function sortNames(names)
{
  function sortAlphabetically(a, b)
  {
    var left = a.toLowerCase(), right = b.toLowerCase();
    if (left > right)
      return 1;
    if (left === right)
      return 0;
    return -1;
  }
 
  // Имена всегда сортируются по алфавиту, не так ли?
  names.sort(sortAlphabetically);
}


Исторически поддержка i18n в JavaScript сделана плохо. Для форматирования с поддержкой и17и используются методы toLocaleString(). Итоговые строки содержат те детали, которые предоставляет конкретная реализация языка – нет возможности выбрать (нужен ли день недели в дате? а год важен, или нет?). Даже если включены все детали, формат может быть неверным – десятичный вместо процентов, и т.д. И локаль выбирать нельзя.

При сортировке предлагается почти бесполезное сравнение текста с учётом локали (collation). Существует localeCompare(), но с неудобным интерфейсом, неподходящим для сортировки. И у неё тоже нельзя выбирать локаль.

Эти ограничения настолько сложны, что серьёзные приложения отправляют данные на сервер, чтобы там произвести нужную операцию, чувствительную к локали, и затем получают результат. Передача данных на сервер и обратно, только для форматирования денежных сумм. Бред.

Новое API для и17и JS


Новое ECMAScript Internationalization API увеличивает возможности JS. Предоставляются все навороты для форматирования дат и чисел и сортировки текста. Локаль можно выбирать, а в целях быстродействия это можно сделать единожды, а не каждый раз перед операцией.

Но это API не панацея, а в лучшем случае «хорошая попытка». Точный формат вывода не задан. Реализация может поддерживать экзотические языки, или же просто игнорировать все параметры форматирования. Большинство реализаций будут поддерживать много локалей, но без всяких гарантий.

Реализация Firefox зависит от библиотеки International Components for Unicode (ICU), которая сама зависит от Unicode Common Locale Data Repository (CLDR). При этом большинство функций ICU написаны на JavaScript.

Интерфейс Intl


API и17и живёт в объекте Intl. Он содержит три конструктора: Intl.Collator, Intl.DateTimeFormat и Intl.NumberFormat. Создание объекта происходит так:

var ctor = "Collator"; // или другой из трёх
var instance = new Intl[ctor](locales, options);


locales – строка, задающая тэг язык или объект, содержащий несколько тэгов языков. Тэг – строчки типа en (английский), de-AT (австрийский немецкий) или zh-Hant-TW (тайваньский китайский в традиционной записи). Тэги могут включать расширение юникоде в виде -u-key1-value1-key2-value2..., где каждый ключ – ключ расширения. Разные конструкторы по-разному это интерпретируют.

options – объект, чьи свойства определяют форматирование и сортировку.

Firefox поддерживает более 400 локалей для сортировки и более 600 для форматирования – так что, скорее всего нужная локаль найдётся.

Intl не гарантирует конкретного поведения. Если запрошенная локаль не поддерживается, Intl пытается наилучшим образом обработать запрос. Если поддерживается, то поведение его не задано жёстко. Никогда нельзя подразумевать, что конкретный набор настроек соответствует конкретному формату. Это может меняться от браузера к браузеру или от версии к версии. Не заданы компоненты форматирования – краткая запись для дня недели может быть “S”, “Sa” или “Sat”.

Форматирование даты и времени


Настройки

weekday, era
    "narrow", "short", or "long". (era – промежутки длиннее года: BC/AD, время правления императора Японии, и т.д.)
month
    "2-digit", "numeric", "narrow", "short" или "long"
year
day
hour, minute, second
    "2-digit" или "numeric"
timeZoneName
    "short" или "long"
timeZone
    Регистронезависимое "UTC" задаёт форматирование с учётом UTC. Значения типа "CEST" и "America/New_York" не обязаны обрабатываться, и пока не поддерживаются в Firefox.


Точный формат не задаётся, но смысл в том, что «narrow», «short» и «long» выдают результаты разной длины — “S” или “Sa”, “Sat” и “Saturday”. Вывод может быть двусмысленным Saturday и Sunday в коротком виде могут выдать «S». «2-digit» и «numeric» означают двузначную или полноразмерную запись дат: “70” и “1970”.

Есть и особенные настройки:
hour12
12-часовой или 24-часовой формат. Обычно зависит от локали. От неё же зависят детали вроде того, как записывать полночь – 0 часов или 12pm, и надо ли писать ведущий нуль.


Есть два особых свойства localeMatcher (значения «lookup» или «best fit») и formatMatcher («basic» или «best fit»), по умолчанию оба имеют значения «best fit». Задают то, как используются локаль и форматирование. Они используются очень редко и их можно игнорировать.

Настройки, связанные с локалью

DateTimeFormat разрешает форматирование при помощи настраиваемых календарных и числовых систем. Эти детали задаются в тэге языка в настройках Unicode-расширения.

Например, тэг тайского языка в Таиланде th-TH. Формат Unicode-расширения -u-key1-value1-key2-value2… Ключ календарной системы – ca, числовой – nu. У числовой системы Таиланда значение будет thai, а у китайской – Chinese. Поэтому для форматирования дат мы присоединяем эти расширения в конец тэга языка: th-TH-u-ca-chinese-nu-thai.

Подробности читайте в документации.

Примеры

После создания объекта DateTimeFormat надо использовать его при помощи функции format(). Это связанная функция, так что не нужно вызывать её непосредственно. Ей передаётся временная метка или объект Date.

var msPerDay = 24 * 60 * 60 * 1000;
 
// July 17, 2014 00:00:00 UTC.
var july172014 = new Date(msPerDay * (44 * 365 + 11 + 197));


Давайте отформатируем дату для американского английского. Включим двузначные месяц/день/год, часы/минуты и временную зону в короткой записи.

var options =
  { year: "2-digit", month: "2-digit", day: "2-digit",
    hour: "2-digit", minute: "2-digit",
    timeZoneName: "short" };
var americanDateTime =
  new Intl.DateTimeFormat("en-US", options).format;
 
print(americanDateTime(july172014)); // 07/16/14, 5:00 PM PDT


Теперь сделаем то же для португальского бразильского и для португальского в Португалии. Формат сделаем подлиннее, с полной записью года и названием месяца, но в зоне UTC.

var options =
  { year: "numeric", month: "long", day: "numeric",
    hour: "2-digit", minute: "2-digit",
    timeZoneName: "short", timeZone: "UTC" };
var portugueseTime =
  new Intl.DateTimeFormat(["pt-BR", "pt-PT"], options);
 
// 17 de julho de 2014 00:00 GMT
print(portugueseTime.format(july172014));


Компактное расписание швейцарских поездов для UTC с использованием официальных языков, перечислив от самого популярного до наименее популярного:

var swissLocales = ["de-CH", "fr-CH", "it-CH", "rm-CH"];
var options =
  { weekday: "short",
    hour: "numeric", minute: "numeric",
    timeZone: "UTC", timeZoneName: "short" };
var swissTime =
  new Intl.DateTimeFormat(swissLocales, options).format;
 
print(swissTime(july172014)); // Do. 00:00 GMT


Попробуем вывести дату по-японски, с использованием японского календаря с годом и эрой:

var jpYearEra =
  new Intl.DateTimeFormat("ja-JP-u-ca-japanese",
                          { year: "numeric", era: "long" });
 
print(jpYearEra.format(july172014)); // 平成26年


А теперь – длинная дата для Таиланда, с использованием тайских цифр и китайского календаря:

var options =
  { year: "numeric", month: "long", day: "numeric" };
var thaiDate =
  new Intl.DateTimeFormat("th-TH-u-nu-thai-ca-chinese", options);
 
print(thaiDate.format(july172014)); // ๒๐ 6 ๓๑


Форматирование чисел


Настройки

Основные настройки для форматирования чисел следующие:

style
    "currency", "percent" или "decimal" (по умолчанию) для форматирования значения
currency
    трёхбуквенное обозначение валюты USD или CHF. Обязательно, если style = "currency", иначе смысла не имеет.
currencyDisplay
    "code", "symbol" или "name", по умолчанию "symbol". "code" использует трёхбуквенный код, "symbol" использует символ типа $ или £. "name" использует название валюты. minimumIntegerDigits
    целое от 1 до 21 (включительно), по умолчанию 1. По необходимости добавляются лидирующие нули.
minimumFractionDigits, maximumFractionDigits
    целое от 0 до 20 (включ). В строке будет как минимум minimumFractionDigits, и не более maximumFractionDigits знаков после запятой. По умолчанию – зависит от валюты (обычно 2, иногда 0 или 3) если style = "currency", иначе 0. Для процентов максимум 0, 3 для десятичных чисел, а для валют – в зависимости от валюты.
minimumSignificantDigits, maximumSignificantDigits
    целые от 1 до 21 (включительно). Если указано, имеет преимущество перед предыдущими настройками количества цифр, и определяет минимум и максимум значимых цифр.
useGrouping
    булево значение, по умолчанию true. Определяет наличие в строке групповых разделителей (как , для разделения тысяч в английском формате).


Настройки локали

NumberFormat поддерживает настраиваемое форматирование чисел по ключу nu, точно так же, как это делает DateTimeFormat. К примеру, языковой тэг для китайского — zh-CN. Система записи чисел Han задаётся как hanidec. Чтобы отформатировать число для этой системы, мы прицепляем Unicode-расширение в виде тэга: zh-CN-u-nu-hanidec.

Полное описание возможностей см. в документации.

Примеры

Для начала, отформатируем валюты для китайского языка с использованием числовой записи Han. Выберите стиль «currency», затем используйте код для китайского renminbi (yuan), grouping by default, with the usual number of fractional digits.

var hanDecimalRMBInChina =
  new Intl.NumberFormat("zh-CN-u-nu-hanidec",
                        { style: "currency", currency: "CNY" });
 
print(hanDecimalRMBInChina.format(1314.25)); // ¥ 一,三一四.二五


Теперь отформатируем стоимость бензина по правилам США и UK

var gasPrice =
  new Intl.NumberFormat("en-US",
                        { style: "currency", currency: "USD",
                          minimumFractionDigits: 3 });
 
print(gasPrice.format(5.259)); // $5.259


Попробуем арабские проценты для использования в Египте. Убедимся, что у них есть минимум два знака после запятой. Порядок цифр может быть другой в системах с записью справа налево.

var arabicPercent =
  new Intl.NumberFormat("ar-EG",
                        { style: "percent",
                          minimumFractionDigits: 2 }).format;
 
print(arabicPercent(0.438)); // ٤٣٫٨٠٪


Теперь – персидский язык, используемый в Афганистане. Минимум две цифры в целой части и не более двух в дробной.

var persianDecimal =
  new Intl.NumberFormat("fa-AF",
                        { minimumIntegerDigits: 2,
                          maximumFractionDigits: 2 });
 
print(persianDecimal.format(3.1416)); // ۰۳٫۱۴


И наконец, выведем количество бахрейнских динар по-арабски. Эти динары нехарактерно делятся на тысячные, поэтому у нас должно быть три знака после запятой.

var bahrainiDinars =
  new Intl.NumberFormat("ar-BH",
                        { style: "currency", currency: "BHD" });
 
print(bahrainiDinars.format(3.17)); // د.ب.‏ ٣٫١٧٠


Сортировка


Настройки

usage
    "sort" или "search" (по умолчанию "sort")
    "base", "accent", "case" или "variant". Чувствительность в тех случаях, когда одна и та же основная буква имеет акценты, диакритические знаки и регистр. Причём "базовость" буквы зависит от локали – в немецком у букв “a” and “ä” базовая буква одна, а в шведском – разные. В случае "base" рассматривается только базовая буква (в случае немецкого языка “a”, “A” и “ä” будут одинаковыми). "accent" рассматривает базовую букву и акцент, без учёта регистра (“a” и “A” будут одинаковыми, а “ä” будет от них отличаться). "case" рассматривает базовую букву и регистр, игнорируя акцент (“a” и “ä” будут одинаковые, а “A” будет отличаться). Наконец, "variant" отличает все особенности букв. При использовании "sort" по умолчанию используется "variant"; иначе – в зависимости от локали.
numeric
  булево значение, определяет сортировку чисел по значению или по символам цифр. То есть, в случае numeric последовательность будет "F-4 Phantom II", "F-14 Tomcat", "F-35 Lightning II"; в случае не numeric последовательность будет "F-14 Tomcat", "F-35 Lightning II", "F-4 Phantom II".
caseFirst
    "upper", "lower" или "false" (по умолчанию). Как учитывается регистр при сортировке -  "upper" означает преимущество верхнего регистра ("B", "a", "c"), "lower" наоборот ("a", "c", "B") и "false" игнорирует регистр ("a", "B", "c").
ignorePunctuation
  булево значение, false по умолчанию, определяет, надо ли игнорировать пунктуацию при сравнениях (тогда например "biweekly" и "bi-weekly" будут равны).


Настройки локали

Настройка сортировки в Unicode-расширении задаётся как co, и задаёт тип сортировки – адресная книга (phonebk), словарь (dict), и другие.

Дополнительно ключи kn и kf могут дублировать свойства numeric и caseFirst объекта options. Но их поддержка не гарантирована, поэтому лучше их не использовать.

Примеры

У объектов Collator есть функция compare. Она принимает аргументы x и y и возвращает число меньше нуля, если x < y, 0 если x = y, и число больше нуля, если x > y.

Попробуем сортировать немецкие имена. В Германии есть две разных последовательности сортировки – адресная книга и словарь. Первая основана на произношении, когда “ä”, “ö” и прочие раскрываются как “ae”, “oe” и т.д.

var names =
  ["Hochberg", "Hönigswald", "Holzman"];
 
var germanPhonebook = new Intl.Collator("de-DE-u-co-phonebk");
 
// последовательность ["Hochberg", "Hoenigswald", "Holzman"]:
//   Hochberg, Hönigswald, Holzman
print(names.sort(germanPhonebook.compare).join(", "));


Некоторые слова соединяются умляутами, поэтому в словарях их имеет смысл сортировать с игнорированием умляутов (кроме случаев, когда слова отличаются только умляутами, например schon перед schön).

var germanDictionary = new Intl.Collator("de-DE-u-co-dict");
 
// последовательность ["Hochberg", "Honigswald", "Holzman"]:
//   Hochberg, Holzman, Hönigswald
print(names.sort(germanDictionary.compare).join(", "));


Отсортируем версии Firefox, указанные с разными ошибками, случайными акцентами и диакритиками, по правилам американского английского языка. Учитываем номер версии и сортируем по значению числа, а не по символам цифр.

var firefoxen =
  ["FireFøx 3.6",
   "Fire-fox 1.0",
   "Firefox 29",
   "FÍrefox 3.5",
   "Fírefox 18"];
 
var usVersion =
  new Intl.Collator("en-US",
                    { sensitivity: "base",
                      numeric: true,
                      ignorePunctuation: true });
 
// Fire-fox 1.0, FÍrefox 3.5, FireFøx 3.6, Fírefox 18, Firefox 29
print(firefoxen.sort(usVersion.compare).join(", "));


Наконец, выполним поиск строк с игнорированием регистра и акцентов.

var decoratedBrowsers =
  [
   "A\u0362maya",  // A͢maya
   "CH\u035Brôme", // CH͛rôme
   "FirefÓx",
   "sAfàri",
   "o\u0323pERA",  // ọpERA
   "I\u0352E",     // I͒E
  ];
 
var fuzzySearch =
  new Intl.Collator("en-US",
                    { usage: "search", sensitivity: "base" });
 
function findBrowser(browser)
{
  function cmp(other)
  {
    return fuzzySearch.compare(browser, other) === 0;
  }
  return cmp;
}
 
print(decoratedBrowsers.findIndex(findBrowser("Firêfox"))); // 2
print(decoratedBrowsers.findIndex(findBrowser("Safåri")));  // 3
print(decoratedBrowsers.findIndex(findBrowser("Ãmaya")));   // 0
print(decoratedBrowsers.findIndex(findBrowser("Øpera")));   // 4
print(decoratedBrowsers.findIndex(findBrowser("Chromè")));  // 1
print(decoratedBrowsers.findIndex(findBrowser("IË")));      // 5


Дополнительная информация


Может быть полезно определить, поддерживаются ли какие-то операции в конкретных локалях, или поддерживается ли сама локаль. Для этого в каждом конструкторе есть функция supportedLocales(), а в каждом прототипе — resolvedOptions().

var navajoLocales =
  Intl.Collator.supportedLocalesOf(["nv"], { usage: "sort" });
print(navajoLocales.length > 0
      ? "Navajo collation supported"
      : "Navajo collation not supported");
 
var germanFakeRegion =
  new Intl.DateTimeFormat("de-XX", { timeZone: "UTC" });
var usedOptions = germanFakeRegion.resolvedOptions();
print(usedOptions.locale);   // de
print(usedOptions.timeZone); // UTC


Наследственное поведение


До ES5 у функций toLocaleString и localeCompare не было такой продвинутой семантики, они не принимали настройки и были, по сути, бесполезны. Поэтому их поведение было изменено для поддержки операций Intl. Если вам не особенно важно точное поведение программы в отношении локалей, можно использовать и старые функции. В противном случае рекомендуется использовать примитивы из Intl напрямую.

Заключение


Интернационализация – очень интересная тема, сложность которой ограничена лишь природой человеческого общения. API интернационализации обращается к небольшой, но нужной части этой сложности, и делает проще написание веб-приложений, учитывающих локализацию.
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+14
Comments3

Articles