语言环境管理

语言环境是用户希望用来格式化其数据的语言、地区、脚本和区域偏好设置的组合。

业界有多种语言环境数据结构模型,它们之间具有不同程度的兼容性。历史上,每个主要平台都使用自己的模型,并且许多标准机构提出了相互冲突的建议。

Mozilla 与大多数现代平台一样,遵循 Unicode 和 W3C 的建议,并符合称为 BCP 47 的标准,该标准描述了语言环境的低级文本表示形式,称为语言标签

一些语言标签示例:en-USdearzh-Hanses-CL

语言环境和语言标签

语言环境数据结构包含四个主要字段。

  • 语言(例如:英语 - en,法语 - fr,塞尔维亚语 - sr

  • 脚本(例如:拉丁语 - Latn,西里尔语 - Cyrl

  • 地区(例如:美国 - US,加拿大 - CA,俄罗斯 - RU

  • 变体(例如:Mac OS - macos,Windows - windows,Linux - linux

BCP 47 指定了以字符串形式表示时每个字段(称为子标签)的语法。语法定义了允许使用的字符选择、字符大小写以及定义字段的顺序。

大多数基本子标签都是有效的 ISO 代码,例如 ISO 639 用于语言子标签,或 ISO 3166-1 用于地区。

上面的示例显示了省略了几个字段的语言标签,这是标准允许的。

最重要的是,语言环境可能包含

  • 扩展和私有字段

    这些字段可用于携带有关语言环境的附加信息。Mozilla 目前在 JS 实现中对此提供了部分支持,并计划将其支持扩展到所有 API。

  • extkeys 和“祖传”标签(不幸的用语,但属于规范的一部分)

    Mozilla 尚未支持这些。

语言环境示例可以可视化为

{
    "language": "sr",
    "script": "Cyrl",
    "region": "RU",
    "variants": [],
    "extensions": {},
    "privateuse": [],
}

然后可以将其序列化为字符串:“sr-Cyrl-RU”

重要

由于语言环境通常以语言标签字符串的形式存储和传递到代码库中,因此务必始终使用适当的 API 来解析、操作和序列化它们。避免自己动手的解决方案,这些解决方案会使您的代码变得脆弱,并且可能会在意外的语言标签结构上出现故障。

语言环境回退链

语言环境敏感操作始终被视为“尽力而为”。这意味着不能假设用户请求的内容与 API 可以提供的内容之间存在完美匹配。

因此,最佳实践是始终对语言环境回退链进行操作 - 根据用户偏好对语言环境进行排序的列表。

语言环境回退链示例可能为:["es-CL", "es-ES", "es", "fr", "en"]

以上表示如果可能,请求根据智利西班牙语格式化数据,然后回退到西班牙西班牙语,然后是任何(通用)西班牙语、法语,最后是英语。

重要

始终最好使用语言环境回退链而不是单个语言环境。如果只有一个语言环境可用,则包含一个元素的列表将起作用,同时允许进行未来的扩展,而无需进行代价高昂的重构。

语言协商

由于数据匹配中的不完善,所有语言环境操作都应始终使用语言协商算法来解决最佳可用语言环境集,该算法基于所有可用语言环境列表和请求语言环境的有序列表。

此类算法在复杂性和策略数量上可能有所不同。Mozilla 的解决方案基于 RFC 5656 中修改后的逻辑。

协商中使用的三个语言环境列表

  • 可用 - 本地安装的语言环境

  • 请求 - 用户选择的语言环境,按偏好顺序递减

  • 已解决 - 协商的结果

协商的结果是有序的语言环境列表,这些语言环境可供系统使用,并且预期使用者将尝试按已解决的顺序使用这些语言环境。

在所有场景中都应使用协商,例如选择语言资源、日历、数字格式等。

单一语言环境匹配

每个协商策略都会经历一系列步骤,以尝试在语言环境之间找到最佳匹配。

确切的算法是自定义的,并包含 6 个级别的策略

1) Attempt to find an exact match for each requested locale in available
   locales.
   Example: ['en-US'] * ['en-US'] = ['en-US']

2) Attempt to match a requested locale to an available locale treated
   as a locale range.
   Example: ['en-US'] * ['en'] = ['en']
                          ^^
                          |-- becomes 'en-*-*-*'

3) Attempt to use the maximized version of the requested locale, to
   find the best match in available locales.
   Example: ['en'] * ['en-GB', 'en-US'] = ['en-US']
              ^^
              |-- ICU likelySubtags expands it to 'en-Latn-US'

4) Attempt to look for a different variant of the same locale.
   Example: ['ja-JP-win'] * ['ja-JP-mac'] = ['ja-JP-mac']
              ^^^^^^^^^
              |----------- replace variant with range: 'ja-JP-*'

