深入理解 Angular 编译器
Angular 编译器对于 Angular 开发者来说一直是一个神秘的黑盒,相信看完这篇文章可以让你对 Angular 编译器有更深刻的理解和认识。这篇演讲是 Angular Connect 大会第一天的第一个演讲。
首先是 Alex Rickabaugh 的自我介绍:
-
Google Angular Team 成员 -
致力于将 Angular 编译器升级至 Ivy -
五次 Angular Connect 大会的演讲者
Alex 参与 Angular 编译器的开发有两年了,他提到两年前 Misko 找到他并询问他是否愿意成为 Angular 编译器的 owner 并为 ivy 的开发做好准备时,Alex 的反应是 “scared”,因为当时的 Angular 编译器对他来说是个黑盒并且编译器的代码和 Angular 的其它代码是不同的(It's a different codebase than the rest of Angular)。所以在今天的这份演讲里,Alex 会和我们分享 Angular 编译器这个黑盒里到底有什么。
第一个问题:为什么 Angular 始终需要一个编译器?编译器的主要工作就是将模板转换为代码在 Angular 中,你可以声明式(Declarative)的编写代码,而不需要机械的去编写命令式(Imperative)的代码,然而浏览器并不理解你的声明式代码,所以开发者得编写命令式的代码,告诉浏览器一步一步该怎么做。
所以你可能会疑惑既然命令式的代码是必不可少的,那为什么不直接编写命令式的代码并节省做一个编译器的麻烦事呢?
Alex 解释有两个原因:
-
首先编写声明式代码可以让程序员把时间更专注于业务上 -
其次 Angular 的声明式代码可以做很多优化工作
Angular 可以通过不断迭代的方式帮助开发者的应用变得越来越快,Angular 自身也能即时的改变实现的灵活性(译者注:其实可以从每个版本 Angular 打包出来的代码就可以看出 Angular 本身也是在不断优化和完善的)。
所以编译器的工作就是执行 Angular 的声明式指令,开发者所编写的模板和装饰器都会被转为命令式的代码。例如在这里有个声明式组件 popup-panel
把它投喂给 Angular 编译器 之后:这里我们调用 runtime 来创建组件定义,传递一些有关选择器的信息,传递这些非常命令式的模板方法来调用之前谈到过的 ivy 指令。
当然这里不会深入 runtime 的工作细节,感兴趣的话可以听听 Kara 的另一篇演讲。
Angular 的编译器会采用两种不同方式之一进行代码转换。首先在 Angular 开发模式下使用的是 JIT 的编译方式,当你在 JIT 模式下构建应用时,编写的TypeScript 代码会被第三方编译器编译,但是所有的 Angular 装饰器都会被打包在 JavaScript 代码中并运行在浏览器中。
当这些装饰器执行时,它们会调用编译器并将开发者的模板和其它 Angular 特性转为命令式代码。
另一种则是 AOT 模式,这不是在我们的应用中运行普通的 TypeScript 编译器,而是运行 Angular 自己的编译器 — NGC,执行的结果是一样的。NGC 会将 TypeScript 代码转为 JavaScript ,并将代码中的 Angular 装饰器预编译成在浏览器中直接渲染的命令式指令,这样也就不需要在 runtime 中花费开销来编译。
下面的 Topics 就是这次演讲的主要内容:
-
Architecture:编译器的架构设计 -
Compilation Model:编译模型 -
Features of the Compiler:编译器特性 -
Template Type-Checking:模板类型检查
一、架构
一般的 TypeScript 编译器包含三部分,项目创建、类型检查以及输出,Angular 编译器也是基于这三个阶段。不过 Angular 编译器 - NGC 增加了两个部分,Analysis 和 Resolve那么就来看看这几个阶段吧
1.程序创建程序创建是 TypeScript 发现并理解程序所需的所有源文件的过程。
这可能在一个应用中,也可能是从 node_modules 中导入的一个库,并查看相应的导入和导出,依次类推,直到正确的找到所有的代码为止。
在 NGC(Angular 编译器)中,会在这个过程中添加一些你不一定要写的文件,这也是为什么现在可以在 Angular 中做一些高级特性的原因。
2.分析阶段
编译的下一个阶段是分析(Analysis),这个阶段仅仅属于 Angular 编译器。在这个阶段,NGC(Angular 编译器)会获取程序中的所有文件并遍历其中的所有类,并努力找到使用 Angular 装饰器装饰的类。
NGC 会了解到应用程序的每一个部分是什么,组件、模块、服务和指令,找到一个就会去更深入的理解一点。如果是组件就会去解析它的模板,在分析过程中,NGC 会一个一个的遍历这些类,如果 NGC 找到一个组件,可能并不会找到这个组件所属的模块,NGC 不知道它属于哪一个模块,NGC 会单独的分析每一个类。
3.Resolve
在分析阶段遍历所有的类之后便是解析阶段。这次 NGC 会再次查看所有的内容,但是是在大背景下(but this time in the the context of the larger picture),NGC 会查看组件,知道它所属的模块并基于此做出更多的全局决策和优化,这个阶段还会发现与应用结构有关的错误。
4.Type Checking
类型检查,这部分后续会详细讲述。5.emit
发出阶段。这个阶段 TypeScript 代码转换为可以在浏览器内运行的 JavaScript 代码。
在这个步骤中,当 NGC 为每个类输出代码时,如果类需要的话,NGC 也会为其生成命令式的 Angular 代码。所以 NGC 会将所有的模板函数添加到组件,大概这样。这五个阶段从高层级讲述了每次调用 Angular 会发生什么,但是实际上每个 Angular 应用程序不仅依赖运行一次 Angular 编译器,还依赖于运行多次,开发者可能没有意识到这一点,但是即使是最简单的应用程序也依赖于 Angular Core,它在交付到 npm 之前会使用我们的 Angular 编译器进行编译,多次编译一起工作的依据被我们称为 ”编译模型“ (Compilation Model)
二、编译模型
在 TypeScript 中,如果项目内包含 lib.ts,那么会运行并生成一个 lib.js 文件,lib.js 没有类型信息,可以在浏览器中执行。但是开发者可以使用 TypeScript 生成另一个文件 lib.d.ts,它不包含任何命令式代码,开发者也无法执行它,.d.ts 文件描述了原始 .ts 文件中类型,这一点很重要。如果你构建的应用使用这个库,如果你从 npm 中引入了这个库,TypeScript 需要知道你所引入库的类型,因为 TypeScript 需要检查你是否正确的使用这些库。换句话来说,.d.ts 文件做的事情是将类型信息从一个编译携带到另一个编译中。
Angular 中的编译模型功能也很相似。普通的 TypeScript 编译器也会将 lib.ts 文件生成一个普通的 .js 文件,并渲染在组件里。
但是 NGC 也改变了 .d.ts 文件,并包含了后续的其它编译所需的有关组件的信息,因此使用该组件的任何应用都会使用在组件定义的通用类型中编码的信息,比如它的选择器信息和输入属性信息。这可以使用后面的编译器可以使用该组件,而且也清楚这些部分属于组件的公共 api。如果你改变 lib.ts 文件,.d.ts 文件也将更改,这意味着你的库需要做重大更新。因此,这意味着当开发者构建使用该库的应用时, NGC 会发现 useful-cmp 实际上是该库的一个实例,因为 NGC 可以在 lib.d.ts 文件中查找到详细信息。
三、编译器特性
现在我们准备更深入的研究 NGC 在构建应用和库的过程中实际上所做的一些事情。
-
NgModule 范围及其含义 -
Partial Evaluation (部分求值,一种求值策略) -
如何实际检查模板中表达式的类型
开发者对组件和模板的引用一定都非常熟悉了。
这里,我们使用一个 user-view组件。
开发者可能没有意识到 Angular 将模板中编写的 HTML 元素与要导入的库中某个组件类相匹配实际上是很棘手的事情,因为开发者从未真正编写过该组件的导入。
开发者只是编写了一个恰巧与选择器匹配的 HTML 元素,可能有许多组件与此选择器匹配,虽然这一般不太可能,但是还是有可能发生的。那么 Angular 是如何知道开发者在这里声明的是哪个组件呢?
答案就是:这一切都取决于 NgModules
在 AppModule 中声明了两个组件:AppCmp 和 UserViewCmp
由于 AppCmp 是在 AppModule 中声明的,因此在 AppModule 中声明的所有组件都可以在模板中使用,并且我们将这些组件集合称为模块的编译范围,因此这里的编译范围既包含 AppCmp 又包含 UserViewCmp下面这张图的情形会稍微复杂点。我们将 UserViewCmp 组件提取到了 UserModule 中,UserModule 不仅声明了 UserVIewCmp 也导出了 UserViewCmp,这样 UserModule 除了其编译范围(上面提到的组件集合)外,还提供了导出范围(export scope)的功能,这是一组可用于导入该模块的任何模块的一组组件。
在图中用绿色箭头表示导出,而非使用红色箭头。
在下面这个例子中,AppModule 既包含 AppCmp 也包含 UserModule,那么在这种情况下会通过 UserModule 获取 UserViewCmp。通过构建此模块编译范围和导出作用域,NGC 最终可以确定开发者在应用程序组件模板中编写的 user-view 元素只能是对 UserView 组件类的引用,并且可以相应地生成代码。
实际上,这是编译器为帮助摇树器(tree-shaker)所做的优化。
NGC 会遍历模板并弄清楚编译范围内的所有组件中实际上正在使用的是哪些组件。
比如开发者可能具有导入包含所有 material 组件的模块,但是如果仅仅使用 button 组件
那么 NGC 只会生成对 button 组件的引用,这可以帮助摇树器删除实际上未引用的内容。
NGC 能识别出模板中包含哪些组件并能够导入它们是一件非常令人惊叹的事情。接下来是 Partial Evaluation部分求值
Angular 编译器 — NGC 几乎包含完整的 TypeScript 解释器。
为什么编译器需要解释 TypeScript 代码?
简单来说因为 NGC 需要在构建时知道开发者在装饰器中编写的某些表达式的实际值。这个 NgModule 具有声明和导出,为了要弄清楚编译范围和导出范围,我们需要实际知道这些数组的值是什么,以及实际引用的组件。
这是个比较简单的版本,两个数组都是可遍历的,阅读 TypeScript 代码可以得知这对一个组件的引用,这是对另一个组件的引用,这是非常普通的 TypeScript 语法。
下面是一个稍微复杂一些的版本。这里我们将组件提取到一个数组中,并使用它来对导出进行去重,几乎所有的 Angular 应用程序中都能看到这种模式。
这是一个非常简单的重构,看起来很自然,但是请考虑一下这对编译器意味着什么。NGC 不仅仅需要理解 NgModule 接受一个包含 declarations 属性和 exports 属性的对象(而且这些属性的值还是数组),还必须能够找到该引用指向代码中其他位置的数组,并解构出里面的内容。
换句话说,为了做到这一点,我们的编译器实际上将尝试几乎运行开发者在装饰器中编写的 TypeScript 代码,并尝试确定表达式的值。
在执行此操作的同时,我们执行以下操作:跟随属性访问,解构对象和数组,甚至执行一些简单的函数调用。部分求值可以用来理解更复杂的事情,例如从其他文件导入变量。
多亏了部分求值,编译器可以在一个模块求值出导入的引用,并在另一个模块中读取常量。动态表达式(Dynamic Expressions )很有趣。
这里我们这次是将数组放在配置对象中,该对象将几个不同的东西组合在一起。
它不仅具有此模块列表,而且这里的开发人员正在尝试使用文档正文,滚动宽度和高度来计算浏览器视口的大小。
问题是,当我们尝试在构建时评估代码时,没有浏览器,没有视口,没有文档对象。那么,当我们需要确定一半的内容不存在时,如何计算config的值呢?这是 NGC 为这种情况实际执行的操作。
NGC 解析到 config 是一个具有两个属性的对象,
-
其中之一是可以理解的模块数组 -
另外一个则是无法理解的属性对象
在这种情况下,NGC 会返回一种特殊的类型,称为动态值(Dynamic Value),该值意味着 NGC 遇到了无法执行的表达式。
比如这里的不知道如何计算 document.body.scrollWidth。
这样做有两个好处:
-
首先是我们可以利用我们知道的信息,NGC 仍然可以在任何需要的地方使用 models 数组, -
其次是可以友好的做消息提示,告诉开发者一些 NGC 无法理解的动态值。
这是以前的编译器努力解决的问题,开发者会看到编译器指出代码的元数据不正确或遇到了其它难理解的问题。
此处开发者正在尝试在内联样式中使用 viewportSize,并希望编译器需要在这里知道样式值,这实际上是行不通的。
部分求值器无法弄清楚这个值,因为它将另一个无法求出的值作为输入。
四、模板类型检查
模板类型检查是编译器中的一项重要功能。这是一个简单的 Angular 模板。
有一个 account-view 组件,有一个 account 属性,还有一个带有 async 管道的稍微复杂的表达式。即使在这个非常简单的模板中,如果想要正常运行,那么在运行时也需要做很多的纠正工作。
1.account-view 应该是一个组件,带有 account 输入属性。即使在这个非常简单的模板中,如果想要正常运行,那么在运行时也需要做很多的纠正工作。
1.account-view 应该是一个组件,带有 account 输入属性。3.user 应该具有 id 属性。4.最后,async管道在获得任何值之前实际上会返回 null,所以 account 输入属性最好接受 null 作为可能的值。对模板进行类型检查有着巨大地挑战:
-
首先,模板是HTML,它们不是用 TypeScript 编写的,TypeScript 编译器不支持检查 HTML。 -
其次,虽然 Angular 模板的语法和 TypeScript 看起来很相似,但实际上两者是不同的语言,例如 Angular 语法具有 null safe navigation,而 TypeScript 没有。
NGC 要做的第一件事是将所有模板及其所有表达式转换为 TypeScript 代码块,我们将其称为类型检查块。代码不会作为 JavaScript 输出,开发者也不会看到,浏览器中也不会运行。
NGC 将这些 TypeScript 代码块提供给 TypeScript 编译器,并让 TypeScript 编译器返回错误,然后在模板的上下文中展示这些错误。举个例子:
这是开始时的样子,这将要检查具有此模板的应用程序组件。
类型检查块需要一个参数,此上下文变量以及任何引用应用程序组件属性的表达式,我们可以读取此上下文变量。第一个是 AccountViewCmp 的实例,第二个是 async 管道的实例,两者都齐全才能检查绑定。注意两个变量都没有值,对吗?
没关系,我们只关心这里的类型。我们实际上永远不会尝试运行这段代码。
如果此属性不存在,TypeScript 会报错误信息。最后,我们将表达式转换为管道调用,如果此处管道的任何部分未对齐,TypeScript 都会让我们知道。
因此,如果采用此代码并将其提供给 TypeScript 编译器,则可能会返回这样的错误。
Property 'account' does not exist on type 'AccountViewCmp"
TypeScript 代码块会在我们开发的随机代码的上下文中向我们展示,但是这对您作为开发人员没有帮助,因为您从未编写过此代码,编译器最好是直接向开发者显示来自 Angular 模板中的错误。
您可能会想,我们想要做的就是以某种方式为正在生成的代码生成 source maps,然后将其提供给 TypeScript,就像 JavaScript 所做的那样,让它返回错误,但可惜的是 TypeScript 并不支持这一点。Template Error Mapping 是一种非常巧妙的技巧。
这是我们输入 TypeScript 来检查此绑定的简化示例,除了这里的转换代码外,我们还采用了模板表达式并将其转换为TypeScript,您还会看到带有数字的注释。
这些数字是该表达式模板的偏移量。
所以现在当 TypeScript 给 NGC 一个错误并说您的错误在此表达式上时,我们可以在此处查看注释,并查看表达式在模板中的来源。
这样一来,我们就可以在实际模板 HTML 的上下文中为您提供错误消息。
可以看到这里有一个错误提示 Argument of type 'string' is not assignable to parameter of type 'number'
如果这些模板不在 TypeScript 文件而在外部文件中会发生什么?
在之前的 Angular 视图引擎中,这是一个问题点。例如如下所示的错误消息。
Ivy 之前错误提示往往不准确,但在 Ivy 中修复了此问题。
现在,Angular 编译器会准确的指出模板上下文中的错误。最后谈谈 *ngFor 的类型检查,这是在以前的体系结构下不可能实现的一件事。
假设在模板中使用 *ngFor 迭代为 users 重复此 account 视图组件。
那么 Angular 编译器该如何进行类型检查?*ngFor 指令是通用的,它接受类型参数 T 也会标记出一堆行。如果尝试为此模板编写 TypeScript 块,则会遇到问题:
-
首先需要理解什么是 *ngFor 指令 -
其次需要知道什么是 user 循环变量
为了回答第一个问题,NGC 做了一些很酷的事情。
我们生成了一个称为类型构造函数 (Type Constructor) 的特殊函数。
同样,实现并不重要,它实际上不会运行。
这样做是为了允许 NGC 使用类型推断来找出 *ngFor 指令,或者输入给定的一组值,例如 user ,通用类型是什么?是正确的吗?
如果是 user array,则 T 将以 ngFor
它具有 $implicit 属性表示每一行的实际值。问题在于这是高度动态的。
*ngFor 指令中没有任何内容表明它必须创建具有与数组输入相同值的行。并且可以为该行使用任何想要的值。
实际可行的唯一方法是:*ngFor 指令告诉类型检查器它将为每一行创建哪种类型。Angular 的开发者在 Angular Common 附带的 *ngFor 上引入了一个静态函数,称为 ng 模板上下文函数。
它的作用是允许类型检查器询问 *ngFor 指令它将创建哪种类型的行。
在这种情况下,这就是说如果 *ngFor 指令的类型为 T 的 *ngFor 类型,那么每个行上下文的类型将为 *ngFor 上下文,其 $implicit 类型为 T。所以现在 NGC 知道它是否为 *ngFor user,每行将是一个 user。然后,一切都融合在一起。
NGC 可以从输入中推断出 *ngFor 的类型。
从该行的 $implicit 可以看到 *ngFor 指令对每一行使用什么,并且也知道循环变量的类型。
就这样在 Ivy 中,第一次可以在 *ngFor 中进行类型检查。
这对于之前的类型检查器来说是一个很大的盲点,现在可以说做的非常棒。最后是致谢与结束词。