Angular 18+ 高级教程 – 国际化 Internationalization i18n

介绍

先讲讲名词。

Internationalization 的缩写是 i18n,中文叫国际化。

Globalization 是 Internationalization 的同义词,都是指国际化。

Localization 的缩写是 l10n,中文叫本地化。

i18n vs l10n

一个国际化,一个本地化,它俩有什么区别,又有什么关系呢?

我们来看一个具体的例子

上图是苹果公司给美国人访问的官网,内容是 iPhone 16 Pro 的售价。

文字使用的是美式英文 (en-US),价格使用的是美金 (USD)。

好,我们再看另外两张图

图一是苹果公司给中国人看的官网,图二则是给日本人看的官网

中国人看的是简体中文 (zh-Hans-CN) 和人民币 (CNY)。

日本人看的是日文 (ja-JP) 和日元 (JPY)。

三个网站销售的都是 iPhone 16 Pro。网站设计、排版都一模一样。

唯一的区别就是,网站会依据不同的国家,显示对应的语言和货币。

像这样一个网站,我们就可以说:苹果公司的官网支持国际化,同时也落实了本地化。

所谓支持国际化,意思是,网站架构有能力 handle 不同的语言,货币,时区。(设计,功能全都一样,就语言,货币,时区不同)

所谓落实本地化,意思是,网站不仅有能力 handle 不同的语言,货币,时区,而且它确实做出来了。

国际化指的是一个方案 / preparation,本地化则是具体的实现。

Angular i18n

Angular 有 built-in 的 i18n 方案。我们使用 Angular 就能做出像苹果公司那样支持国际化的网站。

本篇会 step by step 教 i18n,但不会讲解原理,也不会逛源码,开始吧 🚀。

 

参考

YouTube – Introduction to Internationalization in Angular

Docs – Angular Internationalization

 

Angular i18n step by step

一步一步来

创建一个新项目

ng new i18n --routing=false --ssr=false --skip-tests --style=scss

安装 @angular/localize package

ng add @angular/localize

提醒:是 ng add 不是 yarn add 哦。

它会做几件事:

  1. package.json


    安装了 @angular/localize package。

    注意看,它是安装到了 devDependencies 里哦。

    这也意味着,Angular i18n 是在 compile 阶段完成的,而不是在 runtime。

  2. angular.json

    多了一个 polyfill。我们刚说 i18n 发生在 compile 阶段,但也不完全。有一小部分还是需要 runtime 配合完成。

    这个 polyfill 就用在这些地方。

  3. tsconfig

    main.ts


    还需要 TypeScript 配合,因为 runtime 会用到一些全局变量。

i18n Hello World

App Template

<h1 i18n>Hello World</h1>

注意看,这个 h1 有一个 "i18n" 标签 (attribute)。

它用来表示,这个 "Hello World" 待会儿需要被翻译成其它语言。

注:这里给的是最简单的例子,下面还会有比较复杂的玩法,我们先过一轮简单的。

Generate translation files

执行 command

ng extract-i18n --output-path src/locale

上面我们说了,Angular i18n 发生在 compile 阶段。

这个 command 会创建一个 folder (src/locale) 和一个 file (messages.xlf)

messages.xlf 是要给翻译小姐姐使用的。

它长这样

<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
  <!-- 1. source-language="en-US" 表示我们的 source code 写的是美式英文-->
  <file source-language="en-US" datatype="plaintext" original="ng2.template">
    <body>
      <!-- 2. 每一句要翻译的文字都有一个独一无二的 ID 代号 -->
      <trans-unit id="4584092443788135411" datatype="html">
        <!-- 3. source 就是我们要翻译的文字,也就是上面 App Template 里的 <h1 i18n>Hello World</h1> -->
        <source>Hello World</source>
        <context-group purpose="location">
          <!-- 4. location 表明这个要翻译的文字,它来自哪一个 file 和哪一行 -->
          <context context-type="sourcefile">src/app/app.component.html</context>
          <context context-type="linenumber">1</context>
        </context-group>
      </trans-unit>
    </body>
  </file>
</xliff>

Angular i18n 会扫描我们所有的文件,然后提取出需要翻译的部分,制作出 messages.xlf。

Translate

接着,我们把 messages.xlf 寄给翻译小姐姐。

