作为“为大型前端项目”而设计的前端框架,Angular 其实有许多值得参考和学习的设计,本系列主要用于研究这些设计和功能的实现原理。本文围绕 Angular 的核心功能 Ivy 编译器,介绍其中CLI层面的编译器编译过程。

在 Angular 中实现了自己的编译器,来处理 TypeScript 编译器无法完全做到的一些事情。在 Ivy 编译器中,这部分的能力又做了进一步的提升,比如模板类型检查、模块依赖解析等等。

# Ivy 编译器

在前面9.Ivy 编译器整体设计一文中,我有介绍 Ivy 编译器主要包括两部分:

  1. ngtsc:作为主要的 Ivy 编译器,将 Angular 装饰器化为静态属性。
  2. ngcc:作为兼容性的 Ivy 编译器,主要负责处理来自 NPM 的代码并生成等效的 Ivy 版本。

本文将会主要围绕ngtsc该编译器进行介绍。

# Angular 中的 AST 解析

要实现 AST 的解析和转换,离不开解析器。对于 Typescript 代码来说,编译器的整体流程为:

                                                                |------------|
                           |----------------------------------> | TypeScript |
                           |                                    |   .d.ts    |
                           |                                    |------------|
                           |
|------------|          |-----|               |-----|           |------------|
| TypeScript | -parse-> | AST | ->transform-> | AST | ->print-> | JavaScript |
|   source   |    |     |-----|       |       |-----|           |   source   |
|------------|    |        |          |                         |------------|
                  |    type-check     |
                  |        |          |
                  |        v          |
                  |    |--------|     |
                  |--> | errors | <---|
                       |--------|

该过程包括四个步骤:

  1. parse 解析:它是一个传统的递归下降解析器,稍微调整以支持增量解析,它发出一个抽象语法树 (AST),有助于识别文件中导入了哪些文件。
  2. type-check 类型检查器:类型检查器构建一个符号表,然后对文件中的每个表达式进行类型分析,报告它发现的错误。
  3. transform 转换:转换步骤是一组 AST 到 AST 转换,它们执行各种任务,例如删除类型声明、将模块和类声明降低到 ES5、将异步方法转换为状态机等。
  4. print 打印:TS 到 JS 的实际转换是整个过程中最昂贵的操作。

在了解 Angular 是如何处理之前,我们需要知道,对 TypeScript 编译器 API 的任何使用都遵循一个多步骤过程,包括:

  • 一个ts.CompilerHost被创建
  • ts.CompilerHost加上一组“根文件”,用于创建ts.Program,ts.Program用于收集各种诊断(类型检查)
  • ts.Program被要求emit,并生成 JavaScript 代码

将 Angular 编译集成到此过程中的编译器遵循非常相似的流程,但有一些额外的步骤:

  • 一个ts.CompilerHost被创建
  • ts.CompilerHost包含在NgCompilerHost中,它将 Angular 特定文件添加到编译中
  • ts.Program是从NgCompilerHost及其增强的根文件集创建的
  • 一个CompilationTicket被创建,可选择合并来自先前编译运行的任何状态
  • NgCompiler是使用CompilationTicket创建的
  • 诊断信息可以正常从ts.Program收集,也可以从NgCompiler收集
  • 在发射之前,调用NgCompiler.prepareEmit以检索需要馈送到ts.Program.emit的 Angular 转换器
  • 使用上面的 Angular 转换器在ts.Program上调用发射,它生成带有 Angular 扩展的 JavaScript 代码

在这些 Angular 特定的步骤中,主要进行几件事:

  1. 会将特定于 Angular 的文件添加到编译过程中,比如NgModeleComponent的解析。
  2. 修改生成的d.ts,来保存 Angular 中模块和文件间的依赖关系。
  3. 会增加 Angular 中的类型校验,包括<tmeplate>模板的类型校验。

而在自定义 TypeScript 编译器中执行 Angular 编译,主要依赖于NgCompiler,我们来看一下核心的一些方法:

