遺世獨立的組件——Angular應用中的單組件構建

本文內容提取自 《2017成都WEB前端交流大會》 中的主題演講。

Angular 是一款面向構建工具友好的框架,除了部分特殊場景之外,所有實際應用中都需要將應用構建後再部署發布。大部分時候,我們都會將應用作為整體進行構建,不過,有些時候我們需要單獨構建應用的一部分,而不影響應用主體。

例如,在下面這個例子中,我們只需要屬於組件的 URL,就能將其引入到應用中並直接工作。

動態載入組件代碼

Distribution

如果我們直接查看上面用到的組件 JavaScript 文件,例如:

  • trotyl.github.io/ng-sta
  • trotyl.github.io/ng-sta

這是因為組件代碼已經經過完整的構建,包括 Angular Compiler 的 AOT 編譯,Build Optimizer 的優化和 UglifyJS 的 Minification,從而能夠確保載入的大小和執行的速度。

不過只要仔細觀察文件引導部分,很容易發現這是一個 UMD 格式的內容:

function(n,l){"object"==typeof exports&&"undefined"!=typeof module?module.exports=l(require("@angular/core"),require("@angular/forms")):"function"==typeof define&&define.amd?define("ngDemos.temperature",["@angular/core","@angular/forms"],l):(n.ngDemos=n.ngDemos||{},n.ngDemos.temperature=l(n.ng.core,n.ng.forms))n

並且依賴了 @angular/core@angular/forms 這兩個 Angular Packages。

Infrastructure

為了能夠與動態組件共用依賴,因此應用的主體部分(基礎設施)必須將 Angular 的內容原封不動的暴露出來。藉助於 UMD 格式很容易實現這一點,因為即便在不使用任何模塊載入工具的情況下,也能夠通過 Fallback 到全局變數來相互通信。而這裡我們就是通過 Global Fallback 進行的。

Angular 自身的發布內容就提供了 UMD Bundles,其中約定了模塊到全局變數的映射關係,例如:

  • @angular/core -> ng.core
  • @angular/common -> ng.common
  • ...

所以為了能夠保持引用關係,我們需要使用相同的映射[1]。以 Rollup 為例:

const globals = {n @angular/animations: ng.animations,n @angular/core: ng.core,n @angular/common: ng.common,n @angular/compiler: ng.compiler,n @angular/forms: ng.forms,n @angular/platform-browser: ng.platformBrowser,n @angular/platform-browser/animations: ng.platformBrowser.animations,n rxjs/Observable: Rx,n rxjs/Subject: Rx,n rxjs/observable/fromPromise: Rx.Observable,n rxjs/observable/forkJoin: Rx.Observable,n rxjs/operator/map: Rx.Observable.prototypen}nnmodule.exports = {n format: umd,n exports: named,n external: Object.keys(globals),n globals: globalsn}n

只要依賴自身、應用主體和動態組件都使用同一個映射表,依賴關係即便在打包後也不會受影響。

1. 如果不使用 Global Fallback,例如在運行時配置 RequireJs 或者 SystemJS 等模塊管理器,就可以不需要映射直接基於名稱管理依賴。

NgFactory

在 v2-v5 版本中[2],Angular 的編譯策略是產生額外的 JavaScript 文件[3],包含組件模版信息,詳情可以參考《空間換時間——Angular中的View Engine(待寫)》。

例如,假設我們有一個模版為 <p>Hello World!</p>AppComponent 的組件,則編譯後產生的 .ngfactory.js 為:

import * as i0 from "./app.component.css.shim.ngstyle"nimport * as i1 from "@angular/core"nimport * as i2 from "./app.component"nconst styles_AppComponent = [i0.styles]nconst RenderType_AppComponent = i1.?crt({ encapsulation: 0, styles: styles_AppComponent, data: {} })nexport { RenderType_AppComponent as RenderType_AppComponent }nexport function View_AppComponent_0(_l) { return i1.?vid(0, [(_l()(), i1.?eld(0, 0, null, null, 1, "p", [], null, null, null, null, null)), (_l()(), i1.?ted(-1, null, ["Hello World!"])), (_l()(), i1.?ted(-1, null, ["n"]))], null, null) }nexport function View_AppComponent_Host_0(_l) { return i1.?vid(0, [(_l()(), i1.?eld(0, 0, null, null, 1, "app-root", [], null, null, null, View_AppComponent_0, RenderType_AppComponent)), i1.?did(1, 49152, null, 0, i2.AppComponent, [], null, null)], null, null) }nconst AppComponentNgFactory = i1.?ccf("app-root", i2.AppComponent, View_AppComponent_Host_0, {}, {}, [])nexport { AppComponentNgFactory as AppComponentNgFactory }n

其中的 View_AppComponent_0 就是編譯後的模版,不過這裡我們並不需要關心它。而我們需要真正關心的,是與 API 直接相關的 AppComponentNgFactory,它是一個 ComponentFactory 的實例,在 Angular 的很多視圖操作中都會用到。

為了生成 NgFactory,需要用到 Angular Compiler。例如對於默認的 Angular CLI 項目,可以通過 yarn ngc -p src/tsconfig.app.json 或者 npx -p src/tsconfig.app.json,用法與 ngc 相同。

需要注意的一點是,雖然這裡載入的單位是 Component4,但是編譯的最小單位是 NgModule,所以必須把每個 Component 都放到 NgModule 里才能完成編譯,但並不需要處理 NgModule 編譯後的 NgFactory。

接著,僅需要把 Component 的 NgFactory 作為入口,打包成 UMD,即可做成一個獨立組件,進行單獨發布。

2. 不適用於 v6 及以上版本。

3. 在 v2-v4 版本中,AOT 編譯時會產生 .ts 中間文件,之後再生成相應的 .js 文件。

4. 實際項目中將 NgModule 作為基本載入單位可能會是更好的選擇,因為可以直接與 Angular Router 相集成。

Loading

雖然有了能獨立發布的組件,但是我們仍然需要代碼去載入它們。從實際工程的角度來說,使用一個成熟的模塊載入器,例如 SystemJS,是很好的解決方案。不過這裡為了突出本質內容,仍然選擇什麼都不用:

export class AppComponent {n @ViewChild(ComponentOutlet, { read: ViewContainerRef }) container: ViewContainerRefn n scriptHost = document.querySelector(#dynamic-script-host)nn load(url: string): void {n const segments = url.split(/)n const name = segments[segments.length - 1].replace(.js, )n const script = document.createElement(script)n script.src = urln script.type = text/javascriptn script.charset = utf-8n script.defer = truen script.onload = () => {n const cmpFactory = window.ngDemos[name]n this.container.createComponent(cmpFactory)n }n this.scriptHost.appendChild(script)n }nn clear(): void {n this.container.clear()n }n}n

這裡使用了 JSONP 類似的方式,通過動態創建 <script> 標籤來載入內容,並且基於約定來實現名稱映射。

而由於 UMD 文件的導出內容是 NgFactory,便可直接通過 ViewContainerRef API 來進行實例化。


綜上,我們可以在運行時來以組件為單位動態地載入內容,但是對於每一個獨立組件而言,應當滿足:

  • 具備業務邏輯(否則直接綁定 [innerHTML] 就好);
  • 更新頻率較高(例如活動頁面);
  • 不作為其它內容的依賴;

雖然這樣實現了更方便的動態特性,但也會因此帶來一些副作用,例如因為要暴露全部 API,所以構建過程中無法進行 Tree-Shaking,構建後的體積相比於統一構建而言會有所增加。

此外,由於編譯後的代碼會使用到 Private API,因此獨立發布的組件與基礎設施不能有太大的版本差異(例如一個 v4 另一個 v5 可能會出問題)。

完整的 Demo 可以參見 trotyl/ng-component-loader-demo: Demos app for dynamically loading standalone components 與 trotyl/ng-standalone-components-demo: Demo components used for generating standalone bundles。

本文地址:zhuanlan.zhihu.com/p/32


推薦閱讀:

ReactEurope 2016 小記 - 上
如何通俗易懂地解釋 Redux 和 Flux 的區別和實現意義?
vue-router源碼分析-整體流程

TAG:Angular? | 前端框架 |