Angular 的 预先(AOT)编译器

连翰
2023-12-01

预先编译器, 英文全称是 Ahead-of-time compiler。由于 Angular 9 新版本的到来,CLI 应用程序默认情况下以 AOT 模式进行编译,其中包括模板类型检查,因此,我觉得有必要好好理解并总结一下 AOT 编译器的原理和流程。如需参考官方文档请前往 Angular - 预先(AOT)编译器

大家都知道,Angular 的应用主要是由 components 和 HTML templates 组成。components 和 HTML templates 是 declarative 的代码, 而浏览器只接受 imperative 的代码 ( JavaScript ),因此它们无法被浏览器直接理解。这时 Angular 就需要自己的 compiler 来编译这些 declarative 的代码。那么,我们该什么时候编译这些代码呢?

Angular 给出了两种方案/模式:即时编译( JIT )预先( AOT )编译。顾名思义,即时编译,也就是 Just-in-time,是指编译器会在 runtime 编译应用。而预先编译则是指在构建( build )应用的时候进行编译。JIT 很好理解,但是为什么 Angular 会需要 AOT 这个编译模式呢?

使用 AOT 的原因

  1. 更快的渲染速度。就像 AOT 模式的定义所讲的一样,由于 declarative 的代码会被预先编译,浏览器可以直接使用这些可以直接执行的 imperative 代码,立即给用户呈现应用。
  2. 更早检查出 template 错误。由于需要预编译,AOT compiler 会在构建阶段就检测到 template 的绑定错误,并把这些错误提前报告给我们写程序的人,而不是等到 runtime 编译才让用户发现这些错误。
  3. 更高的 client-side 安全性。由于 templates 和 components 在给 client side 接触到之前就被预先编译成了 JavaScript,client side 没有办法读取到 templates,HTML 和 JavaScript 的解析也不会存在很大的危险性,这样也让 Client-side injection attacks 也会变得更加困难。
  4. 需要下载的 Angular Framework size 变得更小。由于应用程序被预编译,Angular 编译器也就无需被下载。应用程序负荷( payload )大大减少。
  5. 更少的异步请求。AOT 编译器会内联 HTML template 和 CSS style sheets,其中的单独的 ajax 请求也会随之被消除。

知道了使用 AOT 的原因,我们现在可以来了解一下 AOT 的编译流程。

代码分析( Code Analysis )

首先,在代码分析阶段,AOT 收集器( collector )起到来关键的作用。顾名思义,AOT 收集器负责收集整理 Angular 装饰器( decorators )的 元数据( metadata )。完成对代码的分析之后,AOT 编译器会为每一个 .ts 文件生成 .d.ts 文件,.d.ts 文件又叫类型定义文件。该文件包含原 .ts 文件中属性方法的类型信息,它可以帮助编译器生成 imperative 代码。

AOT 收集器在该阶段会把收集到的 metadata 信息输出到 .metadata.json 的文件中。每个 .d.ts 文件对应一个 .metadata.json 文件。那么,Angular metadata 有什么作用呢?

  • 告诉 Angular 如何创建应用 classes 的实例。
  • 告诉 Angular 如何在 runtime 跟这些实例进行 interaction。

形象地讲,.metadata.json 文件可以被看成包含了一个装饰器全部 metadata 的全景图,就像是 Abstract syntax tree ( AST )一样。.metadata.json 文件包含了原 .ts 文件中被 template 编译器需要的,但没有在 .d.ts 文件里的信息。举个例子,这些信息可能包括 component 里的 template。

值得一提的是,AOT 编译器其实可以被看作 JavaScript 的一个子集,它不能完整地理解 JavaScript 语法。举个例子,AOT 编译器不支持 Lambda 函数,也就是箭头函数。假设我们想为一个 service 创建一个自己的 provider:

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

在这个例子里, provider 关键词接受了一个独一无二的 Injection Token,而 useFactory 接受了一个返回 service 实例的 Lambda 函数。这段代码没有任何错误,但是 AOT 编译器却无法理解 Lambda 表达式。在 Angular 5 之前,编译器会抛出一个错误。在 Angular 5 及之后的版本,它会自动把上面那段程序转换为以下代码:

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

