Angular小博客

分享让你更聪明

[译2022]Angular 编译器如何工作

浏览:2次 日期:2024年10月20日 21:56:26 作者:admin

Angular 编译器(我们称之为ngc )是用于编译 Angular 应用程序和库的工具。 ngc基于 TypeScript 编译器(称为tsc )构建,并扩展了编译 TypeScript 代码的过程,以添加与 Angular 功能相关的其他代码生成。

Angular 的编译器充当开发人员体验和运行时性能之间的桥梁:Angular 用户根据符合人体工程学、基于装饰器的 API 编写应用程序, ngc将此代码转换为更高效的运行时指令。

例如,基本的 Angular 组件可能如下所示:

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

@Component({
  selector: 'app-cmp',
  template: '<span>Your name is {{name}}</span>',
})
export class AppCmp {
  name = 'Alex';
}

经过ngc编译后,该组件看起来像:

import { Component } from '@angular/core';                                      
import * as i0 from "@angular/core";

export class AppCmp {
    constructor() {
        this.name = 'Alex';
    }
}                                                                               
AppCmp.ɵfac = function AppCmp_Factory(t) { return new (t || AppCmp)(); };
AppCmp.ɵcmp = i0.ɵɵdefineComponent({
  type: AppCmp,
  selectors: [["app-cmp"]],
  decls: 2,
  vars: 1,
  template: function AppCmp_Template(rf, ctx) {
    if (rf & 1) {
      i0.ɵɵelementStart(0, "span");
      i0.ɵɵtext(1);
      i0.ɵɵelementEnd();
    }
    if (rf & 2) {
      i0.ɵɵadvance(1);
      i0.ɵɵtextInterpolate1("Your name is ", ctx.name, "");
    }
  },
  encapsulation: 2
});                                                   
(function () { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(AppCmp, [{
        type: Component,
        args: [{
                selector: 'app-cmp',
                template: '<span>Your name is {{name}}</span>',
            }]
    }], null, null); })();

@Component装饰器已被替换为几个静态属性( ɵfacɵcmp ),它们向 Angular 运行时描述该组件并为其模板实现渲染和更改检测。

通过这种方式, ngc可以被认为是一个扩展的 TypeScript 编译器,它也知道如何“执行”Angular 装饰器,在构建时(而不是运行时)将它们的效果应用到装饰类。

NGC内部

ngc有几个重要目标:

让我们来看看ngc如何管理这些目标。

编译流程

ngc的主要目标是编译 TypeScript 代码,同时将识别的 Angular 修饰类转换为更有效的运行时表示。 Angular编译的主要流程如下:

  1. 创建 TypeScript 编译器的实例,以及一些附加的 Angular 功能。
  2. 扫描项目中的每个文件以查找修饰类,并构建需要编译哪些组件、指令、管道、NgModule 等的模型。
  3. 在装饰类之间建立连接(例如,在哪些组件模板中使用哪些指令)。
  4. 利用 TypeScript 对组件模板中的表达式进行类型检查。
  5. 编译整个程序,包括为每个修饰类生成额外的 Angular 代码。

第 1 步:创建 TypeScript 程序

在 TypeScript 的编译器中,要编译的程序由ts.Program实例表示。该实例结合了要编译的文件集、依赖项的类型信息以及要使用的特定编译器选项集。

识别文件集和依赖项并不简单。通常,用户指定一个“入口点”文件(例如main.ts ),TypeScript 必须查看该文件中的导入以发现需要编译的其他文件。这些文件有额外的导入,可以扩展到更多文件,依此类推。其中一些导入指向依赖项:对未编译但以某种方式使用并且需要 TypeScript 类型系统了解的代码的引用。这些依赖项导入到.d.ts文件,通常位于node_modules中。

在这一步,Angular 编译器做了一些特殊的事情:它将额外的输入文件添加到ts.Program中。对于用户编写的每个文件(例如my.component.ts ),ngc 添加一个带有.ngtypecheck后缀的“影子”文件(例如my.component.ngtypecheck.ts )。这些文件在内部用于模板类型检查(稍后详细介绍)。

第二步:个体分析

