Angular13 Angular 预先编译

2024-02-25 开发教程 Angular13 匿名 3

预先(AOT)编译器

Angular 应用主要由组件及其 HTML 模板组成。由于浏览器无法直接理解 Angular 所提供的组件和模板,因此 Angular 应用程序需要先进行编译才能在浏览器中运行。

在浏览器下载和运行代码之前的编译阶段,Angular 预先(AOT)编译器会先把你的 Angular HTML 和 TypeScript 代码转换成高效的 JavaScript 代码。在构建期间编译应用可以让浏览器中的渲染更快速。

本指南中解释了如何指定元数据,并使用一些编译器选项以借助 AOT 编译器来更有效的编译应用。

观看 Alex Rickabaugh 在 AngularConnect 2019 解释 Angular 编译器的演讲

下面是你可能要使用 AOT 的部分原因。

原因

详情

更快的渲染方式

使用 AOT,浏览器会下载应用程序的预编译版本。浏览器加载可执行代码,以便立即渲染应用程序,而无需等待先编译应用程序。

更少的异步请求

编译器在应用程序 JavaScript 中内联外部 HTML 模板和 CSS 样式表,消除对这些源文件的单个 ajax 请求。

更小的 Angular 框架下载大小

如果应用程序已被编译,则无需下载 Angular 编译器。编译器大约是 Angular 本身的一半,因此省略它会大大减少应用程序的体积。

及早检测模板错误

AOT 编译器会在用户看到之前在构建步骤中检测并报告模板绑定错误。

更好的安全性

AOT 会在 HTML 模板和组件提供给客户端之前就将它们编译为 JavaScript 文件。由于没有要读取的模板,也没有危险的客户端 HTML 或 JavaScript 求值,因此注入攻击的机会更少。

选择编译器

Angular 提供了两种方式来编译你的应用:

ANGULAR 编译方式

详情

即时 (JIT)

当运行时在浏览器中编译你的应用程序。在 Angular 8 之前,这是默认值。

预先 (AOT)

在构建时编译你的应用程序和库。这是从 Angular 9 开始的默认值。

当运行 CLI 命令 ​ng build​ (只构建) 或 ​ng serve​ (构建并启动本地服务器) 时,编译类型(JIT 或 AOT)取决于你在 ​angular.json​ 中的构建配置所指定的 ​aot ​属性。默认情况下,对于新的 CLI 应用,其 ​aot ​为 ​true​。

AOT 工作原理

Angular AOT 编译器会提取元数据来解释应由 Angular 管理的应用程序部分。你可以在装饰器(比如 ​@Component()​ 和 ​@Input()​)中显式指定元数据,也可以在被装饰的类的构造函数声明中隐式指定元数据。元数据告诉 Angular 要如何构造应用程序类的实例并在运行时与它们进行交互。

在下列范例中,​@Component()​ 元数据对象和类的构造函数会告诉 Angular 如何创建和显示 ​TypicalComponent ​的实例。

@Component({
selector: 'app-typical',
template: '<div>A typical component for {{data.name}}</div>'
})
export class TypicalComponent {
@Input() data: TypicalData;
constructor(private someService: SomeService) { … }
}

Angular 编译器只提取一次元数据,并且为 ​TypicalComponent ​生成一个工厂。当它需要创建 ​TypicalComponent ​的实例时,Angular 调用这个工厂,工厂会生成一个新的可视元素,并且把它(及其依赖)绑定到组件类的一个新实例上。

编译的各个阶段

AOT 编译分为三个阶段。

阶段

详情

1

代码分析

在此阶段,TypeScript 编译器和AOT 收集器会创建源代码的表示。收集器不会尝试解释它收集的元数据。它会尽可能地表示元数据,并在检测到元数据语法违规时记录错误。

2

代码生成

在此阶段,编译器的 StaticReflector会解释在阶段 1 收集的元数据,对元数据执行额外的验证,如果检测到违反元数据限制,则会抛出错误。

3

模板类型检查

在此可选阶段,Angular 模板编译器使用 TypeScript 编译器来验证模板中的绑定表达式。你可以通过设置 fullTemplateTypeCheck配置选项来明确启用此阶段。

元数据的限制

你只能使用 TypeScript 的一个子集书写元数据,它必须满足下列限制:

  • 表达式语法只支持 JavaScript 的一个有限的子集
  • 只能引用代码收缩后导出的符号
  • 只能调用编译器支持的函数

  • 被装饰和用于数据绑定的类成员必须是公共(public)的

