本文內容提取自 《2017成都WEB前端交流大會》 中的主題演講。
Angular 是一款面向構建工具友好的框架,除了部分特殊場景之外,所有實際應用中都需要將應用構建後再部署釋出。大部分時候,我們都會將應用作為整體進行構建,不過,有些時候我們需要單獨構建應用的一部分,而不影響應用主體。
例如,在下面這個例子中,我們只需要屬於元件的 URL,就能將其引入到應用中並直接工作。
Distribution
如果我們直接檢視上面用到的元件 JavaScript 檔案,例如:
這是因為元件程式碼已經經過完整的構建,包括 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))
複製程式碼
並且依賴了 @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 = {
'@angular/animations': 'ng.animations',
'@angular/core': 'ng.core',
'@angular/common': 'ng.common',
'@angular/compiler': 'ng.compiler',
'@angular/forms': 'ng.forms',
'@angular/platform-browser': 'ng.platformBrowser',
'@angular/platform-browser/animations': 'ng.platformBrowser.animations',
'rxjs/Observable': 'Rx',
'rxjs/Subject': 'Rx',
'rxjs/observable/fromPromise': 'Rx.Observable',
'rxjs/observable/forkJoin': 'Rx.Observable',
'rxjs/operator/map': 'Rx.Observable.prototype'
}
module.exports = {
format: 'umd',
exports: 'named',
external: Object.keys(globals),
globals: globals
}
複製程式碼
只要依賴自身、應用主體和動態元件都使用同一個對映表,依賴關係即便在打包後也不會受影響。
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"
import * as i1 from "@angular/core"
import * as i2 from "./app.component"
const styles_AppComponent = [i0.styles]
const RenderType_AppComponent = i1.ɵcrt({ encapsulation: 0, styles: styles_AppComponent, data: {} })
export { RenderType_AppComponent as RenderType_AppComponent }
export 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) }
export 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) }
const AppComponentNgFactory = i1.ɵccf("app-root", i2.AppComponent, View_AppComponent_Host_0, {}, {}, [])
export { AppComponentNgFactory as AppComponentNgFactory }
複製程式碼
其中的 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 {
@ViewChild(ComponentOutlet, { read: ViewContainerRef }) container: ViewContainerRef
scriptHost = document.querySelector('#dynamic-script-host')
load(url: string): void {
const segments = url.split('/')
const name = segments[segments.length - 1].replace('.js', '')
const script = document.createElement('script')
script.src = url
script.type = 'text/javascript'
script.charset = 'utf-8'
script.defer = true
script.onload = () => {
const cmpFactory = window.ngDemos[name]
this.container.createComponent(cmpFactory)
}
this.scriptHost.appendChild(script)
}
clear(): void {
this.container.clear()
}
}
複製程式碼
這裡使用了 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。