lihuakkk/blog

Inside Ivy: Exploring the New Angular Compiler(译)

lihuakkk opened this issue · 0 comments

本文并不是原文的完全翻译,只对原文重点部分进行翻译。主要内容是对新的Ivy编译器的探索,可以参考第一篇关于老的编译器的文章。

Ivy,发音跟罗马数字IIV类似得名,是 Angular 的第三代渲染引擎。
相较于早期的编译器,Ivy是 “tree-shaking friendly,”,它会为我们移除没有用到的代码(即使是Angular自身的功能),缩小打包后的文件体积。Ivy单独编译每个文件,这会减小重新编译所花费的时间。简而言之,Ivy将给我们带来更小的编译后的文件,更快的重新编译的速度和更简单的编译后的代码。

Running Ivy

Ivy目前仍处于开发阶段,你可以在这里查看进度,在Angular 6.x版本,部分功能已经可用,被称作“RendererV3”。

即使Ivy还没有100%开发完成,我们还是可以一窥下编译后的代码长什么样子。
新建一个Angular项目:

ng new ivy-internals

然后在tsconfig.json文件里添加下面的代码启用Ivy编译器:

"angularCompilerOptions": {
  "enableIvy": true
}

然后在新创建的项目目录下执行ngc:

node_modules/.bin/ngc

我们可以在dist/out-tsc目录下查看生成的代码。下面是摘自AppComponent的代码。

<div style="text-align:center">
  <h1>
    Welcome to {{ title }}!
  </h1>
  <img width="300" alt="Angular Logo" src="">
</div>
<h2>Here are some links to help you start: </h2>

生成的代码在dist/out-tsc/src/app/app.component.js这个文件中,摘录如下:

 i0.ɵE(0, "div", _c0);
 i0.ɵE(1, "h1");
 i0.ɵT(2);
 i0.ɵe();
 i0.ɵE(3, "img", _c1);
 i0.ɵe();
 i0.ɵe();
 i0.ɵE(4, "h2");
 i0.ɵT(5, "Here are some links to help you start: ");
 i0.ɵe();

Ivy将组件模版内容编译成JavaScript代码,可以对比老版本编译器生成的代码:

image

显然Ivy生成的代码更加简单!修改组件模版文件 ( src/app/app.component.html ) 然后再次运行编译,观察刚刚做的修改对生成的代码产生的影响。

Understanding the Generated Code

我们梳理下生成的代码,看看每一行的作用,生成文件的第一行:

var i0 = require("@angular/core");

i0表示Angular的核心模块,上面那些函数都是这个模块导出的。
这个希腊字母ɵ 被Angular团队用来表示框架的私有方法,用户不应用直接调用,因为这种方法在不同的Angular版本中很有可能发生变化。
这些方法都是Angular的私有方法,使用VS Code代码提示工具我们可以看到每一个方法的详细信
息。

image

类似,ɵT表示text,ɵe表示elementEnd。有了这些知识,我们可以将生成的代码重写成更加可读的代码。

var core = require("angular/core");
//...
core.elementStart(0, "div", _c0);
core.elementStart(1, "h1");
core.text(2);
core.elementEnd();
core.elementStart(3, "img", _c1);
core.elementEnd();
core.elementEnd();
core.elementStart(4, "h2");
core.text(5, "Here are some links to help you start: ");
core.elementEnd();

上面的代码对应的组件模版如下:

<div style="text-align:center">
  <h1>
    Welcome to {{ title }}!
  </h1>
  <img width="300" alt="Angular Logo" src="">
</div>
<h2>Here are some links to help you start: </h2>

我们很容易就可以发现:
1、每一个HTML开始标签对应core.elementStart()
2、每一个HTML结束标签对应core.elementEnd()
3、每一个文本节点对应一个core.text()
elementStart和text方法的第一个参数是一个数字,每次调用这两个函数,这个数字都会增加。Angular以这些数字为索引将这些生成的元素引用保存在一个数组里面。
elementStart第三个参数是可选参数,这个参数是当前DOM节点的属性集合,我们可以查看_c0的值验证,_c0 = ["style", "text-align:center"]。

目前为止,我们已经初步了解了编译器如何利用编译生产的代码渲染组件模版。这部分代码其实是名为AppComponent.ngComponentDef的属性的一部分。这个属性包括关于组件的所有元数据,比如css选择器,变更检查策略,模版等。

