深入理解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<user> 的形式返回。
實際上非常困難,因為您已經使用了容器,您需要傳入此上下文對象。
它具有$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 中進行類型檢查。
這對於之前的類型檢查器來說是一個很大的盲點,現在可以說做的非常棒。
最後是致謝與結束詞。