5) Attempt to look for a maximized version of the requested locale,
   stripped of the region code.
   Example: ['en-CA'] * ['en-ZA', 'en-US'] = ['en-US', 'en-ZA']
              ^^^^^
              |----------- look for likelySubtag of 'en': 'en-Latn-US'

6) Attempt to look for a different region of the same locale.
   Example: ['en-GB'] * ['en-AU'] = ['en-AU']
              ^^^^^
              |----- replace region with range: 'en-*'

过滤 / 匹配 / 查找

在语言环境列表之间进行协商时,Mozilla 的 LocaleService API 提供了三种语言协商策略

过滤

这是最常见的场景,在这种场景中,创建用户可能从中受益的最大可能的语言环境列表是有优势的。

场景示例

let requested = ["fr-CA", "en-US"];
let available = ["en-GB", "it", "en-ZA", "fr", "de-DE", "fr-CA", "fr-CH"];

let result = Services.locale.negotiateLanguages(requested, available);

result == ["fr-CA", "fr", "fr-CH", "en-GB", "en-ZA"];

在上面的示例中,该算法能够将“fr-CA” 匹配为完美匹配,但随后也能够找到其他匹配 - 通用法语是一个非常好的匹配,瑞士法语也与最顶部请求的语言非常接近。

对于第二个请求的语言环境,不幸的是美国英语不可用,但英国英语和南非英语可用。

该算法是贪婪的,并尝试匹配尽可能多的语言环境。这通常是开发人员想要的。

匹配

在不太常见的场景中,代码需要为每个请求的语言环境匹配一个最佳的可用语言环境。

此场景的示例

let requested = ["fr-CA", "en-US"];
let available = ["en-GB", "it", "en-ZA", "fr", "de-DE", "fr-CA", "fr-ZH"];

let result = Services.locale.negotiateLanguages(
  requested,
  available,
  undefined,
  Services.locale.langNegStrategyMatching);

result == ["fr-CA", "en-GB"];

“fr-CA” 的最佳可用语言环境是完美匹配,对于“en-US”,算法选择了英国英语。

查找

第三种策略应在无论如何,只能使用一个语言环境的情况下使用。某些第三方 API 不支持回退,并且在找到第一个语言环境后继续解决没有意义。

仍然建议继续使用此 API 作为回退链列表,只是在这种情况下使用单个元素。

let requested = ["fr-CA", "en-US"];
let available = ["en-GB", "it", "en-ZA", "fr", "de-DE", "fr-CA", "fr-ZH"];

let result = Services.locale.negotiateLanguages(
  requested,
  available,
  Services.locale.defaultLocale,
  Services.locale.langNegStrategyLookup);

result == ["fr-CA"];

默认语言环境

除了可用请求已解决语言环境列表外,还有一个DefaultLocale的概念,它是可用语言环境列表中的单个语言环境,如果在可用语言环境和请求语言环境之间找不到匹配项,则应使用该语言环境。

每个 Firefox 都是使用单个默认语言环境构建的 - 例如 Firefox zh-CNDefaultLocale 设置为zh-CN,因为此语言环境保证已打包在内,拥有所有资源,并且如果协商未能返回任何匹配项,则应使用此语言环境。

let requested = ["fr-CA", "en-US"];
let available = ["it", "de", "zh-CN", "pl", "sr-RU"];
let defaultLocale = "zh-CN";

let result = Services.locale.negotiateLanguages(requested, available, defaultLocale);

result == ["zh-CN"];

链式语言协商

在某些情况下,用户可能希望将语言选择链接到另一个组件。

例如,Firefox 扩展程序可能带有其自己的可用语言环境列表,其中可能包含 Firefox 没有的语言环境。

在这种情况下,用户请求的语言环境与加载项列表之间的协商可能会导致选择取代 Firefox 本身选择的语言环境。

     Fx Available
    +-------------+
    |  it, fr, ar |
    +-------------+                 Fx Locales
                  |                +--------+
                  +--------------> | fr, ar |
                  |                +--------+
        Requested |
 +----------------+
 | es, fr, pl, ar |
 +----------------+                 Add-on Locales
                  |                +------------+
                  +--------------> | es, fr, ar |
  Add-on Available |               +------------+
+-----------------+
|  de, es, fr, ar |
+-----------------+

在这种情况下,加载项最终可能会以西班牙语显示,而 Firefox UI 将使用法语。在大多数情况下,这会导致糟糕的用户体验。

为了避免这种情况,可以将加载项协商链接起来,并将 Firefox 的已解决语言环境作为请求,并针对加载项的可用列表进行协商。

    Fx Available
   +-------------+
   |  it, ar, fr |
   +-------------+                Fx Locales (as Add-on Requested)
                 |                +--------+
                 +--------------> | fr, ar |
                 |                +--------+
       Requested |                         |                Add-on Locales
+----------------+                         |                +--------+
| es, fr, pl, ar |                         +------------->  | fr, ar |
+----------------+                         |                +--------+
                                           |
                          Add-on Available |
                         +-----------------+
                         |  de, es, ar, fr |
                         +-----------------+

可用语言环境

在 Gecko 中,可用语言环境来自打包的语言环境和已安装的语言包。语言包是 WebExtensions 的一种变体,仅提供一种或多种语言的本地化资源。

哪些语言环境可用的主要概念是基于 Gecko 具有哪些语言环境的 UI 本地化资源,以及其他数据集(如国际化)可能包含不同的可用语言环境列表。

请求的语言环境

可以使用 LocaleService::requestedLocales API 读取和设置请求语言环境列表。

使用 API 将执行必要的完整性检查并规范化值。

规范化后,该值将存储在首选项 intl.locale.requested 中。首选项通常会存储以逗号分隔的有效 BCP47 语言环境代码列表,但它还可以具有两种特殊含义

  • 如果根本没有设置首选项,则 Gecko 将使用默认语言环境作为请求的语言环境。

  • 如果首选项设置为空字符串,则 Gecko 将在 OS 应用程序语言环境中查找请求的语言环境。

前者是 Firefox 桌面当前的默认设置,后者是 Firefox for Android 的默认设置。

如果开发人员希望以编程方式请求应用程序遵循 OS 语言环境,则可以将 null 分配给 requestedLocales

区域偏好设置

每个语言环境都带有一组特定于文化和地区的默认偏好设置。这包括诸如日历系统、显示时间的方式(24 小时制与 12 小时制)、一周的开始日期、哪些日期构成周末、给定语言环境使用哪种编号系统和日期时间格式(例如 en-US 中的“MM/DD”与 en-AU 中的“DD/MM”)等偏好设置。

对于所有此类偏好设置,Gecko 都有每个地区的默认设置列表,但每个用户可能都希望进行一定程度的自定义。

所有主要操作系统都具有用于选择这些偏好设置的“设置”UI,并且由于 Firefox 没有提供自己的 UI,因此 Gecko 会在 OS 中查找它们。