Do-It-Yourself Ivy

了解过被Ivy编译过的代码,我们可以尝试使用Ivy一样的RendererV3 API自己构建组件。
我们要写的代码跟编译器生成的类似,只不过我们会以一种更可读的方式来编写,从一个简单的组件开始。

源文件

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

@Component({
selector: 'manual-component',
template: '<h2>Hello, Component</h2>',
})

export class ManualComponent {
}

编译器提取@component装饰器里的数据,生成组件类对应的静态属性。为了模拟编译过程,我们需要移除
@component装饰器,使用ngComponentDef这个静态属性:

源文件

import * as core from '@angular/core';

export class ManualComponent {
  static ngComponentDef = core.ɵdefineComponent({
    type: ManualComponent,
    selectors: [['manual-component']],
    factory: () => new ManualComponent(),
    template: (rf: core.ɵRenderFlags, ctx: ManualComponent) => {
      // Compiled template goes here
    },
  });
}

调用ɵdefineComponent为组件定义元数据。元数据包括组件的类型(以后依赖注入用),selector(s)是这个组件被其他组件模版使用时的名字,factory返回这个组件的一个新的实例,template函数返回组件模版的定义。template函数渲染组件视图和当组件的属性发生变化时更新UI。我们使用上面遇到的方法ɵE, ɵe and ɵT来编写这个模版。

源文件

template: (rf: core.ɵRenderFlags, ctx: ManualComponent) => {
      core.ɵE(0, 'h2');                 // Open h2 element
      core.ɵT(1, 'Hello, Component');   // Add text
      core.ɵe();                        // Close h2 element
    },

现在我们还没使用template函数提供的rf和ctx参数,别急很快我们就会用到。

Our first manually-crafted app

Angular导出了一个名为ɵrenderComponent的方法,将组件渲染到浏览器。我们只要确保在index.html文件中有一个HTML标签匹配我们的组件选择器<manual-component>,然后将下面的代码添加到源文件的最后一行。

core.ɵrenderComponent(ManualComponent);

现在我们实现了手动编译一个Angular应用,虽然只有16行。

Adding Change Detection

为了让组件可以交互,我们把变更检测(Change Detection)加上。修改组件让用户可以自定义“Hello.”之后的内容。我们需要在组件类中新增一个name属性和一个更新name值的方法。

源文件

export class ManualComponent {
  name = 'Component';

  updateName(newName: string) {
    this.name = newName;
  }
  
  // ...
}

接下来我们修改template函数,用name的值来代替静态的文本。

源文件

template: (rf: core.ɵRenderFlags, ctx: ManualComponent) => {
  if (rf & 1) {   // Create: This runs only on first render
    core.ɵE(0, 'h2'); 
    core.ɵT(1, 'Hello, ');
    core.ɵT(2);   // <-- Placeholder for the name
    core.ɵe();
  }
  if (rf & 2) {   // Update: This runs on every change detection
   core.ɵt(2, ctx.name);  // ctx is our component instance
  }
},

与原来相比多了两个与rf值相关的if语句。Angular用rf参数来表示这个组件是否时第一个创建或者是否需要更新组件内容在一个变更检测期间(第二个触发条件)。
组件初始化渲染的时候,我们创建所有的元素。当变更检测触发的时候,我们只需要更新变化部分的UI。ɵt对应的是Angular导出的textBinding方法。

image

从上面的注释可以看到第一个参数表示元素的索引,第二个是要插入的文本的值。在我们的例子里,我们在第5行生成一个空的索引为2的文本元素,将其作为name属性的占位符,当变更检测执行的时候在第9行更新它的内容。

现在我们看到的仍然是“Hello, Component”。
为了实现真正的交互,我们要添加一个input输入框和updateName()这个事件监听输入事件。

源文件

template: (rf: core.ɵRenderFlags, ctx: ManualComponent) => {
  if (rf & 1) {
    core.ɵE(0, 'h2'); 
    core.ɵT(1, 'Hello, ');
    core.ɵT(2);
    core.ɵe();
    core.ɵT(3, 'Your name: ');
    core.ɵE(4, 'input'); 
    core.ɵL('input', $event => ctx.updateName($event.target.value));
    core.ɵe();
  }
  // ...
},

