Angular 2 中的编译器与预编译(AoT)优化

1,128 阅读6分钟
原文链接: click.aliyun.com

Compilation in Angular

源代码浏览器中可运行的程序之间的过程都可以被认为是Compile过程,在Angular程序中,源代码中可能包含@Directive、@Component、@NgModule、@Pipe等各种内容,无论是TypeScript的Annotation还是Template中的double binding,这些最后都会变成可被浏览器解析的语言运行起来。

我们可以将整个compile过程简化为:

Inputs(源代码)-----Parser(解析器)----->Instantiate(实例化)

在后面的文章中我们逐步来介绍这三部分在Angular中具体的工作和原理

Inputs(源代码)

由于篇幅有限,我们以Component和Directive的组合为例来进行介绍

hello.component.ts

@Component({
  templateUrl:'hello_comp.html'
})
class HelloComp{
  user = {name:'Tobias'};
}

hello_comp.html

<form>
  <div>Hello {{user.name}}</div>
  <input ngModel>
</form>

首先在HelloComp中定义了user的初始化值,并在template中渲染出来,在template中还包含了ngModel绑定的input。

我们将Directive代码也添加进来,Directive的selector支持css选择器,当在template代码中发现符合css选择器中的element时,就会实例化相应Directive。

@Directive({selector:'form'})
class NgForm{...}
@Directive({selector:'[ngModel]'})
class NgModel{
  ...
  constructor(form:NgForm){...}
}

以上的简化代码也很容易理解,form和[ngModel]的selector分别在<form>标签和带有ngModel的attribute标签中生成了对应的Directive实例。值得一提的是在NgModel的directive中依赖了NgForm,这意味着NgModel的实例将在template的父元素中查找form依赖,直到命中为止。

以上我们已经明确了原始代码的所有功能,这些被定义Component和Directive正是我们compiler的Inputs,下面就来介绍Compiler对代码的Parse过程

Parser(解析器)

再关注一下之前的hello_comp.html

<form>
  <div>Hello {{user.name}}</div>
  <input ngModel>
</form>

被parser翻译后的template应该对compiler更加友好,以AST(Abstract Syntax Tree)的方式对template中的HTML重新组织之后,我们可以获得以下的JSON数据

{
  name:'form',
  children:[
    {
      name:'div',
      children:[{text:'Hello'},{text:''}]
    },
    {
      name:'input',
      attrs:[['ngModel','']]
    }
  ]
}

以上的数据表示了HTML,相当简单易懂。而template中的binding可以用以下的JSON表示

{
  text:'',
  expr:{propPath:['user','name']},
  line:2,
  col:14
}

text代表着初始化的数据,因为依赖ts代码中的输入,所以默认为空。expr包含着Angular程序中在template的表达式信息,propPath中包含着数据的路径,当在expression中使用pipe或者*ngFor等代码时 ,expr中包含的内容会有更复杂的表现,linecol保留了binding中原始的位置信息,这点很重要,当template报错的时候可以精确告诉开发者template中哪一行代码发生了问题,如果你开发过Angular 2程序,你一定见过这种报错:

Uncaught EXCEPTION: Error in hello_comp.html: 2:14
Uncaught TypeError: Cannot read property 'name' of undefined

现在我们的Parser已经可以解析出Component的内容了,对于Directive又该如何表示呢,我们仍然可以在AST JSON中进行表示

{
  name:'input',
  attrs:[['ngModel','']],
  directives:[
    {
      ctor:NgModel,
      deps:[NgForm]
    }
  ]
}

以上我们已经将所有的代码parse成了对compiler友好的AST JSON格式,下一步就是将parse得到的数据进行实例化,让App可以真正运行起来。

Instantiate(实例化)

首先介绍NgElement的数据结构,NgElement是Angular 2中很重要的一部分,负责将AST转化回DOM结构,并完成相应的binding和Directive等内容的实例化。

class NgElement{
  parrent:ngElement;
  doEl:HTMLElment;
  directives:map;
  constructor(parent:NgElment,ast:ElementAst){
    this.domEl = document.createElement(ast.name);
    ast.attrs.forEach((atrr)=>{
        this.domEl.setAttribute(attr[0],atrr[1]);
    })
    parent.domEl.appendChild(this.domeEl);
  }
}

NgElement中的这部分代码相当简单,生成了AST JSON中对应的DOM结构,并添加了对应attribute,针对Directive部分的处理如下,逐层实例化AST JSON中的ctor和deps

class NgElement{
  parrent:ngElement;
  doEl:HTMLElment;
  directives:map;
  getDirectiveDep(dirType){
    if(this.directives.has(dirType)){
        return this.directives.get(dirType);
    }
    return this.parent.getDirectiveDep(dirType);
  }
  createDirective(dirAst){
    var deps = ast.deps.map((depType)=>this.getDirectiveDep(depType));
    this.directives.set(ast.ctor,new ast.ctor(...deps));
  }
}

针对于Template中的binding部分,通过AST JSON中的expr表达式来进行脏值检查,并将数据存储在target中,在Angular 2中最简单取出该值的方式就是<div #testDiv></div>testDiv就是binding中的target,所有的数据都会存储在target中。

class Binding{
  target: Node;
  targetProp: string;
  expr: BindingAST;
  lastValue: any;
  check(component:any){
    try{
      var newValue = evaluate(this.expr,component);
      if(newValue !== this.lastValue){
        this.target[this.targetProp] = this.lastValue = newValue;
      }
    } catch(e){
      throw new Error(`Error in ${this.expr.line}:${this.expr.col}:${e.message}`);
    }
  }
}

最后我们会有View类来整合NgElement和binding中的脏值检查

class View{
  component:any;
  ngElements: NgElment[];
  bindings: Binding[];
  dirtyCheck(){
    this.bindigns.forEach(binding=>binding.check(this.component));
  }
}

通过以上的步骤,我们可以将Parser生成的AST转化为可以运行的App,然而Compiler的功能不仅仅是将源代码转换AST再转换为可运行程序,在compile的过程中对性能进行优化也是很重要的一步。

Compiler性能优化

在NgElement对Directive处理的代码中,我们看到其中directives的类型是Map,如果我们将所有的directives都列举出来,将代码转换为

class InlineNgElement{
  ...
  dir0,dir1,...:any;
  dirType0,dirType1,...:any;
  getDirecitveDep(dirType){
    if(type === this.dirType0) return this.dir0;
    if(type === this.dirType1) return this.dir1;
    ...
  }
}

这样的代码看起来可能很奇怪,多层If语句的可能会影响函数性能,但是Javascript V8的虚拟机的Fast Property Access via Hidden Classes机制却可以将这类代码进行很好的优化从而获得更高的性能。

我们将NgElement转换为InlineNgElement以获得更高的性能,然而我们的View类中却仍然含有大量的Array,如何让View也利用V8虚拟机的Fast Property优化,其实方法也很明确:我们只需要按正确的顺序初始化DOM,并且在Directive的初始化过程中也依照正确的顺序,保证被依赖的directive先被初始化生成就可以了。

初始化DOM结构

function HelloCompView(component){
  this.component = component;
  this.node0 = document.createElement('form');
  ths.node1 = document.createElement('div');
  this.node0.appendChild(this.node1);
}

初始化Directive

function HelloCompView(component){
  ...
  this.dir0 = new NgForm();
  ...
  this.dir1 = new NgModel(this.dir0);
}

再讲binding中的dirtyCheck对应到相应的node

HelloCompView.prototype.dirtyCheck = function(){
  var v = this.component.user.name;
  if(v !== this.exprVal0){
    this.node3.ngModel = v;
    this.exprVal0 = v;
  }
}

通过以上的步骤,我们View全部可以利用Fast Property特性进行优化,当然所有的component的代码都需要根据component中directive和expression等内容单独生成,我们需要针对每个component生成单独的compile代码

class CompileElement{
  domElProp:string = new PropertyVar();
  stmts: string[];
  constructor(parent:CompileElement,ast:ElementAst){
  this.stmts = [`
      this.${domElProp} = document.createElement('${ast.name}');
      this.${parent.domElProp}.appendChild(${this.domElProp});
  `]
  }
}

