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

TrotylYu發表於2017-12-20

本文內容提取自 《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 componentstrotyl/ng-standalone-components-demo: Demo components used for generating standalone bundles

本文地址:juejin.im/post/5a39ff…

相關文章