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 哦。
它会做几件事:
-
package.json
安装了 @angular/localize package。
注意看,它是安装到了 devDependencies 里哦。
这也意味着,Angular i18n 是在 compile 阶段完成的,而不是在 runtime。
-
angular.json
多了一个 polyfill。我们刚说 i18n 发生在 compile 阶段,但也不完全。有一小部分还是需要 runtime 配合完成。
这个 polyfill 就用在这些地方。
-
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" } } },
还需要设置 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 是依据游览器或服务器的设置。
我们看两个场景体会一下:
-
用户电脑设置的 time zone 是 Asia/Singapore,然后它访问 ja-JP 官网。
网站呈现的语言是日文,货币是日元,但 time zone 却是 Asia/Singapore。
- 服务器设置的 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 😊💻