export class NgCompiler {
  // 将一个 CompilationTicket 转换为一个用于请求编译的 NgCompiler 实例
  // 根据编译请求的性质,NgCompiler 实例可能会从以前的编译中重用并随着任何更改进行更新
  // 它可能是一个新实例,它可以增量地重用以前编译中的状态,或者它可能代表一个完全新的编译 
  static fromTicket(ticket: CompilationTicket, adapter: NgCompilerAdapter, perfRecorder?: PerfRecorder) {}

  // 获取文件的资源依赖
  getResourceDependencies(file: ts.SourceFile): string[] {}

  // 获取此编译的所有与 Angular 相关的诊断信息
  getDiagnostics(): ts.Diagnostic[] {}

  // 将 Angular.io 错误指南链接添加到此编译的诊断中
  private addMessageTextDetails(diagnostics: ts.Diagnostic[]): ts.Diagnostic[] {}
  
  // 获取 ts.Program 以用作生成后续增量编译的起点
  // NgCompiler 产生一个内部增量 TypeScript 编译(为了模板类型检查的目的,将消费者的 `ts.Program` 继承到一个新的编译器中)
  // 此操作后,消费者的 ts.Program 不再可用于启动新的增量编译,getNextProgram 检索可以替代使用的 ts.Program
  getNextProgram(): ts.Program {}

  // 异步执行 Angular 的分析步骤
  // 通常,每当调用 getDiagnostics 或 prepareEmit 时,都会延迟执行此操作
  // 然而,某些消费者可能希望允许分析的异步阶段,其中诸如 “styleUrls” 之类的资源被异步解析
  // 在这些情况下,必须首先调用 analyzeAsync,并且在调用 NgCompiler 的任何其他 API 之前等待它的 Promise
  async analyzeAsync(): Promise<void> {}

  // 列出在分析过程中检测到的惰性路由
  listLazyRoutes(entryRoute?: string): LazyRoute[] {}
}

可见,NgCompiler主要负责将 Angular 编译集成到 TypeScript 编译器的编译流程中,并支持了上述提到的错误信息诊断(类型检查)、依赖关系检索,其中的设计还支持了增量编译、异步编译等能力。

# ngtsc 编译器

ngtsc是一个 Typescript-to-Javascript 编译器。它是一个最小包装器,包裹在tsc之外,而tsc中则包含一系列的 Angular 变换。

# 编译器流程

tsc一样,当ngtsc开始运行时,它首先解析tsconfig.json文件,然后创建一个ts.Program。在上述转换可以运行之前,需要进行几件事情:

  • 为包含修饰符的输入源文件收集元数据
  • @Component装饰器中列出的资源文件必须异步解析
    • 例如 CLI 中,可能希望运行的 WebPack 以产生.css输入到styleUrls的属性@Component
  • 运行诊断程序,这会创建TypeChecker并触及程序中的每个节点(一个相当昂贵的操作)

因为资源加载是异步的(特别是可能通过子进程并发),所以最好在做任何昂贵的事情之前启动尽可能多的资源加载。

ngtsc的运行入口位于NgtscProgram中,可直接替代传统的 View Engine 编译器到诸如命令行main()函数或 Angular 之类的工具命令行界面。

export class NgtscProgram implements api.Program {
  readonly compiler: NgCompiler;
  // 主要的 TypeScript 程序,用于分析和发出
  private tsProgram: ts.Program;

  private closureCompilerEnabled: boolean;
  private host: NgCompilerHost;
  private incrementalStrategy: TrackedIncrementalBuildStrategy;

  // 其他方法
}

编译器流程如下所示:

  1. 创建ts.Program
  2. 扫描源文件以查找具有微不足道的可检测@Component注释的顶级声明,这避免了创建TypeChecker
  • 对于每个具有templateUrlor的此类声明styleUrls,启动该 URL 的资源加载并将加入Promise队列
  1. 获取诊断信息并报告任何初始错误消息。此时,TypeChecker已准备就绪。
  2. @Component注释进行彻底扫描,使用TypeChecker和元数据系统来解析任何复杂的表达式。
  3. 等待所有资源得到解决。
  4. 计算需要应用的一组变换。
  5. 启动Tsickle发射,它运行变换。
  6. .d.ts文件的发出回调期间,重新解析发出的.d.ts并合并来自Angular编译器的任何请求更改。