@Component({
  ...
  providers: [{provide: server, useFactory: serverFactory}]
})

更值得一提的是,你可以注意到上面例子中 serverFactory() 方法使用了 export 关键字,这是因为 AOT 编译器不支持没有被 exported 的 function。

以下是一部分 AOT 编译器支持的语法:

  • 对象字面量 ( Literal object ):{ key1 : value1, key2 : value2 }
  • 数组字面量 ( Literal Array ):[ item1, item2, item3 ]
  • Null 字面量 ( Literal Null )
  • 条件字面量 ( Conditional operator ): expression ? value1 : value2

还有一个有趣的现象,叫做代码折叠( Code folding )。与其把完整的原始表达式记录到 .metadata.json 文件,AOT 收集器会在收集期间执行一些表达式。比如 .ts 文件中有表达式 1 + 5 + 10,收集器则会执行这个表达式,得到结果 16,并记录该结果,而非表达式 1 + 5 + 10。任何能被收集器执行简化的表达式都是可折叠的( foldable )。类似的,收集器还可以把模块局部 const 变量、var 变量和 let 变量以内联的方式把他们折叠进 metadata 中,从而把这些 const 变量、var 变量和 let 变量的声明从 .metadata.json 文件中移除。如果表示式无法折叠,收集器就会把它原原本本地以 AST 的形式记录进 .metadata.json 文件,在接下来的阶段让编译器去解析。

想要查阅所有的表达式语法限制以及可折叠的语法,请前往Angular - 预先(AOT)编译器

代码生成( Code Generation )

AOT 收集器到此为止算是完成了它的任务,它收集了所有 .ts 文件里面的 metadata 并把他们记录进了 .metadata.json 文件。 这个过程中收集器不会试图去解析这些 metadata,而是只负责尽量准确地表述它们,同时记录任何检测到的 metadata 里的语法错误( syntax violation )。

接下来,就到了代码生成的阶段。在这个阶段,AOT 编译器会负责去解析这些 .metadata.json 文件。在解析过程中会抛出所有检测到的语义错误( semantic errors )。这些错误中值得提到的是 public errors。public errors 是指在 HTML template 里使用的变量在 .ts 文件里并没有设置 public 关键字。考虑以下场景:

app.component.html :

<span>{{data}}</span>

app.component.ts

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
})
export class AppComponent {
  private data = 'Some data';
}

从上面的例子可以看出,程序的本来目的是想让 app.component.html 的 data 变量绑定到 app.component.ts 里的 data。但是由于 data 被设有 private 关键字,数据绑定会失败,而编译器则会对这段程序抛出错误。由此我们可以总结一下数据绑定:

对于 decorated component 的类成员:

  1. 数据绑定的属性必须是 public
  2. 使用 @Input property 也必须是 public

在代码分析阶段,只要没有语法错误,AOT 收集器就可以用 new 来表示 function call 或是 对象创建。但是这并不能保证 AOT 编译器在代码生成阶段会生成对应的 function call 或是 对象创建的代码。具体的说,AOT 编译器仅支持 core 装饰器,并且仅支持调用会返回表达式的macros (函数或是静态方法)。

  • 新建实例:编译器仅允许 @angular/coreInjectionToken 类创建实例。
  • 支持的装饰器:编译器只支持来自 @angular/core 的 Angular 装饰器的 metadata。
  • Function calls:Factory functions 必须被 exported,必须是被命名 functions。不支持 Lambda 函数充当 Factory functions。

在代码分析阶段,收集器会接受任何只包含一个 return 语句的 macros。但是就像之前提到的,编译器仅支持会返回表达式的 macros。

模板类型检查( Template type checking )

首先,在 tsconfig.json 文件中的 angularCompilerOptions 中添加编译器选项 fullTemplateTypeCheck 可以开启该阶段。在 Angular 9 里模板类型检查阶段默认启用。