她会替我们翻译出不同语言的版本,比如

messages-zh-Hans-CN.xlf (简体中文版)

<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
  <file source-language="en-US" datatype="plaintext" original="ng2.template">
    <body>
      <trans-unit id="4584092443788135411" datatype="html">
        <source>Hello World</source>
        <!-- 1. 添加了简体中文 -->
        <target>你好,世界</target>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/app.component.html</context>
          <context context-type="linenumber">1</context>
        </context-group>
      </trans-unit>
    </body>
  </file>
</xliff>

messages.ja-JP.xlf (日文版)

Setup angular.json and build application

翻译完后,就到了最后环节,ng build。

在 build 之前,我们需要修改一下 angular.json。

告知 Angular 原文、支持的译文、还有它们的文件路径。

"i18n": {
  // 原文是美式英文
  "sourceLocale": "en-US",
  "locales": {
    // 支持简体中文
    "zh-Hans-CN": {
      // 简体中文翻译文档在这儿
      "translation": "src/locale/messages.zh-Hans-CN.xlf"
    },
    // 支持日文
    "ja-JP": {
      // 日文翻译文档在这儿
      "translation": "src/locale/messages.ja-JP.xlf"
    }
  }
},
View Code

还需要设置 projects.i18n.architect.options.localize: true

接着就可以 build 了

ng build --localize

Warning 

它出现 warning 是因为我写的 locale ID 它不支持。

一个完整的 locale ID 应该是 "语言-国家",比如:zh-Hans 代表简体中文,CN 代表中国,但它只支持写语言 zh-Hans,-CN 不行。

相关 Github Issue – Unable to find zh-Hant-TW or zh-TW locale in @angular/common

不碍事儿,程序员不关心警告 (如果出现 error,请把国家 -CN 删掉,改成 zh-Hans 就好)。继续

一共 build 出了 3 个 folder "en-US"、"ja-JP"、"zh-Hans-CN"。

每一个 folder 里都有各自的 index.html,main.js 等等。

所有的代码都一摸一样,除了这一句

这一段符文是 unicode,对应的文字是

合起来就是 こんにちは世界,日文 Hello World 的意思。

这段是简体中文 Hello World 的 unicode。 

Run application

打开 dist\i18n\browser,然后 Open with Live Server

效果

总结

以上就是 Angular i18n 最简单的 step by step 过程。

有很多细节和玩法我都还没有讲到,下面我们一个一个补上,Let's go 🚀。

 

i18n 标签 (attribute) 的日常用法

我们逐一看看 i18n 标签的各种日常使用方式。

i18n 习性 の tree shaking

Test Template 里有 i18n 标签。

但是,Test 组件没有被任何其它组件使用。

问:ng extract-i18n 会扫描到这个标签吗?

答:不会,因为扫描是有 tree shaking 概念的。 

i18n 习性 の same word same translate

<h1 i18n>Hello World</h1>
<h1 i18n>Hello World</h1>

两个 h1 有着一模一样的文字

它们会被放到同一个 <trans-unit> 里,只需要翻译一次。

Description, meaning, translate ID

<h1 i18n="description for this translate">Home</h1>

标签值可以用来描述要翻译的文字。

这行字会被填入 <trans-unit> 里。

除了 description,我们还可以加入 meaning / title。

<h1 i18n="meaning for this translate|description for this translate">Home</h1>

使用 pipe symbol | 作为分隔符,前面一段是 meaning,后一段是 description。

meaning 除了是一个描述以外,它还有 unique 功能。

我们举一个例子。

"Home" 这个英文字,可以被翻译成 "家",也可以被翻译成 "首页"。

具体要翻译成哪一个,还得看它的上下文。

也就是说,上一 part 我们提到的特性 -- same word same translate 这个潜规则是不能满足所有场景的。

当出现这种情况时,meaning 就可以用于区分相同的文字。

<h1 i18n="Header navigation|">Home</h1>
<h1 i18n="A song name|">Home</h1>

效果

虽然文字相同,但是 meaning 不同,所以生成出了 2 个 <trans-unit>。

最后说一说 translate ID。

这个 ID 是 unique,Angular i18n 会依据文字和 meaning 自动生成对应的 ID。

如果我们想自己管理,可以 hardcode 写一个给它。

<h1 i18n="@@id-1">Home</h1>
<h1 i18n="@@id-2">Home</h1>

ID 的代号是 starts with @@。

效果

虽然文字一样,meaning 一样,但 ID 不同,所以会分开两个 <trans-unit>。

With HTML and interpolation

<p i18n>Copyright © 2024 <a href="/">{{ companyName() }}</a>. All rights reserved</p>

p 里头包含了 <a> 和 {{ interpolation }},这些都是 OK 的。

翻译的时候,针对文字翻就可以了,其它的不要乱改哦。

Translate attribute value

title、aria-label 这些 element attribute value 也可以被 translate。

<button i18n-aria-label aria-label="Example icon button with a vertical three dot icon" mat-icon-button>
  <mat-icon>more_vert</mat-icon>
</button>

在 i18n 标签后面加上指定的 attribute name 作为 suffix 就可以了。

Without element

假如没有 element,就只有 text,那 i18n 标签要放哪里呢?

答案是 <ng-container>

<ng-container i18n>Hello World</ng-container>

ICU expressions

ICU expressions 被用于 conditional 翻译。

我们看例子来理解

handle conditional number – plural

<p i18n>{ peopleCount(), plural, =0 { no person } =1 { one person } other { {{ peopleCount() }} people } }</p>

它的语法是这样的

{ 参数一,参数二,参数三 }

参数一是 condition,peopleCount 是一个组件 property Signal<number>。

参数二有 2 个值可以填,一个是 plural,一个是 select。

plural 就是针对数字来做判断,select 则是针对 string 来做判断,select 下面会教,我们先看 plural。

参数三是不同数字下要呈现的文字。

在 runtime 阶段,假如 peopleCount = 0,那就会显示 "no person"。

假如 peopleCount = 1,那就会显示 "one person"。

peopleCount = 其它数字,则会显示 "{{ peopleCount() }} people"。

用 @if 实现的话,长这样

<p i18n>
  @if(peopleCount() === 0) {
    no person
  }
  @else if (peopleCount() === 1) {
    one person
  }
  @else {
    {{ peopleCount() }} people
  }
</p>

显然使用 ICU expressions 会更精简一些。(但 plural 的表达式是有限的,它只能写 =1,=5,不可以写 >5,<3 大于小于这些都不支持)

翻译文档长这样

同样的,我们只翻译文字就好,其它的不要乱改。

handle conditional string – select

select 和 plural 结构是一样的,只是前者针对 string,后者针对 number。

export class AppComponent {
  readonly gender = signal<'male' | 'female' | null>(null);
}
<p i18n>{ gender(), select, male { male } female { female } other { other } }</p>

当 gender 是 "male" 时,显示 "男"。

当 gender 是 "female" 时,显示 "女"。

当 gender 不是 "male" 也不是 "female" (e.g. null) 时,显示 "其它"。

Translate in script

上面我们讲的都是在 HTML 里做翻译。

如果我的文字写在 script 里头呢?如何打上 i18n 标签?

答案是使用 $localize

export class AppComponent {
  constructor() {
    const value = $localize`Hello World`;
    console.log(value);
  }
}

$localize 是一个全局变量

main.ts 引入的类型就是为了它

$localize 等价于 HTML 的 i18n 标签,用法也大同小异,生成出来的翻译文档也是一样的。

下面这个是 meaning, description, id 的写法

const value = $localize`:meaning|description@@id:Hello World`;

用 : 分号做分割。

唯一比较大的区别是,$localize 不支持 ICU expressions。

假如我们需要 conditional 就用一般的 if else swtich 来完成就可以了,比如:

// <p i18n>{ peopleCount(), plural, =0 { no person } =1 { one person } other { {{ peopleCount() }} people } }</p>

const peopleCount = signal(0);
const value =
  peopleCount() === 0
    ? $localize`no person`
    : peopleCount() === 1
    ? $localize`one person`
    : $localize`${peopleCount()} people`;

console.log(value);

总结

以上便是 i18n 标签的日常用法。

 

ng serve for i18n application

上面我们讲的都是 ng build 最终的发布。

那在 development 阶段,ng serve 是否可以开启 i18n application?

