前兩天,Jigsaw七巧板上來了個issue https://github.com/rdkmaster/jigsaw/issues/113(https://github.com/jackjoy) 在issue中提到了一篇介紹Angular AoT的文章,我看了一下,覺得講的非常好,還涉及到一些Angular編譯原理的內容。于是打算翻譯一下,讓大伙都能夠讀一讀,多了解一點AoT知識。
文中的第一人稱“我”均指代作者本人(http://blog.mgechev.com)
原文地址是 http://blog.mgechev.com/2016/08/14/ahead-of-time-compilation-angular-offline-precompilation/
最近我給angular-seed增加了對Ahead-of-Time(AoT)編譯的支持,這引來了不少關于這個新特性的問題。我們從下面這些話題開始來回答這些問題:
- 為什么Angular需要編譯?
- 什么東西會被編譯?
- 他們是如何被編譯的?
- 編譯發(fā)生在什么時候?JiT vs AoT
- 我們從AoT中獲得了什么?
- AoT編譯是如何工作的?
- 我們使用AoT和JiT的代價是什么?
為什么Angular需要編譯?
這個問題的簡短回答是:編譯可以讓Angular應用達到更高層度的運行效率,我所說的效率,主要是指的性能提升,但也包括電池節(jié)能和節(jié)省流量。
AngularJs1.x 有一個實現渲染和變化檢測的很動態(tài)的方式,比如AngularJs1.x的編譯器非常通用,它被設計為任何模板實現一系列的動態(tài)計算,雖然它在通常情況下運行的很好,但是JS虛擬機的動態(tài)特性讓一些低層次的計算優(yōu)化變得很困難。由于js虛擬機無法理解那些我們作為臟檢查的上下文對象(術語為scope)的形態(tài),虛擬機的內聯緩存常常不精確,這導致了運行效率的下降。
譯者:scope是AngularJs1.x中的一個重要對象,他是AngularJs1.x用于計算模板的上下文。
Angular2+采用了一個不同的方式。在給每個組件做渲染和變化檢測的時候,它不再使用同一套邏輯,框架在運行時或者編譯時會生成對js虛擬機友好的代碼。這些友好的代碼可以讓js虛擬機在屬性訪問的緩存,執(zhí)行變化檢查,進行渲染的邏輯執(zhí)行的快的多。
舉個例子,看看下面的代碼:
// ...
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();
}
};
// ...
這個代碼片段來自《輕量級angularJs1.x實現》一文。這些代碼實現了對scope樹做深度優(yōu)先搜索,目的是為了尋找綁定數據的變化,這個方法對任何指令都生效。這些代碼顯然比下面這些直接指定檢查的代碼慢:
// ...
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);
// ...
譯者:
《輕量級angularJs1.x實現》的地址是 <https://github.com/mgechev/light-angularjs/blob/master/lib/Scope.js#L61-L79
這里一下子提及了angularJs1.x的好幾個概念,包括scope,數據綁定,指令。不熟悉angularJs1.x的同學理解起來費勁,想弄懂的話,自行搜索吧。個人認為可以無視,畢竟這個文章的重點不是在這里。你就認為Angular2+的處理方式比angularJs1.x牛逼很多就好了,哈哈。
上面代碼包含了一個來自angular-seed的某個編譯后的組件的代碼,這些代碼是由編譯器生成的,包含了一個 detectChangesInternal 方法的實現。Angular框架通過直接屬性訪問的方式讀取了數據綁定中的某些值,并且采用了最高效的方式與新的值做比較。一旦Angular框架發(fā)現這些值發(fā)生了變化,它就立即更新只受這些數據波及的DOM元素。
在回答了“為什么Angular需要編譯”這個問題的同時,我們同時也回答了“什么東西會被編譯”這個問題。我們希望把組件的模板編譯成一個JS類,這些類包含了在綁定的數據中檢測變化和渲染UI的邏輯。通過這個方式,我們和潛在的平臺解耦了。換句話說,通過對渲染器采取了不同的實現,我們在不對代碼做任何的修改的前提下,就可以對同一個以AoT方式編譯的組件做不同的渲染。比如,上述代碼中的組件還可以直接用在NativeScript中,這是由于這些不同的渲染器都能夠理解編譯后的組件。
編譯發(fā)生在什么時候?JiT 還是 AoT
Angular編譯器最cool的一點是它可以在頁面運行時(例如在用戶的瀏覽器內)啟動,也可以作為構建的一個步驟在頁面的編譯時啟動。這主要得益于Angular的可移植性:我們可以在任何的平臺的JS虛擬機上運行Angular,所以我們完全可以在瀏覽器和NodeJs中運行它。
JiT編譯模式的流程
一個典型的非AoT應用的開發(fā)流程大概是:
- 使用TypeScript開發(fā)Angular應用
- 使用
tsc來編譯這個應用的ts代碼 - 打包
- 壓縮
- 部署
一旦把app部署好了,并且用戶在瀏覽器中打開了這個app,下面這些事情會逐一進行:
- 瀏覽器下載js代碼
- Angular啟動
- Angular在瀏覽器中開始JiT編譯的過程,例如生成app中各個組件的js代碼
- 應用頁面得以渲染
AoT編譯模式的流程
相對的,使用AoT模式的應用的開發(fā)流程是:
- 使用TypeScript開發(fā)Angular應用
- 使用
ngc來編譯應用- 使用Angular編譯器對模板進行編譯,生成TypeScript代碼
- TypesScript代碼編譯為JavaScript代碼
- 打包
- 壓縮
- 部署
雖然前面的過程稍稍復雜,但是用戶這一側的事情就變簡單了:
- 下載所以代碼
- Angular啟動
- 應用頁面得以渲染
如你所見,第三步被省略掉了,這意味著頁面打開更快,用戶體驗也更好。類似Angular-cli和Angular-seed這樣的工具可以讓整個編譯過程變的非常的自動化。
概括起來,Angular中的Jit和AoT的主要區(qū)別是:
- 編譯過程發(fā)生的時機
- JiT生成的是JS代碼,而AoT生成的是TS代碼。這主要是因為JiT是在瀏覽器中進行的,它完全沒必要生成TS代碼,而是直接生產了JS代碼。
你可以在我的Github賬號中找到一個最小的AoT編譯demo,鏈接在這里 https://github.com/mgechev/angular2-ngc-rollup-build
深入AoT編譯
這個小節(jié)回答了這些問題:
- AoT編譯過程產生了什么文件?
- 這些產生的文件的上下文是什么?
- 如何開發(fā)出AoT友好又有良好封裝的代碼?
對@angular/compiler的代碼一行一行的解釋沒太大意義,因此我們僅僅來快速過一下編譯的過程。如果你對編譯器的詞法分析過程,解析和生成代碼過程等感興趣,你可以讀一讀Tobias Bosch的《Angular2編譯器》一文,或者它的膠片。
譯者:
《Angular2編譯器》一文鏈接 https://www.youtube.com/watch?v=kW9cJsvcsGo
它的膠片鏈接 https://speakerdeck.com/mgechev/angular-toolset-support?slide=69
Angular模板編譯器收到一個組件和它的上下文(可以這認為是組件在組件樹上的位置)作為輸入,并產生了如下文件:
-
*.ngfactory.ts我們在說明組件上下文的小節(jié)會仔細看看這些文件 -
*.css.shim.ts樣式作用范圍被隔離后的css文件,根據組件所設置的ViewEncapsulation模式不同而會有不同 -
*.metadata.json當前組件/模塊的裝飾器元數據信息,這些數據可以被想象成以json格式傳遞給@Component@NgModule裝飾器的信息。
* 是一個文件名占位符,例如對于hero.component.ts這樣的組件,編譯器生成的文件是 hero.component.ngfactory.ts, hero.component.css.shim.ts 和 hero.component.metadata.json。*.css.shim.ts和我們討論的主題關系不大,因此不會對它詳細描述。如果你希望多了解 *.metadata.json 文件,你可以看看“AoT和第三方模塊”小節(jié)。
*.ngfactory.ts 的內部結構
它包含了如下的定義:
-
_View_{COMPONENT}_Host{COUNTER}我們稱之為internal host component -
_View_{COMPONENT}{COUNTER}我們稱之為 internal component
以及下面兩個函數
viewFactory_{COMPONENT}_Host{COUNTER}viewFactory_{COMPONENT}{COUNTER}
其中的 {COMPONENT} 是組件的控制器名字,而 {COUNTER} 是一個無符號整數。他們都繼承了 AppView,并且實現了下面的方法:
-
createInternal組件的渲染器 -
destroyInternal執(zhí)行事件監(jiān)聽器等的清理 -
detectChangesInternal以內聯緩存優(yōu)化后的邏輯執(zhí)行變化檢測
上述這些工廠函數只在生成的AppView實例中才存在。
我前面說過,detectChangesInternal中的代碼是JS虛擬機友好的。
<div>{{newName}}</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,我們來跟蹤看看這個方法的執(zhí)行。對于這樣的一個調用 import4.checkBinding(1, 3),在生產環(huán)境下,checkBinding 執(zhí)行的是下面的檢查:
1 === 3 || typeof 1 === 'number' && typeof 3 === 'number' && isNaN(1) && isNaN(3);
上述表達式返回false,因此我們將把變化保持下來,以及直接更新 NgModel 的屬性 model 的值,在這之后,detectContentChildrenChanges 方法會被調用,它將為整個模板內容的子級調用 detectChangesInternal。一旦 NgModel 指令發(fā)現了 model 屬性發(fā)生了變化,它就會(幾乎)直接調用渲染器來更新對應的DOM元素。
目前為止,我們還沒有碰到任何特殊的,或者特別復雜的邏輯。
context 屬性
也許你已經注意到了在internal component內部訪問了 this.context 屬性。
譯者:internal component 指的前一小節(jié)的
_View_{COMPONENT}{COUNTER}函數
internal component中的 context 是這個組件的控制器的實例,例如這樣的一個組件:
@Component({
selector: 'hero-app',
template: '<h1>{{ hero.name }}</h1>'
})
class HeroComponent {
hero: Hero;
}
this.context 就是 new HeroComponent(),這意味著如果在 detectChangesInternal 中我們需要訪問 this.context.name 的話,就帶來了一個問題:如果我們使用AoT模式編譯組件的模板,由于這個模式會生成TypeScript代碼,因此我們要確保在組件的模板中只訪問 this.context 中的public成員。這是為何?由于TypeScript的類屬性有訪問控制,強制類外部只能訪問類(及其父類)中的public成員,因此在internal component內部我們無法訪問 this.context 的任何私有成員。因此,下面這個組件:
@Component({
selector: 'hero-app',
template: '<h1>{{ hero.name }}</h1>'
})
class HeroComponent {
private hero: Hero;
}
以及這個組件
class Hero {
private name: string;
}
@Component({
selector: 'hero-app',
template: '<h1>{{ hero.name }}</h1>'
})
class HeroComponent {
hero: Hero;
}
在生成出來的 *.ngfactory.ts 中,都會拋出編譯錯誤。第一個組件代碼,internal component無法訪問到在 HeroComponent 類中被聲明為 private 的 hero 屬性。第二個組件代碼中,internal component無法訪問到 hero.name 屬性,因為它在 Hero 類中被聲明為private。
AoT與封裝
好吧,我們只能在組件模板中綁定public屬性,以及調用public方法。但是,如何確保組件的封裝性?在開始的時候,這可能不是一個大問題,但是想象一下下面這個場景:
// component.ts
@Component({
selector: 'third-party',
template: `
{{ _initials }}
`
})
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;
}
}
}
這個組件有一個屬性 name,它只能寫入而無法讀取。在 name 屬性的 setter 方法中,計算了 _initials 屬性的值。
我們可以用類似下面的方式使用這個組件:
@Component({
template: '<third-party [name]="name"></third-party>'
// ...
})
// ...
在JiT編譯模式下,一切正常,因為JiT模式只生成JavaScript代碼。每次 name 屬性的值發(fā)生變化,_initials 就會被重新計算。但是,這個組件卻不是AoT友好的,必須改為:
// component.ts
@Component({
selector: 'third-party',
template: `
{{ initials }}
`
})
class ThirdPartyComponent {
initials: string;
private _name: string;
@Input()
set name(name: string) {...}
}
codelyzer 這個工具可以確保你在模板中每次都能訪問到public成員。
這讓組件的使用者可以這樣做:
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.';
}
}
對public屬性 initials 的直接修改導致了組件處于不一致性的狀態(tài):組件的 _name 的值是 Foo Bar,但是它的 initials 的值是 M. D.,而非 F. B.。
在Angular的源碼中,我們可以找到解決的辦法,使用TypeScript的 /** @internal */ 注釋聲明,就能夠達到既保證組件代碼對AoT友好,又能夠確保組件的封裝良好的目的。
// component.ts
@Component({
selector: 'third-party',
template: `
{{ initials }}
`
})
class ThirdPartyComponent {
/** @internal */
initials: string;
private _name: string;
@Input()
set name(name: string) {...}
}
initials 屬性仍然是public的。我們在使用 tsc 編譯這個組件時,設置 --stripInternal 和 --declarations參數,initials 屬性就會從組件的類型定義文件(即 .d.ts 文件)中被刪掉。這樣我們就可以做到在我們的類庫內部使用它,但是我們的組件使用者無法使用它。
ngfactory.ts 概要
我們來對幕后所發(fā)生的一切做一些概要描述。拿我們前面的例子中的 HeroComponent 為例,Angular編譯器會生成兩個類:
-
_View_HeroComponent_Host1這是 internal host component -
_View_HeroComponent1這是 internal component
_View_HeroComponent1 負責渲染這個組件的模板,以及進行變化檢測。在執(zhí)行變化檢測時,它會對 this.context.hero.name之前保存的值和當前值做比較,一旦發(fā)現這兩個值不一致,<h1/>元素就會被更新,這意味著我們必須保持 this.context.hero 和 hero.name 是public的。這一點可以通過 codelyzer 這個工具來輔助確保。
另外,_View_HeroComponent_Host1 則負責 <hero-app></hero-app> 和 _View_HeroComponent1 本身的渲染。
這個例子可以以下面這個圖來總結:
AoT vs JiT 開發(fā)體驗
這個小結,我們來討論使用AoT開發(fā)和JiT開發(fā)的另一種體驗。
可能使用JiT對開發(fā)體驗的沖擊最大的就是JiT模式為internal component和internal host component生成的是JavaScript代碼,這意味著組件的控制器中的屬性都是public的,因此我們不會得到任何編譯錯誤。
在JiT模式下,一旦我們啟動了應用,根組件的根注入器和所有的指令就已經準備就緒了(他們被包含在 BrowserModule 和其他所有我們在根模塊中引入的模塊中了)。元數據信息會傳遞給編譯器,用于對根組件的模板的編譯。一旦編譯器生成了JiT下的代碼,編譯器就擁有了用于生成各個子組件的所有元數據信息。由于編譯器此時不僅知道了當前層級的組件有那些provider可用,還可以知道那些指令是可見的,因此它可以給所有的組件生成代碼。
這一點讓編譯器在訪問了模板中的一個元素時,知道該怎么工作。根據是否有選擇器是 bar-baz 的指令/組件,<bar-baz></bar-baz> 這樣的一個元素就有了兩種不同的解釋。編譯器在創(chuàng)建了 <bar-baz></bar-baz> 這樣的一個元素的同時,是否還同時初始化 bar-baz 對應的組件類的實例,則完全取決于當前階段的編譯過程的元數據信息。
這里有一個問題,在編譯階段,我們如何知道指令在整個組件樹上是否可訪問?得益于Angular框架的良好設計,我們通過靜態(tài)代碼分析就可以做到。Chuck Jazdzewski 和 Alex Eagle 在這個方向上做出了令人驚嘆的成果,他們實現了 MetadataCollector 和相關的模塊。MetadataCollector 所做的事情就是通過遍歷組件樹來獲取每個組件和NgModule的元數據信息,這個過程中,很多牛逼的技術被用到,可惜這些技術超出了本文的范疇。
AoT與第三方模塊
為了編譯組件的模板,編譯器需要組件的元數據信息,我們來假設我們的應用使用到了一些第三方組件,Angular的AoT編譯器是如何獲取這些已經被編譯成JavaScript代碼的組件的元數據信息的?這些組件庫必須連帶發(fā)布對應的 *.metadata.json 文件,這樣才能夠對一個引用了它的頁面進行AoT編譯。
如果你想了解如何使用Angular編譯器,例如如何編譯你自定義庫使得他們能夠被用于以AoT編譯的應用,那請訪問這個鏈接 https://github.com/angular/mobile-toolkit/blob/master/app-shell/gulpfile.ts#L52-L54
我們從AoT中獲得了什么?
你可能已經想到了,AoT給我們帶來了性能的提升。以AoT方式開發(fā)的Angular應用的初次渲染性能要比JiT的高的多,這是由于JS虛擬機需要的計算量大大減少了。我們只在開發(fā)過沖中,將組件的模板編譯成js代碼,在此之后,用戶不需要等待再次編譯。
下面這個圖中,可以看出JiT渲染方式在初始化過程中所消耗的時間:
下面這個圖你可以看出AoT方式初始化在初始化過程中所消耗的時間:
Angular編譯器不僅能夠生產JavaScript,還能夠生成TypeScript,這一點還帶給我們要給非常棒的特性:在模板中進行類型檢查。
由于應用的模板是純JavaScript/TypeScript,我們可以精確的知道哪些東西在哪被用到了,這一點讓我們可以對代碼進行有效的搖樹操作,它能夠把所有的未使用過的指令/模塊從我們生產環(huán)境中的應用代碼包中給刪除掉。這首要的一點是,我們的應用程序代碼包中,再也無需包含 @angular/compiler 這個模塊,因為我們在應用的運行時根本就用不到它。
有一點需要注意的是,一個中大型的應用代碼包,在進行AoT編譯過之后,可能會比使用JiT方式編譯的代碼包要大一些。這是因為 ngc 生成的對JS虛擬機友好的代碼比基于HTML模板的代碼要冗長一些,并且這些代碼還包含了臟檢查邏輯。如果你想降低你的應用代碼的尺寸,你可以通過懶加載的方式來實現,Angular內建的路由已經支持這一點了。
在某些場合,JiT模式的編譯根本就無法進行,這是由于JiT在瀏覽器中,不僅生成代碼,它還使用 eval 來運行它們,瀏覽器的內容安全策略以及特定的環(huán)境不允許這些被生成的代碼被動態(tài)的運行。
最后一個但不是唯一的:節(jié)能!在接受到的是編譯后的代碼時,用戶的設備可以花更少的時間運行他們,這節(jié)約了電池的電力。節(jié)能的量有多少呢?下面是我做的一些有趣的計算的結果:
基于《Who Killed My Battery: Analyzing Mobile Browser Energy Consumption》這偏文章的結論,訪問Wikipedia時,下載和解析jQuery的過程大約需要消耗4焦耳的能量。這個文章沒有提及所使用的jQuery的確切版本,基于文章發(fā)表的日期,我估計版本號是1.8.x。Wikipedia采用了gzip對靜態(tài)資源做壓縮,這意味著jQuery1.8.3的尺寸約33k。而被最小化并且gzip壓縮后的 @angular/compiler 包的尺寸在103k,這意味著對這些代碼的下載和解析需要消耗12.5焦的能量(我們可以忽略JiT的運算還會增加能耗的事實,這是因為jQuery和@angular/compiler這兩個場景,都是使用了要給單一的TCP鏈接,這從是最大的能耗所在)。
iPhone6s的電池容量是6.9Wh,即24840焦。基于AngularJs1.x官網的月訪問量可以得知現在大約有一百萬名Angular開發(fā)者,平均每位開發(fā)者構建了5個應用,每個應用每天約100名用戶。5個app * 1m * 100用戶 = 500m,在使用JiT編譯這些應用,他們就需要下載 @angular/compiler 包,這將給地球帶來 500m * 12.5J = 6250000000J焦(=1736.111111111千瓦時)的能量消耗。根據Google搜索結果,1千瓦時約等于12美分,這意味著我們每天需要消耗約 210 美元。注意到我們還沒進一步對代碼做搖樹操作,這可能會讓我們的應用程序代碼降低至少一半!
結論
Angular的編譯器利用了JS虛擬機的內聯緩存機制,極大的提升了我們的應用程序的性能。首要的一點是我們把編譯過程作為構建應用的一個環(huán)節(jié),這不僅解決了非法eval的問題,還允許我們對代碼做高效的搖樹,降低了首次渲染的時間。
不在運行時編譯是否讓我們失去是什么嗎?在一些非常極端的場景下,我們會按需生成組件的模板,這就需要我們價值一個未編譯的組件,并在瀏覽器中執(zhí)行編譯的過程,在這樣的場景下,我們就需要在我們的應用代碼包中包含 @angular/compiler 模塊。AoT編譯的另一個潛在缺點是,它會造成中大型應用的代碼包尺寸變大。由于生成的組件模板的JavaScript代碼比組件模板本身的尺寸更大,這就可能造成最終代碼包的尺寸更大一些。
總的來說,AoT編譯是一個很好的技術,現在它已經被集成到了Angular-seed和angular-cli中,所以,你今天就可以去使用它了。
參考資料
- 內聯緩存 http://mrale.ph/blog/2012/06/03/explaining-js-vms-in-js-inline-caches.html
- 2.5X Smaller Angular 2 Applications with Google Closure Compiler http://blog.mgechev.com/2016/07/21/even-smaller-angular2-applications-closure-tree-shaking/
- Who Killed My Battery: Analyzing Mobile Browser Energy Consumption https://crypto.stanford.edu/~dabo/pubs/abstracts/browserpower.html
- Angular的源碼 https://github.com/angular/angular
題外話
這些文章都是我們在研發(fā)Jigsaw七巧板過程中的技術總結,如果你喜歡這個文章,請幫忙到 Jigsaw七巧板的工程上點個星星鼓勵我們一下(點擊閱讀原文看直達 https://github.com/rdkmaster/jigsaw),這樣我們會更有動力寫出類似高質量的文章。Jigsaw七巧板現在處于起步階段,非常需要各位的呵護。