在编译的分析阶段, ngc寻找具有 Angular 装饰器的类,并尝试静态地理解每个装饰器。例如,如果它遇到@Component装饰类,它会查看装饰器并尝试确定组件的模板、其选择器、视图封装设置以及为其生成代码可能需要的有关组件的任何其他信息。这要求编译器能够执行称为部分评价:读取装饰器元数据中的表达式并尝试解释这些表达式而不实际运行它们。

部分评价:

有时,Angular 装饰器中的信息隐藏在表达式后面。例如,组件的选择器以文字字符串形式给出,但它也可以是常量:

const MY_SELECTOR = 'my-cmp';

@Component({
  selector: MY_SELECTOR,
  template: '...',
})
export class MyCmp {}

ngc使用 TypeScript 的 API 来导航代码以计算表达式MY_SELECTOR ,将其追溯到其声明并最终将其解析为字符串'my-cmp' 。部分求值器可以理解简单的常量;对象和数组文字;财产访问;进口/出口;算术和其他二元运算;甚至评估对简单函数的调用。此功能使 Angular 开发人员能够更灵活地向编译器描述组件和其他 Angular 类型。

分析输出:

在分析阶段结束时,编译器已经很好地了解了输入程序中的组件、指令、管道、注入和 NgModule。对于其中每一个,编译器都会构造一个“元数据”对象,描述它从类的装饰器中学到的所有内容。此时,组件已经从磁盘加载了模板和样式表(如果需要),并且如果到目前为止在输入的任何部分检测到语义错误,编译器可能已经产生错误(在 TypeScript 中称为“诊断”)。

第三步:全局分析

在进行类型检查或生成代码之前,编译器需要了解程序中的各种修饰类型如何相互关联。此步骤的主要目标是了解程序的 NgModule 结构。

Ng模块:

为了进行类型检查和生成代码,编译器需要知道每个组件的模板中使用了哪些指令、组件和管道。这并不简单,因为 Angular 组件不直接导入它们的依赖项。相反,Angular 组件使用 HTML 描述模板,并使用 CSS 样式选择器将潜在依赖项与这些模板中的元素进行匹配。这实现了强大的抽象层:Angular 组件不需要确切地知道它们的依赖项是如何构造的。相反,每个组件都有一组潜在的依赖项(其“模板编译范围”),只有其中的一个子集最终会匹配其模板中的元素。

这种间接性是通过 Angular @NgModule抽象来解决的。 NgModule 可以被认为是模板范围的可组合单元。基本的 NgModule 可能如下所示:

@NgModule({
  declarations: [ImageViewerComponent, ImageResizeDirective],
  imports: [CommonModule],
  exports: [ImageViewerComponent],
})
export class ImageViewerModule {}

NgModules 可以理解为各自声明两个不同的作用域:

在上面的示例中, ImageViewerComponent是在此 NgModule 中声明的组件,因此其潜在依赖项由 NgModule 的编译范围给出。此编译范围是所有声明和导入的任何 NgModule 的导出范围的联合。因此,在 Angular 中,在多个 NgModule 中声明一个组件是错误的。此外,组件及其 NgModule 必须同时编译。

在本例中, CommonModule被导入,因此ImageViewerModule (以及ImageViewerComponent )的编译范围包括CommonModule导出的所有指令和管道 — NgIf 、 NgForOf 、 AsyncPipe以及其他六个指令和管道。编译范围还包括两个声明的指令 – ImageViewerComponentImageResizeDirective 。

请注意,对于组件,它们与声明它们的 NgModule 的关系是双向的:NgModule 既定义组件的模板范围,又使该组件在其他组件的模板范围中可用。

上面的 NgModule 还声明了一个仅由ImageViewerComponent组成的“导出范围”。导入此模块的其他 NgModule 会将ImageViewerComponent添加到其编译范围中。通过这种方式,NgModule 允许封装ImageViewerComponent的实现细节 – 在内部,它可能使用ImageResizeDirective ,但该指令不适用于ImageViewerComponent的使用者。

为了确定这些范围,编译器使用从上一步中分别了解的每个类的信息来构建 NgModule、其声明及其导入和导出的图表。它还需要有关依赖项的知识:从库导入但未在当前程序中声明的组件和 NgModule。 Angular 将此信息编码到这些依赖项的.d.ts文件中。

 
.d.ts 元数据:

在全局分析阶段,Angular编译器需要完整枚举编译中声明的NgModule的编译范围。但是,这些 NgModule 可能会从编译外部、库和其他依赖项导入其他 NgModule。 TypeScript 通过声明文件(扩展名为.d.ts从此类依赖项中了解类型。 Angular 编译器使用这些.d.ts声明来传递有关这些依赖项中的 Angular 类型的信息。

例如,上面的ImageViewerModule@angular/common包导入CommonModule 。导入列表的部分评估会将导入中指定的类解析为来自这些依赖项的.d.ts文件中的声明。

仅知道导入的 NgModule 的符号是不够的。为了构建其图,编译器通过特殊元数据类型的.d.ts文件传递​​有关 NgModule 的声明、导入和导出的信息。例如,在为 Angular 的CommonModule生成的声明文件中,此(简化的)元数据如下所示:

export declare class CommonModule {
  static mod: ng.NgModuleDeclaration<CommonModule, [typeof NgClass, typeof NgComponentOutlet, typeof NgForOf, typeof NgIf, typeof NgTemplateOutlet, typeof NgStyle, typeof NgSwitch, typeof NgSwitchCase, typeof NgSwitchDefault, typeof AsyncPipe, ...]>;
  // … 
}

此类型声明并非用于 TypeScript 进行类型检查,而是将有关 Angular 对相关类的理解的信息(引用和其他元数据)嵌入到类型系统中。从这些特殊类型中, ngc可以确定CommonModule的导出范围。使用 TypeScript 的 API 解析此元数据中对这些类定义的引用,它可以提取有关指令/组件/管道本身的有用元数据:

// ngif.directive.d.ts 
export declare class NgIf<T> {
  // …
  static dir: ng.DirectiveDeclaration<NgIf<any>, "[ngIf]", never, { "ngIf": "ngIf"; "ngIfThen": "ngIfThen"; "ngIfElse": "ngIfElse"; }, {}, never>;
}

第 4 步:模板类型检查

ngc能够报告 Angular 模板中的类型错误。例如,如果模板尝试绑定值{{name.first}}但名称对象没有first属性, ngc可以将此问题显示为类型错误。有效地执行此检查对于ngc来说是一个重大挑战。

TypeScript 本身不了解 Angular 模板语法,无法直接对其进行类型检查。为了执行此检查,Angular 编译器将 Angular 模板转换为 TypeScript 代码(称为“类型检查块”或 TCB),以在类型级别表达等效操作,并将此代码提供给 TypeScript 进行语义检查。然后,任何生成的诊断都会被映射回并在原始模板的上下文中报告给用户。

例如,考虑一个带有使用ngFor模板的组件:

<span *ngFor="let user of users">{{user.name}}</span>

对于此模板,编译器想要检查user.name属性访问是否合法。为此,必须首先了解如何通过NgForusers的输入数组派生出循环变量user的类型。

编译器为此组件模板生成的类型检查块如下所示:


import * as i0 from './test';
import * as i1 from '@angular/common';
import * as i2 from '@angular/core';

const _ctor1: <T = any, U extends i2.NgIterable<T> = any>(init: Pick<i1.NgForOf<T, U>, "ngForOf" | "ngForTrackBy" | "ngForTemplate">) => i1.NgForOf<T, U> = null!;

/*tcb1*/
function _tcb1(ctx: i0.TestCmp) { if (true) {
    var _t1 /*T:DIR*/ /*165,197*/ = _ctor1({ "ngForOf": (((ctx).users /*190,195*/) /*190,195*/) /*187,195*/, "ngForTrackBy": null as any, "ngForTemplate": null as any }) /*D:ignore*/;
    _t1.ngForOf /*187,189*/ = (((ctx).users /*190,195*/) /*190,195*/) /*187,195*/;
    var _t2: any = null!;
    if (i1.NgForOf.ngTemplateContextGuard(_t1, _t2) /*165,216*/) {
        var _t3 /*182,186*/ = _t2.$implicit /*178,187*/;
        "" + (((_t3 /*199,203*/).name /*204,208*/) /*199,208*/);
    }
} }

这里的复杂性似乎很高,但从根本上来说,这个 TCB 正在执行特定的操作序列:

如果根据 TypeScript 的规则,访问_t3.name不合法,TypeScript 将为此代码生成诊断错误。 Angular 的模板类型检查器可以查看 TCB 中此错误的位置,并使用嵌入的注释将错误映射回原始模板,然后再将其显示给开发人员。

由于 Angular 模板包含对组件类属性的引用,因此它们具有来自用户程序的类型。因此,模板类型检查代码不能独立检查,必须在用户整个程序的上下文中进行检查(在上面的示例中,组件类型是从用户的test.ts文件导入的)。 ngc通过 TypeScript 增量构建步骤(生成新的ts.Program )将生成的 TCB 添加到用户的程序中来实现此目的。为了避免破坏增量构建缓存,类型检查代码被添加到单独的.ngtypecheck.ts文件中,编译器在创建时将其添加到ts.Program而不是直接添加到用户文件中。

第 5 步:发射

当此步骤开始时, ngc既理解了程序,又验证了不存在致命错误。然后 TypeScript 的编译器被告知为程序生成 JavaScript 代码。在生成过程中,Angular 装饰器被剥离,并将几个静态字段添加到类中,生成的 Angular 代码已准备好写入 JavaScript。

如果正在编译的程序是一个库,还会生成.d.ts文件。这些文件包含嵌入的 Angular 元数据,描述未来的编译如何使用这些类型作为依赖项。

逐渐快速

如果上面的内容听起来像是在生成代码之前需要完成大量工作,那是因为事实确实如此。虽然 TypeScript 和 Angular 的逻辑非常高效,但执行为输入程序生成 JavaScript 输出所需的所有解析、分析和综合仍然需要几秒钟的时间。因此,TypeScript 和 Angular 都支持增量编译模式,当输入发生微小更改时,可以重用之前完成的工作来更有效地更新已编译的程序。

增量编译的主要问题是:给定输入文件中的特定更改,编译器需要确定哪些输出可能已更改,以及哪些输出可以安全地重用。编译器必须是完美的,如果不能确定输出没有更改,则可能会错误地重新编译输出。

为了解决这个问题,Angular编译器有两个主要工具:导入图语义依赖图

导入图表

当编译器在第一次分析程序时执行部分评估操作时,它会构建文件之间的关键导入图。这允许编译器在发生变化时了解文件之间的依赖关系。

例如,如果文件my.component.ts有一个组件,并且该组件的选择器是由从selector.ts导入的常量定义的,则导入图显示my.component.ts依赖于selector.ts 。如果selector.ts发生变化,编译器可以查阅该图并知道my.component.ts的分析结果不再正确,必须重新进行。

导入图对于理解可能发生的变化很重要,但它有两个主要问题:

这两个问题都可以通过编译器的第二个增量工具解决:

语义依赖图

语义依赖图接续导入图的位置。该图捕获了编译的实际语义:组件和指令如何相互关联。它的工作是知道哪些语义变化需要重现给定的输出。

例如,如果selector.ts更改,但MyCmp的选择器未更改,则语义 dep 图将知道在语义上影响MyCmp的任何内容均未更改,并且可以重用MyCmp之前的输出。相反,如果选择器确实发生变化,则其他组件中使用的组件/指令集可能会发生变化,并且语义图将知道这些组件需要重新编译。

增量性

因此,两个图一起工作以提供快速增量编译。导入图用于确定需要重新进行哪些分析,然后应用语义图来了解分析数据的更改如何通过程序传播并需要重新编译输出。结果是编译器可以有效地对输入的变化做出反应,并且只做最少量的工作来正确更新其输出作为响应。

概括

Angular 的编译器利用 TypeScript 编译器 API 的灵活性来提供 Angular 模板和类的正确且高性能的编译。编译 Angular 应用程序使我们能够在 IDE 中提供理想的开发人员体验,提供有关代码中问题的构建时反馈,并在构建过程中将该代码转换为在浏览器中运行的最高效的 JavaScript。

原文链接:https://blog.angular.dev/how-the-angular-compiler-works-42111f9d2549