Angular 国际化标记机制深解:认识 @angular/localize 与 $localize 标签模板
【免费下载链接】angularDeliver web apps with confidence 🚀项目地址: https://gitcode.com/GitHub_Trending/an/angular
导读
@angular/localize是 Angular 官方用于“应用本地化(localization)”的辅助包,其核心是把代码中需要翻译的字符串,通过一个名为$localize的模板字符串标签(tagged template)进行“标记”,再由构建期工具或运行期函数统一完成翻译替换。本文以仓库中 packages/localize/PACKAGE.md 为骨架,结合@angular/localize包的源码实现,讲清楚$localize为什么能同时扮演“运行时翻译函数”与“可被静态后处理工具替换的全局标记”,并给出元数据块、占位符命名、三种处理场景等完整语法与工程实践。
读完本文,你将掌握:如何为组件模板中的i18n文本与代码中的 TS 字符串统一打上翻译标记、$localize的编译期/运行期两种翻译路径如何工作、以及loadTranslations等运行时 API 的正确使用方式。
一、包的定位与安装前提
在 Angular 仓库中,packages/localize对应发布到 npm 上的@angular/localize包。官方 PACKAGE 文档对它的定位只有一句话,但信息量很足:
@angular/localize包包含用于本地化你的应用的辅助函数与工具。
注意这里的“工具”是实打实的:查看 packages/localize/package.json 可以看到,该包内置了三组命令行工具:
"bin": { "localize-translate": "./tools/bundles/src/translate/cli.js", "localize-extract": "./tools/bundles/src/extract/cli.js", "localize-migrate": "./tools/bundles/src/migrate/cli.js" }分别对应翻译替换、消息提取、代码迁移三个场景,它们的实现位于 packages/localize/tools/ 目录。
官方文档特别强调安装方式与时机:
- 只有当你的应用需要把文本标记为可翻译时,才应执行
ng add @angular/localize。 - 从 package.json 的
ng-add配置可以看出,该包通过 Angular CLI 的ng add安装时会被保存到devDependencies("save": "devDependencies"),因为它的角色更偏向构建期工具链而非运行期业务依赖。
二、核心机制:用模板字符串标签$localize标记可翻译文本
整个本地化方案围绕一个概念展开:使用被称为$localize的模板字符串标签(template literal tag)来“标记”代码中的字符串。在 index.ts 的全局类型声明中,$localize被定义为ɵLocalizeFn:
const $localize: ɵLocalizeFn;它接收模板字符串的静态片段与插值表达式,返回处理后的字符串。用法是最直观的模板标签形式:
const message = $localize`Hello, World!`;这段代码并不“翻译”任何东西,它只是在告诉本地化工具链:“这个字符串需要进入待翻译清单”。至于翻译何时、以何种方式发生,则取决于下面要讲的$localize的双重身份。
三、$localize的双重身份:运行函数与“压缩不死的”全局标记
PACKAGE 文档点出了这一设计最精妙之处,也是整个方案的根基:
这个
$localize标识符可以是一个真实的函数,能够在浏览器里于运行时完成翻译;但更重要的是,它是一个能够扛过代码压缩(minification)的全局标识符。
这意味着它同时具备两种完全不同的用途:
- 运行时翻译函数:代码中真正调用它,翻译在浏览器端执行;
- 静态标记:它仅仅作为源码中的“路标”,供某个静态后处理工具在部署前把原文替换成译文。
“全局标识符在压缩后依然存活”是后一种用途成立的前提——正因为压缩器无法重命名全局引用,后处理工具才能稳定地识别出每一处标记。文档给出了一个非常直观的例子:源码中写的:
warning = $localize`${this.process} is not right`;经过编译期替换后,可能变成:
warning = '' + this.process + ", n'est pas bon.";此时代码里所有对$localize的引用都被彻底移除,渲染翻译后的文本零运行时开销(zero runtime cost)。这就是$localize被设计成“既可运行、又可作为纯标记被擦除”的根本原因——它把选择权交给了构建管道。
从源码看,运行期翻译时的函数本体位于 localize.ts:
export const $localize: LocalizeFn = function ( messageParts: TemplateStringsArray, ...expressions: readonly any[] ) { if ($localize.translate) { const translation = $localize.translate(messageParts, expressions); messageParts = translation[0]; expressions = translation[1]; } let message = stripBlock(messageParts[0], messageParts.raw[0]); for (let i = 1; i < messageParts.length; i++) { message += expressions[i - 1] + stripBlock(messageParts[i], messageParts.raw[i]); } return message; };可以看到:只有挂载了translate函数(例如通过loadTranslations注入,见下文)时才会走翻译分支;否则就按普通模板字符串拼接求值,并把每个 message part 开头的元数据块(colon block)用stripBlock剥掉。
四、与模板编译器的衔接:i18n属性最终也变成$localize
一个常被忽视的事实是:Angular 模板编译器本身并不做翻译,它只负责把模板中的i18n文本翻译成$localize标签字符串。
也就是说,写模板时的做法与在 TS 代码里手动打标记完全同构。例如下面这段模板:
<h1 i18n>Hello, World!</h1>会被编译成类似下面的指令调用序列:
ɵɵelementStart(0, 'h1'); // <h1> ɵɵi18n(1, $localize`Hello, World!`); // Hello, World! ɵɵelementEnd(); // </h1>这一步的意义在于:无论翻译文本来自组件模板还是 TypeScript 源码,Angular 编译器完成工作之后,所有被打上i18n属性的模板文本都已经统一成了$localize标签字符串。于是后续的处理——无论是提取、编译期内联还是运行期翻译——只需面对同一种语法形态即可,模板和代码两条路径在此汇合。
这与@angular/localize依赖@angular/compiler与@angular/compiler-cli(见 package.json 的peerDependencies)的事实相互印证:模板侧解析i18n的能力来自编译器,而$localize的运行时实现与工具链则由本包提供。
五、$localize的进阶语法:元数据块、占位符命名与冒号转义
PACKAGE 文档只给出了最基础的用法,但 index.ts 与 localize.ts 中的全局类型 JSDoc 把完整语法都写明了。这些语法与模板中i18n标记的写法完全一致(文档明确注明了这一点),值得完整掌握。
5.1 元数据块:meaning / description / id
可以为本地化字符串指定可选的meaning、description与id,做法是在消息文本前追加一段冒号分隔的元数据块,格式为:meaning|description@@id::
$localize`:meaning|description@@id:source message text`; // 也可以只提供部分元数据 $localize`:meaning|:source message text`; $localize`:description:source message text`; $localize`:@@id:source message text`;meaning与description用来辅助翻译人员理解上下文(同一句原文在不同语境下可能含义不同);@@id用于强制指定该消息的消息 ID,覆盖默认的哈希 ID。
这也是为什么运行时$localize实现里需要stripBlock:处理每个 message part 时都要把这段前置的元数据块从最终渲染文本中剥离(见 localize.ts,冒号块由BLOCK_MARKER = ':'界定)。
5.2 占位符的自动命名与手动命名
如果模板字符串里含表达式,占位符会被自动命名。例如:
$localize `Hi ${name}! There are ${items.length} items.`;会生成消息源(message-source):
Hi {$PH}! There are {$PH_1} items.不过文档给出的推荐实践是为每个表达式手动命名占位符——方法是在表达式之后紧跟一段用:包裹的占位符名。这些占位符名随后会被从最终本地化字符串中剔除。例如把items.length命名为itemCount:
$localize `There are ${items.length}:itemCount: items.`;手动命名之所以是“推荐实践”,是因为翻译人员看到的占位符(如{$itemCount})将比自动生成的{$PH}、{$PH_1}可读得多,能显著降低翻译歧义。
5.3 冒号转义:\:
冒号:是元数据块的界定符,因此当文本内容本身就出现在“可能被误认为元数据块”的位置时,就必须用反斜杠转义:
- 带元数据块时无需转义(
::写法用于在描述之后让正文以冒号开头):
// message 带元数据块,因此无需转义冒号 $localize `:some description::this message starts with a colon (:)`; // 没有元数据块,开头冒号必须转义 $localize `\:this message starts with a colon (:)`;- 具名替换后无需转义,匿名替换后的冒号必须转义:
// 具名替换后无需转义(:紧跟在具名块之后是合法的) $localize `${label}:label:: ${}`; // 匿名替换后冒号会被当成块起始标记,必须转义 $localize `${label}\: ${}`;实现上,stripBlock判断是否剥块时读取的是messageParts.raw[i](保留反斜杠的原始片段),这正是反斜杠转义能生效的底层原因——见 localize.ts 中对 raw message part 的检查。
六、三种处理场景:编译期内联 / 运行期求值 / 透传求值
PACKAGE 文档与 index.ts 的 JSDoc 都一致地把本地化字符串的处理方式归纳为三种场景,理解它们就理解了整套国际化方案的取舍:
| 场景 | 机制 | 适用场景 |
|---|---|---|
| 编译期内联(compile-time inlining) | 由转译器在编译期转换$localize标签:移除标签,用提供的翻译集合中的译文直接替换模板字符串字面量 | 需要零运行时成本、发布纯静态多语言包时;前文warning = '' + this.process + ", n'est pas bon.";即属此类 |
| 运行期求值(run-time evaluation) | $localize是一个真实的运行函数,把模板字符串的各个部分(静态文本与表达式)替换、重排为运行期加载的翻译文本 | 希望按需在浏览器中加载某一种语言,且允许保留标签引用 |
| 透传求值(pass-through evaluation) | $localize是运行函数,但不做任何翻译,只是按原始模板字符串求值 | 开发阶段,或当前场景不需要翻译这些标签文本 |
在 Angular 仓库源码中这三种路径都能找到对应物:
- 编译期内联与消息提取由
@angular/localize/tools(即 package.json 中localize-translate/localize-extract命令背后的实现,见 packages/localize/tools/)承担; - 运行期求值与透传求值都落在 localize.ts 的
$localize函数本体上——当$localize.translate未挂载时(典型的开发态/透传态),函数直接拼接原文;挂载了翻译函数后才会进入真正的翻译分支。
七、运行时翻译 API:loadTranslations与clearTranslations
如果你的应用走“运行期求值”路线(不在编译期做死译文),就需要把翻译文件在浏览器中加载给$localize。这正是 packages/localize/src/translate.ts 里两个公开 API 的职责,它们也通过 index.ts 对外导出:
7.1loadTranslations(translations)
作用是把一组翻译挂到全局$localize对象上。其核心行为(源码中均有明确注释):
- 翻译表以消息 ID(MessageId)为键,译文值为内容;
- 这些消息 ID 与翻译文本的格式,与提取消息时生成的 “simple JSON” 翻译文件完全一致;
- 占位符在消息中采用
{$PLACEHOLDER_NAME}语法渲染。
从 translate.ts 的源码可见两个值得注意的实现细节:
- 首次调用时会先把
$localize.translate挂载为内部translate函数(因为只有当存在翻译时,运行期翻译才被激活); - 翻译存储容器使用
Object.create(null)创建的空原型对象。注释解释得很清楚:消息 ID 是逐字取自翻译文件的键,可能恰好是__proto__这类字符串,若用普通对象作为 map,对该键的赋值会触发继承的__proto__setter 造成污染甚至抛错——null 原型对象能把它当作普通键安全存储。
7.2 一个完整的运行时消息 ID 示例
翻译表键(消息 ID)与译文的形态如下(来自 translate.ts 的 JSDoc 示例)。给定模板:
<div i18n>pre<span>inner-pre<b>bold</b>inner-post</span>post</div>它在translationsmap 中对应的条目为:
{ "2932901491976224757": "pre{$START_TAG_SPAN}inner-pre{$START_BOLD_TEXT}bold{$CLOSE_BOLD_TEXT}inner-post{$CLOSE_TAG_SPAN}post" }可以看到,HTML 元素的开闭标签都变成了带明确语义的占位符({$START_TAG_SPAN}、{$CLOSE_TAG_SPAN}等),这让译文能够在必要时重排或删除这些片段(比如某些语言里行内元素的语序与英文不同)。
7.3 与“运行时翻译”有关的三个重要约束
源码注释还明确了三个经常被踩坑的行为边界:
- 翻译函数可能重排替换表达式——
translate的签名返回[TemplateStringsArray, readonly any[]],即静态片段与表达式数组都可能被重新排列(见 translate.ts 与TranslateFn定义),所以不要假设原来的插值顺序在译文中保持不变。 - 加载新翻译不会回溯改写已经翻译过的消息——
$localize消息只在标签字符串首次被求值时处理一次;在应用生命周期后期加载新翻译,并不会改变那些已被翻译消息的输出。 - 翻译挂在全局对象上、跨应用共享——由于
TRANSLATIONS依附于全局$localize,同一页面里运行的所有应用(例如多个独立 Angular 应用并存)会共享同一份翻译表。
7.4clearTranslations():复位运行时翻译
clearTranslations用于把所有已加载进内存的翻译清除干净(translate.ts)。实现上它把$localize.translate置回undefined、TRANSLATIONS重置为空原型对象——也就是说它会连带关闭运行期翻译开关,让$localize回到透传求值状态。当你需要在测试之间隔离翻译状态,或实现语言切换前的重置时,这个 API 就派上了用场。
import {loadTranslations, clearTranslations} from '@angular/localize'; loadTranslations({ '2932901491976224757': '译文内容...', }); // ...应用运行... clearTranslations();八、工程集成全景:从一个标记到一条完整流水线
把上文串联起来,一个完整的本地化工作流大致是:
- 打标:模板中用
i18n属性,TS 代码中用$localize标签,或先用localize-migrate对旧版@angular/i18n用法做迁移; - 提取:构建期运行
localize-extract,把所有$localize标记提取为翻译源文件(如 XLIFF、XMB 或 simple JSON); - 翻译:交给翻译团队产出各语言的翻译文件;
- 落地二选一:
- 编译期:用
localize-translate(CLI)或 Angular CLI 的--localize构建为各语言静态包——此时代码中的$localize被彻底擦除,译文内联,零运行时成本; - 运行期:引入
@angular/localize/init(package.json 的sideEffects中专门列出了./fesm2022/init.mjs,表明该入口必须在构建时保留副作用),再调用loadTranslations()在浏览器端注入翻译。
- 编译期:用
ng add @angular/localize之所以被作为第一步推荐,正是因为它会帮你把上述工具链与入口完整接入 Angular 工程配置,避免手动接线。相关的工具实现与说明可以继续在仓库的 packages/localize/tools/ 与 packages/localize/schematics/ 中深入研读。
九、总结
@angular/localize的设计核心,是把“标记可翻译文本”与“执行翻译”彻底解耦:
$localize是一个合法可运行的标签函数,但它被刻意设计成“在压缩后依然存活的全局标识符”,从而也能纯粹充当静态标记,供编译期工具在部署前完成译文内联并擦除自身,达成零运行时成本;- Angular 模板编译器不直接翻译,而是把所有
i18n文本编译为$localize标签字符串,让模板与 TS 代码在工具链中汇合于同一语法; - 元数据块、具名占位符与冒号转义构成了这套标记语法的完整规则;
- 编译期内联、运行期求值、透传求值三种处理场景,加上
loadTranslations/clearTranslations运行 API 与localize-*三组 CLI,共同支撑起从打标、提取到多语言产出的整条本地化流水线。
无论你的应用最终选择编译期多语言静态包,还是运行期按需加载语言,$localize都是整条链路上不可绕过的锚点——理解了它,就掌握了 Angular 本地化的底层语言。
【免费下载链接】angularDeliver web apps with confidence 🚀项目地址: https://gitcode.com/GitHub_Trending/an/angular
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考