该阶段启用后,紧接着代码生成,AOT 编译器会检测 template types。它会使用 TypeScript 编译器来验证 templates 里面被绑定的表达式(变量或 function )。这样保证里在 runtime 程序运行崩溃之前就先捕获错误。

考虑以下场景,AOT 编译器会检查在 HTML template 里面使用的 isEven() function 是否在 TS 文件中定义:

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  template:'<span *ngIf="isEven(2)">Even number</span>',
  styleUrls: ['./app.component.scss'],
})
export class AppComponent {
  title = 'App component';
}

由于 isEven() function 没有定义,编译器会抛出错误 isEven is not a function。这也和 TypeScript 编译器在处里 .ts 文件中的代码时报告错误的方式很类似。

在推出 Angular Ivy之后,模板类型检查器已经被全面更新,更强大更严格的它可以捕获更多新类型的错误。但是同时这样也意味着能够在 Ivy 之前的 View Engine 通过编译的 templates 可能无法通过更严格的 Ivy 的模版类型检查。因为这个原因,Angular 9 中并没有默认启用这更严格的模版类型检查。

目前来说,模板类型检查有三个等级的模式:Basic Mode,Full Mode 和 Strict Mode。当前为哪个等级取决于 tsconfig.json 文件中编译器选项 fullTemplateTypeCheckstrictTemplates 的值。

如果 fullTemplateTypeCheck 设置为 false,那么当前处于 Basic Mode。在该模式下,AOT 编译器仅检测 templates 中最顶层的表达式。如果在验证 <app-people [name]="user.name"> 的时候,编译器只会验证 user 是否是 component class 的 property 和 user 是否是具有 name 的对象。它不会验证 user.name 的值是否可以复制给 app-people 组建的 name property。除此之外,Basic Mode 下编译器不会检查嵌入式视图,例如 *ngIf*ngFor 和其他 <ng-template> 嵌入式视图、无法弄清 #refs 的类型、pipes 的结果、事件绑定中 $event 的类型等等。

如果 fullTemplateTypeCheck 设置为 true,那么当前处于 Full Mode。Full Mode 下 AOT 编译器则会更加主动地检测之前提到的嵌入式视图、pipes 的结果、directives 和 pipes 的 local references 是否有正确类型。

如果 strictTemplates 设为 true,那么当前处于 Strict Mode。Strict Mode 就是 Angular 9 推出的更强大的 Full Mode 的超集。注意 strictTemplates 会取代掉 fullTemplateTypeCheck,并且 Strict Mode 仅在 Ivy 下有效。在 Strict Mode 下,编译器还会:

  • 验证 component 或 directives 的绑定值是否可赋给对应的 @Input
  • 推断 component 或 directives 的正确类型,包括泛型。
  • 推断配置 template 的 context type(例如,允许对 *ngFor 进行正确的类型检查)。
  • 在 component 或 directives 、DOM 和动画事件绑定中推断 $event 的正确类型。
  • 根据标签(tag)名称(例如, document.createElement ),推断出对 DOM 元素的局部引用的正确类型。

 

import { Component, EventEmitter, Output } from '@angular/core';

@Component({
  selector: 'text-editor',
  template: `
    <textarea (keyup)="emitWordCount($event)"></textarea>
  `
})
export class TextEditorComponent {
  @Output() countUpdate = new EventEmitter<number>();

  emitWordCount(e: Event) {
    this.countUpdate.emit(
            (e.target.value.match(/\S+/g) || []).length);
  }
}

出现错误:

Property 'value' does not exist on type 'EventTarget'

 

Angular 11+

Open tsconfig.json and disable strictTemplates.

"angularCompilerOptions": { .... ........ "strictTemplates": false }

<div class="form-inline float-left mr-1">
                <select class="form-control" [value]="productsPerPage"
                        (change)="changePageSize($event.target.value)">
                  <option value="3">3 per Page</option>
                  <option value="4">4 per Page</option>
                  <option value="6">6 per Page</option>
                  <option value="8">8 per Page</option>
                </select>
              </div>

changePageSize($event.target.value) 也报同样的错误。设置strictTemplates为false,ng build通过.

 

 类似资料: