文档

I18n

NSH 应用用两种方式讲用户的语言:翻译,即应用自己的文本在每种语言下的写法;格式化,即按各语言和地区的习惯书写数字、货币和日期。两者都跟随应用当前显示的语言:默认是用户自己的语言,直到应用选择另一种。

翻译使用 rust-i18n 的文件格式和 API,TSX 和 Rust 都一样:把文件放进 locales/,调用 t("hello"),文本就以用户的语言显示。

import { Text } from "nsh";
import { t } from "nsh/i18n";

export default function App() {
  return <Text>{t("messages.hello", { name: "Jason" })}</Text>;
}

#翻译文件

翻译放在应用 nsh.json 旁边的 locales/ 目录里(Rust 应用放在 Cargo.toml 旁边)。不需要任何配置:构建时会读取这里的每个文件,并随应用一起打包。文件就是 rust-i18n 的文件,所以同一份文件也能给应用的 Rust crate、服务端或其他工具使用。

my-app/
  nsh.json
  App.tsx
  locales/
    app.yml

一个文件可以包含所有语言(_version: 2),每个 key 下面是它在各语言中的文本:

# locales/app.yml
_version: 2

hello:
  en: Hello
  zh-CN: 你好
  ja: こんにちは

messages.hello:
  en: Hello, %{name}!
  zh-CN: 你好,%{name}!
  ja: こんにちは、%{name}!

也可以每种语言一个文件(_version: 1,默认格式),文件名以语言结尾(en.yml、app.zh-CN.yml),key 以对象形式嵌套:

# locales/zh-CN.yml
hello: 你好
messages:
  hello: 你好,%{name}!

文件可以是 YAML(.yml、.yaml)、JSON(.json)或 TOML(.toml),也可以放在 locales/ 的子目录里。嵌套的 key 用点连接,例如上面的 messages.hello。大型应用可以按功能拆分文件(locales/settings.yml、locales/checkout.yml);同一语言在多个文件中的 key 会合并。

在 nsh run 和 nsh dev 中,保存 locales/ 里的文件和保存代码一样会重新构建应用:正在运行的应用会显示新的文本。

#t

t(key, options?) 返回 key 在应用当前显示语言下的文本。

import { t } from "nsh/i18n";

t("hello");                                  // "Hello"、"你好"……
t("messages.hello", { name: "Jason" });      // "Hello, Jason!":填入 %{name}
t("hello", { locale: "ja" });                // "こんにちは":指定另一种语言
t!("hello");
t!("messages.hello", name = "Jason");
t!("hello", locale = "ja");
  • 参数会填入文本中的 %{name}。值按文本写出(TSX 中是 String(value),Rust 中是 Display);没有对应参数的占位符保持原样。
  • locale 用于取另一种语言的文本,而不是应用当前显示的语言。
  • 回退与 rust-i18n 相同:某个语言缺少的 key,会依次从去掉最后一个子标签的语言中查找(zh-Hant-TW,然后 zh-Hant,然后 zh),再从英语(en)中查找。所有语言都没有这个 key 时,t 返回 <locale>.<key>("fr.messages.hello"),这样缺失的翻译会显示在屏幕上,而不是一片空白。
  • 响应式:t 会读取当前语言,所以调用它的文本、样式或计算值会在语言变化时更新,开发中翻译文件变化时也会更新。请在显示文本的地方调用它(<Text>{t("hello")}</Text>、text(move || t!("hello"))),不要只调用一次存进常量。

翻译在应用的任何模块运行之前就已就绪,所以在模块顶层也可以调用 t(此时结果只计算一次)。

#应用显示的语言

默认情况下,应用的文本以用户的语言显示:在用户的偏好语言(localeSettings().languages,按系统列出的顺序,最偏好的在前)中,取第一个应用有翻译的语言(它本身或去掉子标签后的较短标签)。偏好瑞士德语、然后中文、然后英语的用户,在应用没有德语时看到 zh 文本,有德语时看到 de 文本。应用一个都没有时,文本以 locale()(设备的地区格式)的语言显示,并按上面的规则回退到英语。

setLocale 让应用显示用户在应用内选择的语言,而不管设备的语言是什么:

import { Button } from "nsh/base";
import { Text, View } from "nsh";
import { availableLocales, setLocale, t } from "nsh/i18n";

export function LanguagePicker() {
  return (
    <View>
      <Each of={availableLocales()}>
        {(locale) => (
          <Button onPress={() => setLocale(locale())}>
            <Text>{t("language.name", { locale: locale() })}</Text>
          </Button>
        )}
      </Each>
    </View>
  );
}
  • setLocale("zh-CN") 会同时改变所有跟随语言的东西:t、locale()、localeSettings().languages 的第一项,以及 Intl 的默认 locale。不是 BCP 47 的标签会抛出 RangeError。
  • setLocale(null)(Rust:set_locale(None))重新跟随设备语言。
  • availableLocales()(Rust:available_locales())按顺序列出应用翻译所包含的语言。