可以,但只能选定其中一个 locale。

去 angular.json 指定 locale

把原本的 true,改成 array,array 里只能放一个 locale。

接着 ng serve 就可以了。

Get current locale ID

通过 inject LOCALE_ID,我们可以获知当前是什么 locale。

export class AppComponent {
  constructor() {
    console.log(inject(LOCALE_ID)); // zh-Hans
  }
}

在没有 i18n 的情况下,它的默认值是 "en-US" (提醒:它不是依据游览器 settings 哦,它是 hardcode en-US)。

 

关于 base href

所有 build 出来的 index.html 都带有 <base href="/locale/">

<base href> 有啥用,可以看这篇

为什么 Angular i18n 要在 base href 加上 locale 呢?

因为它想让我们更方便的部署,我拿 ASP.NET Core 来举例。

ASP.NET Core 常规做法是把 ng build 的产物通通放到 wwwroot folder 里

然后在 program.cs 做 routing

简单说就是,当用户访问 /zh-Hans-CN/**/* 就会访问到 /zh-Hans-CN/index.html。

index.html 

polyfills.js 结合 base href 后的路径是 /en-US/polyfills-js

从 wwwroot 往下 "\en-US\polyfills.js",这个路径是正确的。

假如 base href 是 "/",那路径就变成了 "/polyfills.js"。

那这个文档就要在 wwwroot\polyfills.js 才能拿到。

由此可见,加上 base href 会比较合理方便。

如果我们不喜欢它自作主张,也可以去 angular.json 里配置

这样 ng build 出来的 index.html 就变成 <base href="/" > 了。

 

DatePipe with Locale

DatePipe 会依据 locale 而变化,比如

<p>{{ today() | date }}</p>

在 zh-Hans 的情况下,它的效果是

用 formatDate 也是同理

constructor() {
  const today = new Date();
  const format = 'mediumDate';
  const locale = inject(LOCALE_ID);
  console.log(formatDate(today, format, locale)); // 2024年9月17日
}

formatDate 底层是如何做到 translate 的呢?

首先,它不是使用 JS 原生的 Intl,而是 Angular 自己写了一套逻辑 (注:不过这套逻辑正在被废弃,未来 Angular 会改成使用原生的 Intl,相关 Issue)。

通过 ɵfindLocaleData (formatDate 底层用的就是它) 我们可以拿到许多翻译内容

import { ɵfindLocaleData } from '@angular/core';

constructor() {
  console.log(ɵfindLocaleData('zh-Hans')); // 注:这里不能是 zh-Hans-CN 哦,因为 Angular 的 locale data 没有 zh-Hans-CN 只有 zh-Hans /.\
}

效果

里面包含了 formatDate 需要用到的日期格式和语言。

我们再试试看 find 其它 locale

console.log(ɵfindLocaleData('ja')); 

报错了

原因很简单,Angular 默认是不会加载所有 locale 资料的。zh-Hans 之所以可以 find 到是因为我们做了 i18n,并指定了 ng serve 是 zh-Hans。

它不自动加载,但我们可以手动替它加载。

import { registerLocaleData,  } from '@angular/common';

import jaLocaleData from '@angular/common/locales/ja';
registerLocaleData(jaLocaleData, 'ja');

import 日文资料,然后 register 到 localeData 里。

这样就可以 find 到了

console.log(ɵfindLocaleData('ja')); 

效果

关于 Time zone (时区)

Angular i18n 没有把 locale 和 time zone 关联起来。

locale "ja-JP" 不代表 time zone 就是 Asia/Tokyo。

DatePipe 使用的 time zone 是依据游览器或服务器的设置。

我们看两个场景体会一下:

  1. 用户电脑设置的 time zone 是 Asia/Singapore,然后它访问 ja-JP 官网。

    网站呈现的语言是日文,货币是日元,但 time zone 却是 Asia/Singapore。

  2. 服务器设置的 time zone 是 Asia/Singapore,在做服务端渲染时,不管是哪一个 locale,time zone 都是 Asia/Singapore。

小心 DatePipe 的 time zone 设置

DatePipe 允许我们设置 time zone:

<p>{{ today() | date : 'full' : '+05:00' }}</p>

最后一个参数就是 time zone。

或者设置全局也可以

providers: [
  {
    provide: DATE_PIPE_DEFAULT_OPTIONS,
    useValue: {
      timezone: '+05:00',
    } satisfies DatePipeConfig,
  },
],

但是!有一点要搞清楚。'+05:00' 是 time zone offset,它不是 time zone。

time zone 指的是 "Asia/Singapore","Asia/Tokyo" 这些才是 time zone。

两者的区别是,一个 time zone 可能会有多个 time zone offset。

下图是马来西亚 time zone。

在不同的年份,offset 是有可能不一样的。所以我们不能随随便便拿 offset 当 time zone 来用,否则会掉坑里去的。

DatePipe 的这个 time zone 缺陷,早就有人提 Issue – Support IANA time zone for date formatting 了,Angular 也正在解决中,感兴趣的朋友可以关注这个 Issue – Use platform Intl APIs in Angular's i18n subsystem

解决思路也很简单,使用 JS 原生的 Inlt.DateTimeFormat 就可以了,它支持 time zone。

 

CurrencyPipe with Locale

不同国家使用不同的货币,locale 除了语言,日期格式,当然也包括货币。

上一 part,我们拿 locale data 查看时,其实货币资料也包含在内。

  

我们来试试 CurrencyPipe

<p>{{ 500 | currency }}</p>

效果

夷...怎么不是人民币❓🤔

因为 Github Issue – Currency pipe and locale

简单说就是,Angular 团队觉得自动换 currency symbol 但不换 value 是不合理的,所以干脆把职责全交给开发人员。

我们有两种方法可以做到 currency pipe with locale。

第一种是使用 getLocaleCurrencyName/Code/Symbol 函数

import { getLocaleCurrencyCode, getLocaleCurrencyName, getLocaleCurrencySymbol } from '@angular/common';

const code = getLocaleCurrencyCode('zh-Hans');
const name = getLocaleCurrencyName('zh-Hans');
const symbol = getLocaleCurrencySymbol('zh-Hans');
console.log([code, name, symbol]); // ['CNY', '人民币', '¥']

这三个函数底层用的是 ɵfindLocaleData 函数,这个我们上一 part 讲解过了。

另外,getLocaleCurrencyName/Code/Symbol 目前已是废弃的状态。

Angular 建议我们使用原生的 Intl 去实现 name 和 symbol

getLocaleCurrencyCode 无法用 Intl 去实现,Angular 的建议是让我们自己写一个 mapping list 😮。

第二种方法就是听从 Angular 的建议,使用原生的 Intl。

const locale = 'zh-Hans';
const code = getLocaleCurrencyCode(locale)!; // Intl 没法从 zh-Hans 生成 CNY,我们只能自己写 mapping list 或者继续用它废弃的接口

const symbolFormatter = new Intl.NumberFormat(locale, {
  style: 'currency',
  currency: code,
  currencyDisplay: 'symbol'
});
const symbol = symbolFormatter.formatToParts(0).find(part => part.type === 'currency')!.value; // ¥

const displayNames = new Intl.DisplayNames([locale], { type: 'currency' });
const name = displayNames.of(code);

console.log([code, symbol, name]); // ['CNY', '¥', '人民币']

 

 

总结

本篇简单介绍了 Angular i18n 方案,没有深入讲解原理,也没有逛源码。

因为我个人从来没有在项目中使用过它,希望未来有机会吧,到时再深入研究研究。

另外,日常项目中,我使用的是 ASP.NET Core – Globalization & Localization

对比它俩,最大的区别是,ASP.NET Core 的翻译文档是拆散的,每一个页面,甚至每一个组件都有一个翻译文档。

而不像 Angular 那样把整个项目每一个组件资料通通放到了同一个文档里。

感觉 Angular 维护起来可能会比较乱,尤其是当网站或应用程序内容有更动的时候。

 

 

目录

上一篇 Angular 18+ 高级教程 – Memory leak, unsubscribe, onDestroy

下一篇 Angular 19 功能介绍

想查看目录,请移步 Angular 18+ 高级教程 – 目录

喜欢请点推荐👍,若发现教程内容以新版脱节请评论通知我。happy coding 😊💻

 

posted @ 2024-09-17 02:41  兴杰  阅读(356)  评论(0编辑  收藏  举报