在以上的代码中,我们展示了Angular的Parser和Instantiate是如何协同工作的,通过优化Instantiate的代码,利用V8虚拟机的性能优化,Angular 2再次获得了将近1倍的性能提升。

然而有一个问题被我们忽略了,我们应该使用什么作为Parser? 使用浏览器是个很好的主意,浏览器很适合用于解析HTML,在Angular 1和Angular 2中我们也的确可以使用浏览器作为Parser,这也就是JIT(Just In Time)编译的部分,所有的Compile过程全都是在浏览器端进行的。

屏幕快照 2016-12-26 下午4.13.25.png

如果我们可以将CompileElement的过程放在Server端,那浏览器端承载的工作量就会大幅度减少,相应的页面加载时间也会大幅度减少

屏幕快照 2016-12-26 下午4.20.24.png

Angular团队已经实现了可以在server端对代码进行parse的工具:compiler-cli

官方提供的angular-cli通过ng serve --aotng build --prod --aot也支持实时aot的实时预览与生产代码生成,github上的angular2-aot-webpack项目提供了简单的webpack实现

用作者手中的一个Angular项目比对一下JIT和AOT的性能

JIT Compile

屏幕快照 2016-12-26 下午4.31.44.png

AOT Compile

屏幕快照 2016-12-26 下午4.31.22.png

效果感人

支持AOT

AOT优化虽然带来了相当大的性能提升,但是由于AOT的特性,部分在JIT模式下可用的方法在AOT下是不可行或者官方不建议的,在github上的webpack2-starter总结了会导致AOT编译失败的情况:

  • Don’t use require statements for your templates or styles, use styleUrls and templateUrls, the angular2-template-loader plugin will change it to require at build time.
  • Don’t use default exports.
  • Don’t use form.controls.controlName, use form.get(‘controlName’)
  • Don’t use control.errors?.someError, use control.hasError(‘someError’)
  • Don’t use functions in your providers, routes or declarations, export a function and then reference that function name
  • Inputs, Outputs, View or Content Child(ren), Hostbindings, and any field you use from the template or annotate for Angular should be public

It's just "Angular"

尾巴

Angular官方在2016年12月13日宣布了一个非常"耸人听闻"的消息:将在2017年3月份跳过3.0版本正式release Angular 4.0。不过Angular官方随后快速放出了定心丸,4.0版本只不过是Angular团队将命名方式切换为Semantic Versioning(SEMVER),并且向下兼容2.0,这么一看就很容易理解了,React的版本号都已经15了,Angular的版本到4.0也没有什么大惊小怪的。

另外一个问题就是3.0版本去哪了,一路从rc版本使用Angular 2.0的用户都知道@angular/router曾经废弃掉了一个版本,这样目前的版本号就变得很尴尬,@angular/core,@angular/compiler,@angular/http等版本号都是保持一致的,而@angular/router的版本号却永远高出一个版本,当主版本号是2.3.1时,router的版本号却已经是3.3.1了,为了保持版本一致,Angular将越过3.0版本直接统一从4.0开始。

为了避免各种Angular版本号给开发者造成不必要的误解,也为了避免整个社区割裂,Angular团队号召大家在非必要情况下忽略版本号,比如:我是一个Angular开发者,这是一个Angular会议,Angular的生态系统发展很快等等。在培训和介绍的时候使用版本号,例如本文介绍的内容就是针对于Angular 2版本的。

参考资料

[1​]: juristr.com/blog/2016/1… "it's going to be Angular 4.0, or simply Angular"
[2​]: youtu.be/kW9cJsvcsGo "The Angular 2 Compiler Tobias Bosch"
[​3]: blog.mgechev.com/2016/08/14/… "Ahead-of-Time Compilation in Angular"
[​4]: angular.io/docs/ts/lat… "AHEAD-OF-TIME COMPILATION"
[5​]: github.com/v8/v8/wiki/… "V8 Design Elements"