文档结构  
可译网翻译有奖活动正在进行中,查看详情 现在前往 注册?
原作者:Minko Gechev    来源:blog.mgechev.com [英文]
zhongzhong    计算机    2017-01-09    0评/507阅
翻译进度:已翻译   参与翻译: zhongzhong (31), vincentsun (1), 爱上星云 (1)

最近我在angular-种子项目中添加了预编译支持(AoT)并且有很多关于新特性的问题。为了回答大多数问题,我将从头开始解释以下主题。

  • 在Angular中为什么需要编译?
  • 什么东西需要编译?
  • 它是如何被编译的?
  • 编译发生的时机? 即时编译 (JiT) VS预编译 (AoT)。
  • 我们将从AoT中得到什么?
  • AoT编译是如何工作的?
  • 使用AoT和JiT我们失去了什么?

在Angular中我们为什么需要编译?

这个问题的简短答案是 - 我们需要编译,让我们的 Angular应用实现更高水平的效率。 我说的效率主要是指性能方面的改善,但对资源或者对宽带的消耗有时也有改善。

第 1 段(可获 1.53 积分)

AngularJS 1.x 有一个相当动态的渲染和变化检测方法。例如,AngularJS 1.x 中编译器是非常通用的。 它能够为任何模板执行一组动态计算。 虽然这在一般情况下很好, JavaScript虚拟机 (VM) 在较低的水平上优化计算 因为它们的动态特性。 因为虚拟机(VM)为脏值检测逻辑提供上下文的对象的结构  (例如, 所谓的scope),它的内联缓存产生了很多失误,使得执行速度减慢。

第 2 段(可获 1.29 积分)

在Angular版本2以及以上版本, 采取了不同的策略。而不是对每个组件使用相同的逻辑执行渲染和变化检测, 框架会在运行或者构建期间动态生成VM友好的代码。 这允许JavaScript虚拟机执行属性访问缓存并且更快的执行检测/渲染逻辑。

例如,看看下面的例子:

// ...
Scope.prototype.$digest = function () {
  'use strict';
  var dirty, watcher, current, i;
  do {
    dirty = false;
    for (i = 0; i < this.$$watchers.length; i += 1) {
      watcher = this.$$watchers[i];
      current = this.$eval(watcher.exp);
      if (!Utils.equals(watcher.last, current)) {
        watcher.last = Utils.clone(current);
        dirty = true;
        watcher.fn(current);
      }
    }
  } while (dirty);
  for (i = 0; i < this.$$children.length; i += 1) {
    this.$$children[i].$digest();
  }
};
// ...
第 3 段(可获 0.79 积分)

这段代码是拷贝自我的 轻量级 AngularJS 1.x实现。这个代码中我们在整个作用域树上进行深度优化搜索,来查找绑定的变化。这种方法可以在任何指令中工作。 然后,与指令特定的代码相比,它明显慢。

// ...
var currVal_6 = this.context.newName;
if (import4.checkBinding(throwOnChange, this._expr_6, currVal_6)) {
    this._NgModel_5_5.model = currVal_6;
    if ((changes === null)) {
        (changes = {});
    }
    changes['model'] = new import7.SimpleChange(this._expr_6, currVal_6);
    this._expr_6 = currVal_6;
}
this.detectContentChildrenChanges(throwOnChange);
// ...
第 4 段(可获 0.58 积分)

angular种子项目中复制过来的代码片段中,包含了一个编译完成组件的通用的实现方法detectChangesInternal 。它使用直接属性访问获取单个绑定的值,并使用最有效的方法将它们与新值进行比较。 一旦发现它们的值是不相等的,它只更新受影响的DOM元素。

通过回答这个问题“为什么我们需要编译?“ 以及回答这个问题 ”什么事需要编译的?“。我们需要将组件模板编译成JavaScript类。这些类包含用于检测绑定中的更改并呈现用户界面的逻辑的方法。 这样我们就不会连接到底层平台 (除了标记格式)。 换句话说,通过不同的渲染器实现我们可以使用相同的AoT编译代码组件并使它没有任何变化。所以上面的组件可以在NativeScript呈现, 例如, 当渲染器识别传递过来的参数。

第 5 段(可获 2.16 积分)

即时 (JiT) 编译vs 预 (AoT)编译

本节将要回答的问题:

  • 编译发生在什么时候?

一件很酷的事就是 Angular的编译器能够在运行时调用(例如,用户在浏览的时候) 或构建时调用(作为构建过程的一部分)。 这是因为Angular的可移植性 - 我们可以在任何JavaScript VM的平台上运行Angular框架,所以这就是为什么要让Angular编译器能在浏览器和node环境中运行?

事件流和即时编译

让我们来跟踪一下典型的开发流程,在每一 AoT的情况下:

第 6 段(可获 1.21 积分)
  • 使用TypeScript开发Angular应用。
  • 使用tsc编译应用。
  • 构建。
  • 压缩。
  • 部署。

一旦我们部署了应用程序,用户打开浏览器,她将经历以下步骤 (没有严格的CSP):

  • 下载所有JavaScript资源。
  • Angular 启动。
  • Angular通过JIT编译处理过程,例如对我们的应用程序的每一个ie组件生成对应的javascript代码。
  • 应用页面呈现。

事件流与预编译

相比之下,AoT需要通过以下步骤:

第 7 段(可获 1.01 积分)
  • 使用Typescript开发Angular 应用。
  • 使用 ngc来编译应用。
    • 执行Angular的编译器编译模板和生成(通常是)TypeScript文件。
    • 编译TypeScript文件为JavaScript代码。
  • 捆绑。
  • 压缩。
  • 部署。

虽然上述过程似乎更为复杂,用户只需通过步骤:

  • 下载所有的资源。
  • Angular启动。
  • 应用程序被渲染。

正如你所看到的,第三步是没有的,这意味着更快更好的用户体验和一些上面的 angular-seed 和angular-cli这样的工具,将能够自动化的构建这些过程。

第 8 段(可获 1.11 积分)

回顾一下我们之前说的Angular的JiT和 AoT区别:

  • 编译发生的时间。
  • JiT 生成 JavaScript (TypeScript没有很大的作用,因为代码需要被编译为JavaScript在浏览器), however, AoT 通常生成 TypeScript。

一个迷你的 AoT编译demo你可以在这里查看我的GitHub账号

深度预编译

本节回答了这些问题:

  • AoT编译器产生什么工件?
  • 工件的上下文环境是什么?
  • 两者如何去开发: AoT友好以及封装了代码?
第 9 段(可获 1.2 积分)

我们会很快的通过编译,因为没有必要一行一行的去解释 整个@angular/compiler模块。如果你对词法分析,语法分析和代码生成的过程感兴趣, 你可以看一看 “The Angular 2 Compiler” by Tobias Bosch 或者这个 slide deck

Angular的编译器接收一个组件和上下文作为输入 (我们可以把上下文看作组件树中的一个位置。)并产生以下文件:

  • *.ngfactory.ts - 我们将在下一节来看看这个文件。
  • *.css.shim.ts - 基于组件的ViewEncapsulation 的作用域模式的CSS文件。
  • *.metadata.json - 关联当前组件的元数据 (或NgModule)。我们可以认为它是一个使用了 @Component@NgModule装饰器的JSON格式的对象。
第 10 段(可获 1.61 积分)

* 是一个文件的名称的占位符。 对于hero.component.ts, 编译器将生成: hero.component.ngfactory.ts, hero.component.css.shim.ts, 和hero.component.metadata.json这些文件。

*.css.shim.ts 和我们讨论的目的没有太多关联,所以我们不会详细描述它。 如果你想知道更多关于 *.metadata.json 的信息,你可以看看这一节: “AoT和第三方模块”。

*.ngfactory.ts文件内部。

这个文件 *.ngfactory.ts 包含以下定义:

  • _View_{COMPONENT}_Host{COUNTER} - 我们称之为 “internal host component”(内部宿主组件)。
  • _View_{COMPONENT}{COUNTER} - 我们称之为 “internal component”(内部组件)。
第 11 段(可获 0.91 积分)

…还有两个函数:

  • viewFactory_{COMPONENT}_Host{COUNTER}
  • viewFactory_{COMPONENT}{COUNTER}

以上的{COMPONENT}是组件控制器的名称,{COUNTER}是一个无符号整形数据。

都继承自AppView并且实现了以下方法:

  • createInternal - 渲染组件。
  • destroyInternal - 执行清理 (删除事件侦听器等。)。
  • detectChangesInternal - 优化检测内联缓存方法检测变化的实现。

上面的工厂函数只负责生成的AppViews的实例化。

正如我上面提到的, detectChangesInternal 包含VM友好的代码。让我们看看模板的编译版本:

第 12 段(可获 0.99 积分)
<div></div>
<input type="text" [(ngModel)]="newName">

detectChangesInternal方法看起来像这样:

// ...
var currVal_6 = this.context.newName;
if (import4.checkBinding(throwOnChange, this._expr_6, currVal_6)) {
    this._NgModel_5_5.model = currVal_6;
    if ((changes === null)) {
        (changes = {});
    }
    changes['model'] = new import7.SimpleChange(this._expr_6, currVal_6);
    this._expr_6 = currVal_6;
}
this.detectContentChildrenChanges(throwOnChange);
// ...

让我们假设currVal_6的值为3和this_expr_6的值为1, 现在让我们跟踪方法的执行:

第 13 段(可获 0.3 积分)

对于像这样的调用: import4.checkBinding(1, 3),在生产模式下, checkBinding执行以下检查:

1 === 3 || typeof 1 === 'number' && typeof 3 === 'number' && isNaN(1) && isNaN(3);

上面的表达式将返回 false,所以我们要存储改变和更新NgModel指令实例的model的值。之后detectContentChildrenChanges将会被调用,然后回调用所有子视图的detectChangesInternal方法。一旦 NgModel指令发现了改变了值得model属性,他会直接调用渲染器更新对应的元素。

第 14 段(可获 0.9 积分)

迄今为止没有什么不同寻常的事情。

context 属性

也许你已经注意到了 内部组件我们访问this.context这个属性。 这个context属性就是内置组件的控制器自身的实例。 例如:

@Component({
  selector: 'hero-app',
  template: '<h1></h1>'
})
class HeroComponent {
  hero: Hero;
}

this.context 就等于 new HeroComponent()。这意味着,在 detectChangesInternal方法中 我们要访问this.context.name。问题就出在这里。如果我们在Aot编译过程中想要生成TypeScript的输出,我们必须确保我们的模板组件只访问公共的字段属性。 这是为什么呢? 正如我们已经提到的编译器可以产生TypeScript和JavaScript。由于TypeScript的访问修饰符,并执行只能访问公共属性在继承链,在内部组件的里面我们可以访问上下文对象的任何一部分私有属性。 所以:

第 15 段(可获 1.64 积分)
@Component({
  selector: 'hero-app',
  template: '<h1></h1>'
})
class HeroComponent {
  private hero: Hero;
}

…和…

class Hero {
  private name: string;
}

@Component({
  selector: 'hero-app',
  template: '<h1></h1>'
})
class HeroComponent {
  hero: Hero;
}

将在生成 *.ngfactory.ts过程中引发编译时错误。 在第一个例子中我们不能访问 hero因为它是HeroComponent组件内部私有的属性,第二个例子中在组件内部不能访问hero.name, 因为name是Hero内部私有的属性。

AoT 和封装

第 16 段(可获 0.56 积分)

好的,我们只绑定公共属性,只调用模板内的公共方法,但组件封装会发生什么? 这可能看起来并不是一个大问题,但是想象一下下面的场景:

// component.ts
@Component({
  selector: 'third-party',
  template: `
    
  `
})
class ThirdPartyComponent {
  private _initials: string;
  private _name: string;

  @Input()
  set name(name: string) {
    if (name) {
      this._initials = name.split(' ').map(n => n[0]).join('. ') + '.';
      this._name = name;
    }
  }
}
第 17 段(可获 0.5 积分)

上面的组件有一个单一的输入 - name。在name的setter方法里面, _initials属性的值是计算出来的。 我们可以像下面这样使用组件:

@Component({
  template: '<third-party [name]="name"></third-party>'
  // ...
})
// ...

因为在JiT模式,Angular的编译器生成JavaScript, 这非常完美!每一次name属性的变化,并且name不为空的时候, _initials属性都将会被重新计算。然而,第三方的组件实现不是Aot友好的(为了确保您只能访问模板中的公共/现有字段,您可以使用codelyzer。 如果我们想这样做我们必须改变它:

第 18 段(可获 1.14 积分)
// component.ts
@Component({
  selector: 'third-party',
  template: `
    
  `
})
class ThirdPartyComponent {
  initials: string;
  private _name: string;

  @Input()
  set name(name: string) {...}
}

因此,这样的事情将是可能的:

import {ThirdPartyComponent} from 'third-party-lib';

@Component({
  template: '<third-party [name]="name"></third-party>'
  // ...
})
class Consumer {
  @ViewChild(ThirdPartyComponent) cmp: ThirdPartyComponent;
  name = 'Foo Bar';

  ngAfterViewInit() {
    this.cmp.initials = 'M. D.';
  }
}
第 19 段(可获 0.1 积分)

…这会让ThirdPartyComponent组件处于不一致的状态。ThirdPartyComponent实例中的 _name属性的值将会是Foo Bar, initials 将会是 M. D.(而不是F. B.)。

如何解决这个问题的根本在于 Angular的代码。如果我们想我们的代码是AoT友好的(例如,只绑定公共属性和方法到模板),同时保持封装,我们可以使用TypeScript的注释/** @internal */:

// component.ts
@Component({
  selector: 'third-party',
  template: `
    
  `
})
class ThirdPartyComponent {
  /** @internal */
  initials: string;
  private _name: string;

  @Input()
  set name(name: string) {...}
}
第 20 段(可获 0.96 积分)

这个initials属性将会是公开的,但是如果我们使用tsc编译我们的第三方库的时候加上 --stripInternal 和--declarations标志,这个initials属性的类型声明将从ThirdPartyComponent的类型声明文件中忽略(例如 d.ts 文件) 。 这样我们就能够在我们自己绑定的库中访问它, 但不会被消费者访问。

回顾 ngfactory.ts

现在让我们做一个快速回顾幕后发生了什么!从上面的示例中,我们假设我们已经有了HeroComponent。对于这个组件,Angular编译器将会生成两个类文件。

第 21 段(可获 1.14 积分)
  • _View_HeroComponent_Host1 -  internal host component(内部宿主组件)
  • _View_HeroComponent1 -  internal component(内部组件)

_View_HeroComponent1将会负责渲染HeroComponent的模板和变更检测。当执行变更检测时_View_HeroComponent1将会比较 this.context.hero.name的当前值和当前存储的值。如如果值是不同的, 这个 <h1/>元素将会被更新。 这就意味着我们需要确保this.context.hero 和hero.name都是公开的(public类型)。可以通过 codelyzer来验证。

第 22 段(可获 0.85 积分)

另一方面, _View_HeroComponent_Host1 将负责渲染 <hero-app></hero-app>(the host element), 和_View_HeroComponent1它自身。

你可以找到整个例子总结,如下图:

AoT Summary

AoT vs JiT - 开发经验

在这节中,我们将讨论另外一个在AoT和JiT之间的开发经验的不同点。

也许最大的区别是JIT影响开发经验的事实是,在JIT模式的内部组件和内部主机组件将被定义在JavaScript。 这意味着我们组件的控制器中的字段总是公开的,所以如果我们的内部组件访问其上下文中的任何私有字段,我们不能得到任何编译时错误。

第 23 段(可获 1.44 积分)

在JiT模式中,一旦我们已经启动应用,我们就已经有了根注入器并且所有的指令在根组件中都是可用的 (它们都被包含在 BrowserModule以及我们在根模块导入的其它模块的)。这些元数据将会传递给根组件的编译器的编译过程中。一旦编译在JiT模式下生成了代码, 它拥有所有用于生成所有子组件代码的元数据。它能够生成它们的代码,因为它不仅知道那些provider在这个级别的组件上可用而且还知道哪些指令是可见的。

第 24 段(可获 1.46 积分)

这将允许编译器知道在模板中访问元素时该做什么。例如,这个 <bar-baz></bar-baz> 元素可以用两种不同的方式解释,取决于是否有一个指令/组件是否拥有一个bar-baz的选择器。 编译器是否只创建一个元素 bar-baz 或者实例化组件相关的选择器 bar-baz 依赖于编译过程当前阶段的元数据。 (在当前状态)。

问题来了。在构建时,我们如何知道在组件树的各个层次上可以访问什么指令? 由于Angular的伟大设计,我们可以执行静态代码分析发现这些。Chuck Jazdzewski 和Alex Eagle在 MetadataCollector关联模块方向上做了很多惊人的工作。收集器要做的就是遍历每一个单独的组件树和Ng模块来提取所有的元数据。这涉及到一些更加高深的技术,已经超出了这个博客帖子的讨论范围。

第 25 段(可获 2.09 积分)

AoT和第三方模块

好了, 所以编译器需要组件的元数据来编译它们的模板。假设在我们的应用程序使用一个第三方组件库。Angular的AoT编译器如何它们定义的元数据分布在哪里,如果是纯javascript写的话?它不知道。 为了能够预编译应用程序用了Angular之外的模块,这些库需要提供由编辑器生成的*.metadata.json文件。

为了进一步了解如何使用 Angular编译器,你可以看一下 以下链接。 例如,如何建立自定义库为AoT做准备, 你可以看看 angular/mobile-toolkit

第 26 段(可获 1.55 积分)

我们能从AoT中得到什么?

正如你可能已经猜到的,从AoT中我们得到了性能。 使用了AoT开发的每一个Angular应用程序初始化渲染的时候都要比使用JiT开发出来的应用要快的多,因为Javascript的虚拟机需要执行的计算更少。在开发过程中我们只编译一次模板,之后用户获取编译的模板都是免费的。

在下图中你可以看到它与JIT执行初始渲染多少时间:

在下图中你可以看到它与JIT执行初始渲染多少时间:

第 27 段(可获 1.36 积分)

另一个关于Angular编译器很给力的事是它不仅可以生成JavaScript的代码也能生成TypeScript 的代码。 这就允许我们在模板中执行类型检查。

因为应用程序的模板是纯JavaScript/TypeScript的,我们知道是什么和在哪里去使用。这就允许我们去执行有效的摇树优化和在生产(环境的js)包去掉我们的应用中的所有没有使用到指令/模块。 之上,我们不需要包括 @angular/compiler模块在生产环境的包中,因为我们不需要运行时编译。

第 28 段(可获 1.21 积分)

请注意,对于大中型的应用进行AoT编译可能会比使用JiT编译后的包更大。  这是因为相对于HTML类似的模板来说,ngc生成的对于javascript虚拟机友好的代码可能更大,其中还包括了脏值检测逻辑。如果你想减小应用程序的大小,你可以执行懒加载策略,Angular的路由天生支持这种策略。

在某些情况下,JiT 编译不能执行。因为JiT生成和执行代码在浏览器中它使用eval。 CSP和一些特定的环境不允许我们动态的执行生成的源代码。

第 29 段(可获 1.39 积分)

最后但同样重要的是,能源效率! 用户设备将执行更少,因为它接收的是已经编译后的代码。这减少了电池的消耗,但是是多少?这里有一些有趣的计算结果 :-):

在这个结果上进行分析 “谁耗尽了我的电池:手机浏览器能耗分析” (by N. Thiagarajan, G. Aggarwal, A. Nicoara, D. Boneh, and J. Singh), 下载和解析jQuery当访问维基百科需要大约4焦耳的能量。由于本文没有提及具体版本的jQuery,基于出版的时间,我认为它是谈论v1.8.x。 由于维基百科使用gzip压缩的静态内容这意味着jQuery1.8.3的包大小是33 k。 @angular/compiler模块的gzip压缩的最小版的大小为103K。这意味着它会花费我们大约12.5 j下载编译器,使用JavaScript虚拟机处理等。(我们忽略了我们没有执行JiT这个事实, 这将进一步减少处理器的使用。 我们这样做是因为在这两种情况下 - jQuery 和@angular/compiler 我们只能打开一个TCP连接,这是能量消耗最大的一个地方。)

第 30 段(可获 2.54 积分)

iPhone 6s的电池容量为6.9Wh,即 24840J。根据AngularJS 1.x官方网站的月访问量,有至少一百万开发者平均在5个Angular应用上开发。每个应用每天有大约100个用户。5个应用 * 1百万 * 100个用户 = 500万。要是我们使用 JiT,并且我们下载 @angular/compiler,这会消耗地球 5亿 * 12.5J = 6250000000J能量,即1736.111111111KWh。根据Google的数据,在美国1KWh = ~12 美分,这意味着我们将花费大概210美元用于恢复一天所消耗的能量。请注意,我们甚至没有通过应用tree-shaking而进行进一步优化,而这个优化可能会允许我们将应用程序大小缩减至少两倍!:)

第 31 段(可获 1.54 积分)

结论

Angular的编译器显著的提高了我们的应用程序的性能,通过利用JavaScript虚拟机的内联缓存机制。最重要的是我们将它作为我们构建过程的一部分,它能解决例如禁止eval等这些问题,允许我们进行更高效的摇树优化,提高了初始渲染的效率。

不在运行时执行编译是否会丢失任何东西?在一些特定的情况下,我们可能需要对特定的需求生成组件的模板。这将要求我们加载未编译的组件并在浏览器中执行编译过程, 在这种情况下,我们需要包括 @angular/compiler,作为我们应用程序包的一部分。AoT编译的另一个潜在的缺点是大中型应用将会增加包的大小。 由于生成的JavaScript组件模板与模板本身具有更大的规模,这可能会导致最终编译的包更大。

第 32 段(可获 1.99 积分)

总之, 预编译是非常棒的技术并已被整合到angular-seedangular-cli中 , 今天就开始使用吧。

引用

第 33 段(可获 0.66 积分)

文章评论