关于准备 AOT 编译应用程序的其它准则和说明,参阅 Angular:编写 AOT 友好的应用程序

AOT 编译中的错误通常是由于元数据不符合编译器的要求而发生的(下面将更全面地介绍)。

配置 AOT 编译

你可以在 ​tsconfig.json​ TypeScript 配置文件中提供控制编译过程的选项。

阶段 1:分析

TypeScript 编译器会做一些初步的分析工作,它会生成类型定义文件​.d.ts​,其中带有类型信息,Angular 编译器需要借助它们来生成代码。 同时,AOT 收集器(collector) 会记录 Angular 装饰器中的元数据,并把它们输出到​.metadata.json​文件中,和每个 ​.d.ts​ 文件相对应。

你可以把 ​.metadata.json​ 文件看做一个包括全部装饰器的元数据的全景图,就像抽象语法树 (AST)一样。

Angular 的 ​schema.ts​ 会将 JSON 格式描述为 TypeScript 接口的集合。

表达式语法限制

AOT 收集器只能理解 JavaScript 的一个子集。定义元数据对象时要遵循下列语法限制:

语法

范例

对象字面量

{cherry: true, apple: true, mincemeat: false}

数组字面量

['cherries', 'flour', 'sugar']

展开数组字面量

['apples', 'flour', ...]

函数调用

bake(ingredients)

新建对象

new Oven()

属性访问

pie.slice

数组索引访问

ingredients[0]

引用标识符

Component

模板字符串

`pie is ${multiplier} times better than cake`

字符串字面量

'pi'

数字字面量

3.14153265

逻辑字面量

true

null 字面量

null

受支持的前缀运算符

!cake

受支持的二元运算符

a+b

条件运算符

a ? b : c

括号

(a+b)

如果表达式使用了不支持的语法,收集器就会往 ​.metadata.json​ 文件中写入一个错误节点。稍后,如果编译器用到元数据中的这部分内容来生成应用代码,它就会报告这个错误。

如果你希望 ​ngc ​立即汇报这些语法错误,而不要生成带有错误信息的 ​.metadata.json​ 文件,可以到 TypeScript 的配置文件中设置 ​strictMetadataEmit ​选项。

"angularCompilerOptions": {

"strictMetadataEmit" : true
}

Angular 库通过这个选项来确保所有的 Angular ​.metadata.json​ 文件都是干净的。当你要构建自己的代码库时,这也同样是一项最佳实践。

不要有箭头函数

AOT 编译器不支持函数表达式箭头函数,也叫 lambda 函数。

考虑如下组件装饰器:

@Component({

providers: [{provide: server, useFactory: () => new Server()}]
})

AOT 的收集器不支持在元数据表达式中出现箭头函数 ​() => new Server()​。它会在该函数中就地生成一个错误节点。稍后,当编译器解释该节点时,它就会报告一个错误,让你把这个箭头函数转换成一个导出的函数。

你可以把它改写成这样来修复这个错误:

export function serverFactory() {
return new Server();
}
@Component({

providers: [{provide: server, useFactory: serverFactory}]
})

在版本 5 和更高版本中,编译器会在发出 ​.js​ 文件时自动执行此重写。

代码折叠

编译器只会解析到已导出符号的引用。收集器可以在收集期间执行表达式,并用其结果记录到 ​.metadata.json​ 中(而不是原始表达式中)。这样可以让你把非导出符号的使用限制在表达式中。

比如,收集器可以估算表达式 ​1 + 2 + 3 + 4​ 并将其替换为结果 ​10​。这个过程称为​折叠​。可以用这种方式简化的表达式是可折叠的。

收集器可以计算对模块局部变量的 ​const ​声明和初始化过的 ​var ​和 ​let ​声明,并从 ​.metadata.json​ 文件中移除它们。

考虑下列组件定义:

const template = '<div>{{hero.name}}</div>';
@Component({
selector: 'app-hero',
template: template
})
export class HeroComponent {
@Input() hero: Hero;
}

编译器不能引用 ​template ​常量,因为它是未导出的。但是收集器可以通过内联 ​template ​常量的方式把它折叠进元数据定义中。最终的结果和你以前的写法是一样的:

@Component({
selector: 'app-hero',
template: '<div>{{hero.name}}</div>'
})
export class HeroComponent {
@Input() hero: Hero;
}

这里没有对 ​template ​的引用,因此,当编译器稍后对位于 ​.metadata.json​ 中的收集器输出进行解释时,不会再出问题。

你还可以通过把 ​template ​常量包含在其它表达式中来让这个例子深入一点:

const template = '<div>{{hero.name}}</div>';
@Component({
selector: 'app-hero',
template: template + '<div>{{hero.title}}</div>'
})
export class HeroComponent {
@Input() hero: Hero;
}

收集器把该表达式缩减成其等价的已折叠字符串:

'<div>{{hero.name}}</div><div>{{hero.title}}</div>'

可折叠的语法

下表中描述了收集器可以折叠以及不能折叠哪些表达式:

语法

可折叠?

对象字面量

数组字面量

展开数组字面量

函数调用

新建对象

属性访问

如果目标对象也是可折叠的,则是

数组索引访问

如果目标数组和索引都是可折叠的,则是

引用标识符

如果引用的是局部标识符,则是

没有替换表达式的模板字符串

有替换表达式的模板字符串

如果替换表达式是可折叠的,则是

字符串字面量

数字字面量

逻辑字面量

null 字面量

受支持的前缀运算符

如果操作数是可折叠的,则是

受支持的二元运算符

如果左操作数和右操作数都是可折叠的,则是

条件运算符

如果条件是可折叠的,则是

括号

如果表达式是可折叠的,则是

如果表达式是不可折叠的,那么收集器就会把它作为一个 AST(抽象语法树)写入 ​.metadata.json​ 中,留给编译器去解析。

阶段 2:代码生成

收集器不会试图理解它收集并输出到 ​.metadata.json​ 中的元数据,它所能做的只是尽可能准确的表述这些元数据,并在检测到元数据中的语法违规时记录这些错误。解释这些 ​.metadata.json​ 是编译器在代码生成阶段要承担的工作。

编译器理解收集器支持的所有语法形式,但是它也可能拒绝那些虽然语法正确但语义违反了编译器规则的元数据。

公共符号

编译器只能引用已导出的符号。

  • 带有装饰器的类成员必须是公开的。你不可能把一个私有或内部使用的属性做成 ​@Input()​。
  • 数据绑定的属性同样必须是公开的

支持的类和函数

只要语法有效,收集器就可以用 ​new ​来表示函数调用或对象创建。但是,编译器在后面可以拒绝生成对特定函数的调用或对特定对象的创建。

编译器只能创建某些类的实例,仅支持核心装饰器,并且仅支持对返回表达式的宏(函数或静态方法)的调用。

编译器动作

详情

新建实例

编译器只允许创建来自 @angular/coreInjectionToken类创建实例。

支持的装饰器

编译器只支持来自 ​@angular/core​ 模块的 Angular 装饰器的元数据。

函数调用

工厂函数必须导出为命名函数。AOT 编译器不支持用 Lambda 表达式(箭头函数)充当工厂函数。

函数和静态方法调用

收集器接受任何只包含一个 ​return ​语句的函数或静态方法。编译器也支持在返回表达式的函数或静态函数中使用宏。

考虑下面的函数:

export function wrapInArray<T>(value: T): T[] {
return [value];
}

你可以在元数据定义中调用 ​wrapInArray​,因为它所返回的表达式的值满足编译器支持的 JavaScript 受限子集。

你还可以这样使用 ​wrapInArray()​:

@NgModule({
declarations: wrapInArray(TypicalComponent)
})
export class TypicalModule {}

编译器会把这种用法处理成你以前的写法:

@NgModule({
declarations: [TypicalComponent]
})
export class TypicalModule {}

Angular 的 ​RouterModule ​导出了两个静态宏函数 ​forRoot ​和 ​forChild​,以帮助声明根路由和子路由。 查看这些方法的源码,以了解宏函数是如何简化复杂的 ​NgModule ​配置的。

元数据重写

编译器会对含有 ​useClass​、​useValue​、​useFactory ​和 ​data ​的对象字面量进行特殊处理,把用这些字段之一初始化的表达式转换成一个导出的变量,并用它替换该表达式。这个重写表达式的过程,会消除它们受到的所有限制,因为编译器并不需要知道该表达式的值,它只要能生成对该值的引用就行了。

你可以这样写:

class TypicalServer {
}
@NgModule({
providers: [{provide: SERVER, useFactory: () => TypicalServer}]
})
export class TypicalModule {}

如果不重写,这就是无效的,因为这里不支持 Lambda 表达式,而且 ​TypicalServer ​也没有被导出。为了支持这种写法,编译器自动把它重写成了这样:

class TypicalServer {
}
export const θ0 = () => new TypicalServer();
@NgModule({
providers: [{provide: SERVER, useFactory: θ0}]
})
export class TypicalModule {}

这就让编译器能在工厂中生成一个对 ​θ0​ 的引用,而不用知道 ​θ0​ 中包含的值到底是什么。

编译器会在生成 ​.js​ 文件期间进行这种重写。它不会重写 ​.d.ts​ 文件,所以 TypeScript 也不会把这个变量当做一项导出,因此也就不会污染 ES 模块中导出的 API。

阶段 3:模板类型检查

Angular 编译器最有用的功能之一就是能够对模板中的表达式进行类型检查,在由于出错而导致运行时崩溃之前就捕获任何错误。在模板类型检查阶段,Angular 模板编译器会使用 TypeScript 编译器来验证模板中的绑定表达式。

通过在该项目的 TypeScript 配置文件中的 ​"angularCompilerOptions"​ 中添加编译器选项 ​"fullTemplateTypeCheck"​,可以显式启用本阶段。

当模板绑定表达式中检测到类型错误时,进行模板验证时就会生成错误。这和 TypeScript 编译器在处理 ​.ts​ 文件中的代码时报告错误很相似。

比如,考虑下列组件:

@Component({
selector: 'my-component',
template: '{{person.addresss.street}}'
})
class MyComponent {
person?: Person;
}

这会生成如下错误:

my.component.ts.MyComponent.html(1,1): : Property 'addresss' does not exist on type 'Person'. Did you mean 'address'?

错误信息中汇报的文件名 ​my.component.ts.MyComponent.html​ 是一个由模板编译器生成出的合成文件,用于保存 ​MyComponent ​类的模板内容。编译器永远不会把这个文件写入磁盘。这个例子中,这里的行号和列号都是相对于 ​MyComponent ​的 ​@Component​ 注解中的模板字符串的。如果组件使用 ​templateUrl ​来代替 ​template​,这些错误就会在 ​templateUrl ​引用的 HTML 文件中汇报,而不是这个合成文件中。

错误的位置是从包含出错的插值表达式的那个文本节点开始的。如果错误是一个属性绑定,比如 ​[value]="person.address.street"​,错误的位置就是那个包含错误的属性的位置。

验证使用 TypeScript 类型检查器和提供给 TypeScript 编译器的选项来控制类型验证的详细程度。比如,如果指定了 ​strictTypeChecks​,则会报告错误以及下述错误消息。

my.component.ts.MyComponent.html(1,1): : Object is possibly 'undefined'

类型窄化

在 ​ngIf ​指令中使用的表达式用来在 Angular 模板编译器中窄化联合类型,就像 TypeScript 中的 ​if ​表达式一样。比如,要在上述模板中消除 ​Object is possibly 'undefined'​ 错误,可以把它改成只在 ​person ​的值初始化过的时候才生成这个插值。

@Component({
selector: 'my-component',
template: ' {{person.address.street}} '
})
class MyComponent {
person?: Person;
}

使用 ​*ngIf​ 能让 TypeScript 编译器推断出这个绑定表达式中使用的 ​person ​永远不会是 ​undefined​。

非空类型断言操作符

使用 非空类型断言操作符 可以在不方便使用 ​*ngIf​ 或 当组件中的某些约束可以确保这个绑定表达式在求值时永远不会为空时,防止出现 ​Object is possibly 'undefined'​ 错误。

在下面的例子中,​person ​和 ​address ​属性总是一起出现的,如果 ​person ​非空,则 ​address ​也一定非空。没有一种简便的写法可以向 TypeScript 和模板编译器描述这种约束。但是这个例子中使用 ​address!.street​ 避免了报错。

@Component({
selector: 'my-component',
template: '<span *ngIf="person"> {{person.name}} lives on {{address!.street}} </span>'
})
class MyComponent {
person?: Person;
address?: Address;
setData(person: Person, address: Address) {
this.person = person;
this.address = address;
}
}

应该保守点使用非空断言操作符,因为将来对组件的重构可能会破坏这个约束。

这个例子中,更建议在 ​*ngIf​ 中包含对 ​address ​的检查,代码如下:

@Component({
selector: 'my-component',
template: '<span *ngIf="person && address"> {{person.name}} lives on {{address.street}} </span>'
})
class MyComponent {
person?: Person;
address?: Address;
setData(person: Person, address: Address) {
this.person = person;
this.address = address;
}
}