Angular 编译涉及将 Angular 装饰器转换为静态定义字段。在构建时,这是在 TypeScript 编译的整个过程中完成的,其中 TypeScript 代码经过类型检查,然后降级为 JavaScript 代码。在此过程中,还可以生成特定于 Angular 的诊断。

# 增量编译

前面我们介绍了 Ivy 编译器的一些特性,其中包括了通过增加增量编译,来缩短构建时间。

作为在 TypeScript 编译器中执行 Angular 编译的核心 API,NgCompiler的每个实例都支持单个编译,因此也支持增量编译。

Angular 编译器能够进行增量编译,其中来自先前编译的信息用于加速下一次编译。在编译期间,编译器产生两种主要信息:本地信息(如组件和指令元数据)和全局信息(如具体化的NgModule范围)。增量编译通过两种方式进行管理:

  1. 对于大多数更改,新的NgCompiler可以有选择地从以前的实例继承本地信息,并且只需要在底层 TypeScript 文件发生更改的地方重新计算它。在这种情况下,全局信息总是从头开始重新计算。
  2. 对于特定的更改,例如组件资源中的更改,NgCompiler可以整体重用,并更新以合并此类更改的影响,而无需重新计算任何其他信息。

请注意,这两种模式在是否需要新NgCompiler实例或是否可以重用之前的实例方面有所不同。为了防止泄漏这种实现的复杂性并保护消费者不必管理NgCompiler如此具体的生命周期,这个过程通过CompilationTicket进行了抽象。

export type CompilationTicket =
  // 从头开始 Angular 编译操作
  FreshCompilationTicket | 
  // 开始包含对 TypeScript 代码的更改的 Angular 编译操作
  IncrementalTypeScriptCompilationTicket | 
  IncrementalResourceCompilationTicket;

CompilationTicket用于初始化(或更新)NgCompiler实例,该实例为 Angular 编译器的核心。CompilationTicket抽象了编译的起始状态,并允许独立于任何增量编译生命周期管理NgCompiler

消费者首先获得一个CompilationTicket(取决于传入更改的性质),然后使用该票据获取NgCompiler实例。在创建CompilationTicket时,编译器可以决定是重用旧NgCompiler实例还是创建新实例。

# 异步编译

在某些编译环境(例如 Angular CLI 中的 Webpack 驱动编译)中,编译的各种输入只能以异步方式生成。例如,styleUrls链接到 SASS 文件的 SASS 编译需要产生一个子 Webpack 编译。为了支持这一点,Angular 有一个异步接口来加载这些资源。

如果使用此接口,则NgCompiler创建后的另一个异步步骤是调用NgCompiler.analyzeAsync并等待其Promise

export class NgtscProgram implements api.Program {
   ...
  /**
    * 确保 NgCompiler 已正确分析程序,并允许在此过程中异步加载任何资源。
    * Angular CLI 使用它来允许为 styleUrls 中使用的 SASS 文件等内容生成(异步)子编译。
    */
  loadNgStructureAsync(): Promise<void> {
    return this.compiler.analyzeAsync();
  }
}

此操作完成后,所有资源均已加载,其余NgCompilerAPI可以同步使用。

# 总结

Angular 是一套大而全的解决方案,想必大家早已对此有所了解。但实际上 Angular 做了很多深度的设计和能力,包括给开发者更好的体验,比如模板类型检查中,是如何将这些 Angular 特定的类型检查能力添加到 TypeScript 编译过程中,并且能通过文件映射能准确反馈给用户具体的代码位置,这些都是作为开发者的我未曾考虑过的。

感觉 Angular 里面还有特别多值得学习的东西。

# 参考

部分文章中使用了一些网站的截图,如果涉及侵权,请告诉我删一下谢谢~
温馨提示喵