Firefox 29 在半年前发布,所以这篇文章已经很久没有更新了。不过我想停下来讨论一下在那个版本中首次发布的国际化API(并且通过了所有测试!)。Norbert Lindenberg编写了大部分实现,我进行了审查,现在维护着它。(Makoto Kato的工作应该很快将其带到 Android 上;b2g 可能会因为一些特定于 b2g 的障碍而花费更长时间。敬请期待。)
什么是国际化?
国际化(简称i18n — i,18 个字符,n)是指以一种能够轻松适应来自不同地区、使用不同语言的用户群体的应用程序编写应用程序的过程。很容易在不经意间假设用户来自同一个地方并使用同一种语言,从而犯下错误,尤其是在你甚至不知道自己做出了假设的情况下。
function formatDate(d)
{
// Everyone uses month/date/year...right?
var month = d.getMonth() + 1;
var date = d.getDate();
var year = d.getFullYear();
return month + "/" + date + "/" + year;
}
function formatMoney(amount)
{
// All money is dollars with two fractional digits...right?
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 always sort alphabetically...right?
names.sort(sortAlphabetically);
}
JavaScript 的历史 i18n 支持很差
传统JS 中的 i18n 感知格式使用各种 toLocaleString()
方法。生成的字符串包含实现选择提供的任何细节:无法选择和选择(您是否需要在该格式化的日期中包含星期几?年份是否无关紧要?)。即使包含了正确的细节,格式也可能错误e.g.十进制时需要百分比。而且你不能选择区域设置。
至于排序,JS 几乎没有提供任何有用的区域设置敏感文本比较(排序)函数。localeCompare()
存在,但接口非常笨拙,不适合与sort
一起使用。它也没有允许选择区域设置或特定排序顺序。
这些限制非常糟糕,以至于——当我得知时非常惊讶!——需要 i18n 功能的严肃 Web 应用程序(最常见的是显示货币的金融网站)将打包数据,将其发送到服务器,让服务器执行操作,然后将其发送回客户端。仅为了格式化金额而进行服务器往返。真是太糟糕了。
一个新的 JS 国际化 API
新的ECMAScript 国际化 API 极大地提高了 JavaScript 的 i18n 功能。它提供了格式化日期和数字以及排序文本所需的所有花饰。区域设置是可选择的,如果请求的区域设置不受支持,则会回退。格式化请求可以指定要包含的特定组件。支持百分比、有效数字和货币的自定义格式。公开许多排序选项以用于排序文本。如果您关心性能,现在可以一次性完成选择区域设置和处理选项的预先工作,而不是每次执行依赖区域设置的操作时都进行一次。
也就是说,该 API 不是万能药。该 API 仅提供“尽力而为”。精确的输出几乎总是故意未指定。实现可以合法地仅支持oj
区域设置,或者可以忽略(几乎所有)提供的格式化选项。大多数实现将对许多区域设置提供高质量的支持,但这并非保证(尤其是在资源受限的系统上,例如移动设备)。
在幕后,Firefox 的实现依赖于Unicode 国际组件 库(ICU),而 ICU 又依赖于Unicode 通用语言环境数据存储库(CLDR)语言环境数据集。我们的实现是自托管的:ICU 之上的大部分实现都是用 JavaScript 本身编写的。我们一路走来遇到了一些障碍(我们以前从未自托管过如此庞大的东西),但都没有什么大不了的。
Intl
接口
i18n API 位于全局 Intl
对象上。Intl
包含三个构造函数:Intl.Collator
、Intl.DateTimeFormat
和 Intl.NumberFormat
。每个构造函数都创建一个对象,公开相关的操作,有效地为操作缓存区域设置和选项。创建这样的对象遵循以下模式
var ctor = "Collator"; // or the others
var instance = new Intl[ctor](locales, options);
<var>locales</var>
是一个字符串,指定一个语言标签 或包含多个语言标签的类数组对象。语言标签是类似 en
(一般英语)、de-AT
(奥地利使用的德语)或 zh-Hant-TW
(台湾使用的汉语,使用繁体中文)的字符串。语言标签也可以包含“Unicode 扩展”,格式为 -u-key1-value1-key2-value2...
,其中每个键都是一个“扩展键”。各种构造函数对这些进行特殊解释。
<var>options</var>
是一个对象,其属性(或通过评估为 undefined
的不存在)确定格式化程序或排序器如何运行。它的确切解释由各个构造函数决定。
给定语言环境信息和选项,实现将尝试尽可能地产生最接近“理想”行为的行为。Firefox 支持 400 多种语言环境的排序和 600 多种语言环境的日期/时间和数字格式,因此您可能关心的语言环境很可能(但不能保证)得到支持。
Intl
通常不提供任何关于特定行为的保证。如果请求的语言环境不受支持,Intl
允许尽力而为的行为。即使语言环境受支持,行为也没有严格指定。不要假设特定的一组选项对应于特定的格式。整个格式(涵盖所有请求的组件)的措辞可能因浏览器而异,甚至因浏览器版本而异。各个组件的格式未指定:short
格式的星期几可能是“S”、“Sa”或“Sat”。Intl
API 不打算公开完全指定的行为。
日期/时间格式化
选项
日期/时间格式化的主要选项属性如下
weekday
、era
"narrow"
、"short"
或"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 中不起作用。
这些值不会映射到特定的格式:请记住,Intl
API 几乎从不指定确切的行为。但意图是 "narrow"
、"short"
和 "long"
会产生大小相对应的输出——例如,“S”或“Sa”、“Sat”和“Saturday”。(输出可能模棱两可:Saturday 和 Sunday 都会产生“S”。)"2-digit"
和 "numeric"
映射到两位数的数字字符串或完整长度的数字字符串——例如,“70”和“1970”。
最终使用的选项在很大程度上是请求的选项。但是,如果您没有专门请求任何 weekday
/year
/month
/day
/hour
/minute
/second
,则 year
/month
/day
将添加到您提供的选项中。
除了这些基本选项之外,还有一些特殊选项
hour12
- 指定小时是否为 12 小时格式或 24 小时格式。默认值通常取决于区域设置。(有关午夜是零基还是十二基以及是否显示前导零的详细信息也取决于区域设置。)
还有两个特殊属性,localeMatcher
(接受 "lookup"
或 "best fit"
)和 formatMatcher
(接受 "basic"
或 "best fit"
),两者都默认为 "best fit"
。这些会影响如何选择正确的区域设置和格式。这些的使用案例有点深奥,所以您可能应该忽略它们。
以语言环境为中心的选项
DateTimeFormat
还允许使用自定义的日历和数字系统进行格式化。这些细节实际上是语言环境的一部分,因此它们在语言标签中的 Unicode 扩展中指定。
例如,泰国使用的泰语的语言标签为 th-TH
。回想一下,Unicode 扩展的格式为 -u-key1-value1-key2-value2...
。日历系统键为 ca
,数字系统键为 nu
。泰语数字系统的值为 thai
,汉语日历系统的值为 chinese
。因此,要以这种整体方式格式化日期,我们将包含这两个键/值对的 Unicode 扩展附加到语言标签的末尾:th-TH-u-ca-chinese-nu-thai
。
有关各种日历和数字系统的更多信息,请参阅完整的 DateTimeFormat
文档。
示例
创建 DateTimeFormat
对象后,下一步是使用它通过方便的 format()
函数来格式化日期。方便的是,此函数是一个绑定函数:您不必直接在 DateTimeFormat
上调用它。然后向它提供一个时间戳或Date
对象。
将所有内容放在一起,以下是一些如何为特定用途创建 DateTimeFormat
选项的示例,以及 Firefox 中的当前行为。
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年
对于完全不同的东西,一个更长的日期,用于在泰国使用的泰国语中——但使用泰国数字系统和中国日历。(Firefox 等高质量实现将把纯 th-TH
视为 th-TH-u-ca-buddhist-nu-latn
,推断泰国典型的佛教日历系统和拉丁数字 0-9。)
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"
通常使用货币的某种拼写版本。(Firefox 目前仅支持"symbol"
,但此问题将很快得到解决。)minimumIntegerDigits
- 从 1 到 21(含)的整数,默认为 1。结果字符串将用零填充,直到其整数部分至少包含这么多位数。(例如,如果该值为 2,格式化 3 可能生成“03”。)
minimumFractionDigits
、maximumFractionDigits
- 从 0 到 20(含)的整数。结果字符串将至少有
minimumFractionDigits
位小数,且不超过maximumFractionDigits
位小数。如果style
为"currency"
,则默认最小值取决于货币(通常为 2,很少为 0 或 3),否则为 0。百分比的默认最大值为 0,小数的默认最大值为 3,货币的默认最大值取决于货币。 minimumSignificantDigits
、maximumSignificantDigits
- 从 1 到 21(含)的整数。如果存在,这些选项将覆盖上面的整数/小数位控制,以确定格式化数字字符串中最小/最大有效位数,这取决于准确指定数字所需的位数。(请注意,在 10 的倍数中,有效位数可能不明确,例如“100”,它可以有一位、两位或三位有效位数。)
useGrouping
- 布尔值(默认为
true
),确定格式化字符串是否将包含分组分隔符(例如,英语中的千位分隔符“,”)。
NumberFormat
还识别深奥的、大多可忽略的 localeMatcher
属性。
以语言环境为中心的选项
正如 DateTimeFormat
在 Unicode 扩展中使用 nu
键支持自定义数字系统一样,NumberFormat
也支持。例如,中国使用的汉语的语言标记为 zh-CN
。汉语十进制数字系统的值为 hanidec
。要格式化这些系统的数字,我们将 Unicode 扩展附加到语言标记:zh-CN-u-nu-hanidec
。
有关指定各种数字系统的完整信息,请参阅完整的 NumberFormat
文档。
示例
NumberFormat
对象具有一个 format
函数属性,就像 DateTimeFormat
对象一样。并且,与那里一样,format
函数是一个绑定函数,可以在独立于 NumberFormat
的情况下使用。
以下是一些如何为特定用途创建 NumberFormat
选项的示例,以及 Firefox 的行为。首先,让我们格式化一些钱,用于中国使用的汉语,特别使用汉语十进制数字(而不是更常见的拉丁数字)。选择 "currency"
样式,然后使用人民币(元)代码,默认情况下进行分组,并使用通常的小数位数。
var hanDecimalRMBInChina =
new Intl.NumberFormat("zh-CN-u-nu-hanidec",
{ style: "currency", currency: "CNY" });
print(hanDecimalRMBInChina.format(1314.25)); // ¥ 一,三一四.二五
或者让我们格式化一个美国风格的汽油价格,它在千分位上有奇怪的 9,用于美国使用的英语。
var gasPrice =
new Intl.NumberFormat("en-US",
{ style: "currency", currency: "USD",
minimumFractionDigits: 3 });
print(gasPrice.format(5.259)); // $5.259
或者让我们尝试用阿拉伯语格式化一个百分比,用于埃及。确保百分比至少有两级小数。(请注意,此示例和所有其他RTL 示例在 RTL 上下文中可能以不同的顺序显示,例如٤٣٫٨٠٪ 而不是 ٤٣٫٨٠٪。)
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)); // ۰۳٫۱۴
最后,让我们格式化一定数量的巴林第纳尔,用于巴林使用的阿拉伯语。与大多数货币不同,巴林第纳尔分为千分之一(fils),因此我们的数字将有三位小数。(再次注意,明显的视觉顺序应持保留态度。)
var bahrainiDinars =
new Intl.NumberFormat("ar-BH",
{ style: "currency", currency: "BHD" });
print(bahrainiDinars.format(3.17)); // د.ب. ٣٫١٧٠
整理
选项
用于整理的主要选项属性如下
usage
"sort"
或"search"
(默认为"sort"
),指定此Collator
的预期用途。(search
整理器可能希望将更多字符串视为等效,而sort
整理器则不会。)sensitivity
"base"
、"accent"
、"case"
或"variant"
。这会影响整理器对具有相同“基本字母”但具有不同重音/变音符和/或大小写的字符的敏感程度。(基本字母取决于语言环境:“a”和“ä”在德语中具有相同的基本字母,但在瑞典语中是不同的字母。)"base"
敏感度仅考虑基本字母,忽略修改(因此,对于德语,“a”、“A”和“ä”被视为相同)。"accent"
考虑基本字母和重音,但忽略大小写(因此,对于德语,“a”和“A”相同,但“ä”与两者不同)。"case"
考虑基本字母和大小写,但忽略重音(因此,对于德语,“a”和“ä”相同,但“A”与两者不同)。最后,"variant"
考虑基本字母、重音和大小写(因此,对于德语,“a”、“ä, “ä” 和 “A” 都不同)。如果usage
为"sort"
,则默认值为"variant"
;否则,它取决于语言环境。numeric
- 布尔值(默认为
false
),确定在排序时是否考虑字符串中嵌入的完整数字。例如,数字排序可能会产生"F-4 Phantom II", "F-14 Tomcat", "F-35 Lightning II"
;非数字排序可能会产生"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"
)。(注意:Firefox 目前忽略此属性。)ignorePunctuation
- 布尔值(默认为
false
),确定在执行比较时是否忽略嵌入的标点符号(例如,以便"biweekly"
和"bi-weekly"
比较相同)。
还有那个 localeMatcher
属性,您可能可以忽略。
以语言环境为中心的选项
在语言环境的 Unicode 扩展中指定的作为 Collator
主要选项的部分是 co
,它选择要执行的排序类型:电话簿(phonebk
)、字典(dict
)以及许多其他类型。
此外,kn
和 kf
键可以(可选)重复 options
对象的 numeric
和 caseFirst
属性。但不能保证语言标记中支持它们,而且 options
比语言标记组件更清晰。因此,最好仅通过 options
调整这些选项。
这些键值对与在 DateTimeFormat
和 NumberFormat
中包含的方式相同;有关如何在语言标记中指定这些内容,请参阅那些部分。
示例
Collator
对象具有一个 compare
函数属性。此函数接受两个参数 <var>x</var>
和 <var>y</var>
,如果 <var>x</var>
比 <var>y</var>
小,则返回一个小于零的数字;如果 <var>x</var>
等于 <var>y</var>
,则返回 0;如果 <var>x</var>
大于 <var>y</var>
,则返回一个大于零的数字。与 format
函数一样,compare
是一个绑定函数,可以提取出来单独使用。
让我们尝试对一些德语姓氏进行排序,用于德国使用的德语。德语中实际上有两种不同的排序顺序:电话簿排序和字典排序。电话簿排序强调发音,就好像在排序之前,将“ä”、“ö”等扩展为“ae”、“oe”等。
var names =
["Hochberg", "Hönigswald", "Holzman"];
var germanPhonebook = new Intl.Collator("de-DE-u-co-phonebk");
// as if sorting ["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");
// as if sorting ["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(", "));
最后,让我们进行一些语言环境感知的字符串搜索,忽略大小写和重音,同样以美国使用的英语为准。
// Comparisons work with both composed and decomposed forms.
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
零碎
确定是否为特定语言环境提供对某些操作的支持,或者确定是否支持某种语言环境,这可能很有用。Intl
在每个构造函数上提供 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
函数没有特定的语义,不接受特定的选项,而且基本上没有用。因此,i18n API 以 Intl
操作的形式对它们进行了重新定义。现在,每个方法都接受额外的尾随 locales
和 options
参数,这些参数的解释方式与 Intl
构造函数相同。(但对于toLocaleTimeString
和toLocaleDateString
,如果未提供选项,则使用不同的默认组件。)
对于不需要精确行为的简短使用,旧方法仍然可以使用。但是,如果您需要更多控制或多次进行格式化或比较,最好直接使用 `Intl` 原语。
结论
国际化是一个引人入胜的主题,其复杂性仅受人类交流的多样性所限制。国际化 API 解决了其中一小部分但非常有用的复杂性,使生成本地化敏感的 Web 应用程序变得更加容易。赶快去使用它吧!
(特别感谢 Norbert Lindenberg、Anas El Husseini、Simon Montagu、Gary Kwong、Shu-yu Guo、Ehsan Akhgari、#mozilla.de 的成员以及可能被我遗漏的任何人 [抱歉!],他们为这篇文章提供了反馈或帮助我制作和批判示例。英语和德语示例是我知识的极限,如果没有他们的帮助,我会完全迷失在其他示例中。任何剩下的错误都归我负责。再次感谢!)
关于 Jeff Walden
自 ~2003 年起成为 Mozilla 用户,自 ~2006 年起成为 SpiderMonkey 黑客。涉猎 Gecko 的所有领域。通用标准专家。
14 条评论