一个特殊的 API mozilla::intl::OSPreferences 处理与主机操作系统的通信,检索区域首选项并根据用户首选项更改国际化格式。

需要注意的一点是,区域首选项和语言选择之间的界限并不明确。在许多情况下,国际化格式将包含特定于语言的术语和文字。例如,日语日期格式模式可能如下所示 - “2018年3月24日”,或者日期格式可能包含需要翻译的月份或星期几名称(“四月”、“星期二”等)。

因此,在操作系统区域设置选择与 Firefox UI 区域设置不匹配的情况下,遵循区域首选项会比较棘手。

这种行为可能会导致 UI 出现类似“Today is 24 października”的情况,即在英文 Firefox 中使用波兰语日期格式。

因此,默认情况下,Gecko **仅**在操作系统的区域设置和 Firefox 的**语言**部分匹配时才会查看操作系统首选项。这意味着,如果 Windows 设置为“en-AU”而 Firefox 设置为“en-US”,则 Gecko 将查看 Windows 区域首选项,但如果 Windows 设置为“de-CH”而 Firefox 设置为“fr-FR”,则不会查看。为了强制 Gecko 无论语言是否匹配都查看操作系统首选项,请将标志 intl.regional_prefs.use_os_locales 设置为 true

UI 方向

由于 UI 方向与区域设置选择紧密相关,因此测试 Gecko 应用方向性的主要方法位于 LocaleService 中。

LocaleService::IsAppLocaleRTL 返回一个布尔值,指示应用程序 UI 的当前方向是否为从右到左。

默认和最后回退区域设置

每个 Gecko 应用程序都使用单个区域设置作为默认区域设置进行构建。此类区域设置保证所有语言资源都可用,应在语言协商找不到任何匹配项时用作默认区域设置,并且还用作回退链中查找的最后一个区域设置。

如果所有其他方法都失败,Gecko 还支持最后一个回退区域设置的概念,该概念目前硬编码为 “en-US”,并且是在其他任何内容(包括默认区域设置)都不起作用时尝试的最后一个区域设置。请注意,Unicode 和 ICU 在该角色中使用 “en-GB”,因为全球更多说英语的人认可英国的区域首选项而不是美国的(公制与英制、华氏与摄氏等)。Mozilla 未来可能会切换到 “en-GB”

打包的区域设置

当 Gecko 应用程序打包时,它会捆绑一系列区域设置资源,以便在其中使用。目前,例如,大多数 Firefox for Android 版本都打包了近 100 种区域设置,而桌面版 Firefox 通常只打包了一种区域设置。

目前正在进行的工作旨在提高打包区域设置的方式的灵活性,以允许在不同区域捆绑具有不同区域设置集的应用程序 - 字典、连字符、产品语言资源、安装程序语言资源等。

Web 公开区域设置

出于反跟踪或其他一些原因,我们倾向于向 Web 内容公开欺骗的区域设置,而不是默认区域设置。这可以通过设置首选项 intl.locale.privacy.web_exposed 来完成。该首选项是一个用逗号分隔的区域设置列表,空字符串表示默认区域设置。

privacy.spoof_english 设置为 2 时,该首选项没有功能,其中始终返回 “en-US”

多进程

区域设置管理可以在客户端/服务器模型中运行。这允许 Gecko 进程管理区域设置(服务器模式)或仅从父进程接收区域设置选择(客户端模式)。

客户端模式目前由桌面版 Firefox 的所有子进程使用,并且可能被例如 GeckoView 用于遵循父进程的区域设置选择。

要检查进程正在运行的模式,可以使用 LocaleService::IsServer 方法。

请注意,L10nRegistry.registerSourcesL10nRegistry.updateSourcesL10nRegistry.removeSources 都会在父进程和任何现有的内容进程之间触发 IPC 同步,这代价很高。如果您需要更改多个源的注册,最佳方法是将多个请求合并到单个数组中,然后调用该方法一次。

Mozilla 异常