这个选择在应用运行期间有效;需要记住它的应用自己保存(Storage),并在启动时调用 setLocale。每个 surface 有自己的语言:同一个应用被嵌入两次时,可以显示两种语言。

#数字和日期

Intl 和 toLocaleString 默认按应用显示的语言格式化。把 locale() 作为它们的 locale 传入,这样写数字的地方会读取语言,并在 setLocale 改变它时更新:

import { locale } from "nsh";

const price = (amount: number) =>
  amount.toLocaleString(locale(), { style: "currency", currency: "EUR" });
// en-US: "€1,234.50"   de: "1.234,50 €"   zh-CN: "€1,234.50"

Native Shell 的运行时自带英语(en-US)及其地区变体的格式化数据。其他每种语言的数据(分隔符、紧凑单位、日期格式、月份和星期名称)都随应用一起提供:默认是应用翻译所包含的语言,或者 nsh.json 中 locales 列出的语言。

{
  "id": "com.example.shop",
  "locales": ["de", "fr", "ja", "zh-CN"]
}
  • 没有 locales 时,应用按它有翻译的语言(上面的 locales/)和英语格式化。
  • 有 locales 时,应用正好按列出的语言和英语格式化:当用户格式化所用的语言与翻译的语言不同时列出它们(例如只有英文界面、但要显示德国价格的应用),或者用来去掉某些语言。
  • 没有打包的语言按英语(en-US)格式化,与没有数据的 locale 一向的行为相同。列出 Native Shell 没有数据的语言时,构建会给出警告。

每种语言只给 bundle 增加几 KB,而且只有列出它的应用才会打包它:应用只包含用户使用的语言,而不是 Native Shell 支持的所有语言。

有数据的语言:德语(de)、西班牙语(es)、法语(fr)、印地语(hi)、印尼语(id)、意大利语(it)、日语(ja)、韩语(ko)、荷兰语(nl)、波兰语(pl)、葡萄牙语(pt)、俄语(ru)、瑞典语(sv)、泰语(th)、土耳其语(tr)、越南语(vi)和中文(zh,简体和繁体),以及它们的地区变体(de-CH、es-MX、pt-PT、zh-TW……)。新增一种语言只需要在 Native Shell 的 nsh-i18n crate 中加一个数据文件(crates/nsh-i18n/data/),和其他改动一样提交贡献即可。

在 Web 上,浏览器自己的 Intl 可以格式化所有语言,与 locales 列出什么无关。

nsh package 还会把这些语言声明给 iOS 和 macOS(bundle 的 CFBundleLocalizations),让应用里的系统 UI(例如选中文字后的编辑菜单)和应用显示同一种语言:系统语言为中文的用户,在带有中文的应用里看到 全选,在没有中文的应用里看到英文。

Intl 能格式化什么,见数字与日期格式化;用户的其他设置(时区、12 或 24 小时制、每周第一天和单位)见设备与环境。

#Rust 应用

Rust 应用的 locales/ 放在 Cargo.toml 旁边;nsh run、nsh test 和 nsh package 会读取它,并把翻译交给 root(Root::translations),所以 t! 无需任何设置即可使用。只用 Cargo 构建的 Rust 应用(例如嵌入这个 crate 的宿主)自己传入翻译:

use nsh_core::{element::Root, i18n::Translations};

let translations = Translations::from_json(include_str!("../translations.json")).unwrap();
let root = Root::of::<App>().translations(translations);

这里的 t! 是 Native Shell 的,不是 rust-i18n 的:它读取所在 surface 的语言(每个 surface 各有一个),显示它的视图会跟随 set_locale。它的写法与 rust-i18n 相同:t!("key")、t!("key", name = value)、t!("key", locale = "ja")。

TSX 应用中的 Rust 组件也用同一个 t! 翻译:它们共享应用的 locales/。

#测试

nsh test 默认以 en-US 打开应用,除非 open 的 locale 另行指定;测试其他语言时用那种语言打开,或者像应用一样调用 setLocale:

import { open, test } from "nsh/test";

test("the greeting is translated", async () => {
  const app = await open("./App.tsx", { locale: "zh-CN" });
  await app.waitFor("text:你好");
});

#检查清单

  • 用户读到的每个字符串都来自 t(标签、占位符、无障碍标签、提示框的按钮),组件里不写字面量。
  • key 按文本的用途命名(checkout.pay、errors.network),而不是按英文文本命名,这样修改英文时无需重命名 key。
  • 完整的句子放在一个 key 中,用 %{name} 传参数,不要用几个 key 拼接:不同语言的语序不同。
  • 数字、价格和日期都通过 Intl / toLocaleString 写出,不要用 + 和 toFixed 拼接。
  • 布局要留出余量:德语和芬兰语文本比英语长得多,中文和日文则更短。
  • 当用户格式化所用的语言与翻译的语言不同时,在 nsh.json 的 locales 中列出它们。