第9行是绑定事件的地方,ɵL方法为最近定义的元素绑定事件监听。第一个参数是事件类型(这我们这个例子里面是 input,当元素的内容发生改变的时候出触发),第二个参数是一个callback。这个回调函数以DOM Event作为参数,从这里获取目标的值并将其传到组件里面定义的接收函数。
跟下面的HTML代码实现的效果等效。

Your name: <input (input)="updateName($event.target.value)" />

现在,你可以修改输入框的内容,然后你会看到对应的打招呼的内容发生改变。但是,在模版初始化的时候input没有显示对应的值,需要在执行变更检测阶段添加一段代码:

源文件

template: (rf: core.ɵRenderFlags, ctx: ManualComponent) => {
  if (rf & 1) { ... }
  if (rf & 2) {
    core.ɵt(2, ctx.name);
    core.ɵp(4, 'value', ctx.name);
  }
}

实现组件交互的时候,我们引入了另一个方法ɵp,这个方法会根据我们提供的元素索引和新的值更新对应的元素。上面的例子中,元素索引值是4,这是我们定义input元素时的索引,然后将ctx.name的值赋给该元素的value属性。
最终我们使用Ivy rendered API从头开始简单完成了一个数据双向绑定。
目前为止,我们已经熟悉了Ivy生成的一些基础构建代码:知道怎么创建一个元素和文本节点,知道怎么绑定属性和事件监听,知道了怎么处理变更检测。

One More Thing

在本文结束前,在看一下另一个有意思的问题: 编译器怎么处理子模版的?如果模版里面使用了ngIf 或者 ngFor,编译器对它们有不同的处理。接下来,了解下手工编译模版里面使用ngIf。
首先安装@angular/common包,这是
ngIf定义的地方。

import { NgIf } from '@angular/common';

因为NgIf尚未经过编译,为了能在我们的模版里面使用,需要添加一些额外的代码。
使用之前用过的ɵdefineComponent的姊妹方法,ɵdefineDirective这个方法定义指令的元数据:

源文件

(NgIf as any).ngDirectiveDef = core.ɵdefineDirective({
  type: NgIf,
  selectors: [['', 'ngIf', '']],
  factory: () => new NgIf(core.ɵinjectViewContainerRef(), core.ɵinjectTemplateRef()),
  inputs: {ngIf: 'ngIf', ngIfThen: 'ngIfThen', ngIfElse: 'ngIfElse'}
});

这段代码的出处在这里 in Angular’s source code,就在ngFor的定义下面。
现在我们已经正确设置了NgIf指令,可以把它添加进组件的指令列表里面:

源文件

static ngComponentDef = core.ɵdefineComponent({
  directives: [NgIf],
  // ...
});

接下来,我们定义一个使用*ngIf的子模版。
例如我们想要显示一个图片,我们在template函数定义个新的template函数。

源文件

function ifTemplate(rf: core.ɵRenderFlags, ctx: ManualComponent) {
  if (rf & 1) {
    core.ɵE(0, 'div');
    core.ɵE(1, 'img', ['src', 'https://pbs.twimg.com/tweet_video_thumb/C80o289UQAAKIqp.jpg']);
    core.ɵe();
  }
}

这个template函数跟我们之前见到的没什么不同,在div元素里面创建了一个img元素。
把上面的这些代码片段放在一起,同时将ngIf指令添加进组件的template函数代码里面。

源文件

template: (rf: core.ɵRenderFlags, ctx: ManualComponent) => {
  if (rf & 1) {
    // ...
    core.ɵC(5, ifTemplate, null, ['ngIf']);
  }
  if (rf & 2) {
    // ...
    core.ɵp(5, 'ngIf', (ctx.name === 'Igor'));
  }

  function ifTemplate(rf: core.ɵRenderFlags, ctx: ManualComponent) {
    // ...
  }
},

第4行使用一个新方法ɵC。这个方法声明了一个容器元素,第一个参数是指元素的索引,跟之前索引的作用一样,第二个参数是我们自己定义的子模版生成函数,生成的模版将被放在容器元素里面。第三个参数是元素的标签名,在我们这里没有对应的值。最后一个是指令列表和这个元素的属性集合,ngIf在这里使用。
第8行通过判断ctx.name的值是否等于’Igor’来决定ngIf属性的执行结果。
等价HTML代码如下:

<div *ngIf="name === 'Igor'">
  <img src="...">
</div>

比较经过Ivy编译生产的代码和我们手动编译的代码,感觉还不错。
THE END