目前仅存在一个 BCP47 使用的异常,即旧版“ja-JP-mac”区域设置。“mac”是一个变体,BCP47 要求所有变体长度为 5-8 个字符。

Gecko 通过在我们的 API 中接受 3 个字母的变体来支持此限制,并提供一个特殊的 appLocalesAsLangTags 方法以这种形式返回此区域设置。(appLocalesAsBCP47 将规范化它并转换为 “ja-JP-macos”)。

语言协商等的使用不应依赖于此行为。

事件

LocaleService 发出两个事件:intl:app-locales-changedintl:requested-locales-changed,所有代码都可以监听这些事件。

这些事件可能是响应新语言包的安装、卸载或用户语言选择更改而广播的。

在大多数情况下,代码应该观察 intl:app-locales-changed 并仅对该事件做出反应,因为这是指示组件应遵循的当前使用的语言设置发生更改的事件。

测试

许多组件可能具有编码的逻辑以对请求的、可用的或已解析的区域设置的变化做出反应。

为了测试组件的行为,必须复制可能发生此类更改的环境。

由于在大多数情况下建议组件将其语言协商绑定到主应用程序(请参阅Chained Language Negotiation),因此仅添加新的区域设置来触发语言更改是不够的。

首先,有必要将新的区域设置添加到可用的区域设置中,然后更改请求的区域设置,只有这样才会导致新的协商和语言更改发生。

有两种主要方法可以将区域设置添加到可用的区域设置中。

测试本地化

如果目标是测试正确的本地化最终是否出现在正确的位置,则开发人员需要在 L10nRegistry 中注册一个新的 L10nFileSource 并提供 API 要返回的模拟缓存数据。

它可能如下所示

let source = L10nFileSource.createMock(
  "mock-source", "app",
  ["ko-KR", "ar"],
  "resource://mock-addon/localization/{locale}",
  [
    {
      path: "resource://mock-addon/localization/ko-KR/test.ftl",
      source: "key = Value in Korean"
    },
    {
      path: "resource://mock-addon/localization/ar/test.ftl",
      source: "key = Value in Arabic"
    }
  ]
);

L10nRegistry.registerSources([fs]);

let availableLocales = Services.locale.availableLocales;

assert(availableLocales.includes("ko-KR"));
assert(availableLocales.includes("ar"));

Services.locale.requestedLocales = ["ko-KR"];

let appLocales = Services.locale.appLocalesAsBCP47;
assert(appLocales[0], "ko-KR");

从此处,可以将资源 test.ftl 添加到Localization 中,对于 ID key,将返回模拟缓存中的正确值。

测试区域设置切换

第二种方法的限制性更大,因为它仅模拟区域设置可用性,但它也更简单

Services.locale.availableLocales = ["ko-KR", "ar"];
Services.locale.requestedLocales = ["ko-KR"];

let appLocales = Services.locale.appLocalesAsBCP47;
assert(appLocales[0], "ko-KR");

将来,Mozilla 计划为附加组件添加第三种方法(bug 1440969),以允许出于手动或自动测试目的将其区域设置与主应用程序区域设置断开。

测试结果

除了测试对区域设置更改的反应外,建议避免编写期望选择特定区域设置或使用特定国际化或本地化数据的测试。

这样做会锁定测试基础设施,使其只能在单个区域设置环境中启动时使用,并且需要在底层数据更改时更新这些测试。

在测试区域设置选择的情况下,最好使用像 x-test 这样的伪区域设置,该区域设置在测试开始时不会存在。

在测试国际化数据的情况下,最好使用 resolvedOptions() 来验证正在使用正确的数据,而不是比较输出字符串。

在本地化的情况下,最好测试是否设置了正确的 data-l10n-id,或者在极端情况下,使用 String.prototype.includes 验证字符串中是否存在给定变量。

深入探讨

以下列出了有关选定主题的其他详细信息的文章

反馈

如有任何疑问,请咨询 Intl 模块的同行。