【譯】Angular Elements 及其運作原理

HaoliangWu發表於2018-07-16

原文: Angular Elements: how does this magic work under the hood?

現在,Angular Elements 這個專案已經在社群引起一定程度的討論。這是顯而易見的,因為 Angular Elements 提供了很多開箱即用的、十分強大的功能:

  • 通過使用原生的 HTML 語法來使用 Angular Elements —— 這意味著不再需要了解 Angular 的相關知識
  • 它是自啟動的,並且一切都可以按預期那樣運作
  • 它符合 Web Components 規範,這意味著它可以在任何地方使用
  • 雖然你沒有使用 Angular 開發整個網站,但你仍然可以從 Angular Framework 這個龐大的體系中收益

@angular/elements這個包提供可將 Angular 元件轉化為原生 Web Components 的功能,它基於瀏覽器的 Custom Elements API 實現。Angular Elements 提供一種更簡潔、對開發者更友善、更快樂地開發動態元件的方式 —— 在幕後它基於同樣的機制(指建立動態元件),但隱藏了許多樣板程式碼。

關於如何通過 @angular/elements 建立一個 Custom Element,已經有大量的文章進行闡述,所以在這篇文章將深入一點,對它在 Angular 中的具體工作原理進行剖析。這也是我們開始研究 Angular Elements 的一系列文章的原因,我們將在其中詳細解釋 Angular 如何在 Angular Elements 的幫助下實現 Custom Elements API。

Custom Elements(自定義元素)

要了解更多關於 Custom Elements 的知識,可以通過 developers.google 中的這篇文章進行學習,文章詳細介紹了與 Custom Elements API 相關的內容。

這裡針對 Custom Elements,我們使用一句話來概括:

使用 Custom Elements,web 開發者可以建立一個新的 HTML 標籤、增加已有的 HTML 標籤以及繼承其他開發者所開發的元件。

原生 Custom Elements

讓我們來看看下面的例子,我們想要建立一個擁有 name 屬性的 app-hello HTML 標籤。可以通過 Custom Elements API 來完成這件事。在文章的後續章節,我們將演示如何使用 Angular 元件的 @Input 裝飾器與 這個 name 屬性保持同步。但是現在,我們不需要使用 Angular Elements 或者 ShadowDom 或者使用任何關於 Angular 的東西來建立一個 Custom Element,我們僅使用原生的 Custom Components API。

首先,這是我們的 HTML 標記:

<hello-elem name="Custom Elements"></hello-elem>
複製程式碼

要實現一個 Custom Element,我們需要分別實現如下在標準中定義的 hooks:

callback summary
constructor 如果需要的話,可在其中初始化 state 或者 shadowRoot,在這篇文章中,我們不需要
connectedCallback 在元素被新增到 DOM 中時會被呼叫,我們將在這個 hook 中初始化我們的 DOM 結構和事件監聽器
disconnectedCallback 在元素從 DOM 中被移除時被呼叫,我們將在這個 hook 中清除我們的 DOM 結構和事件監聽器
attributeChangedCallback 在元素屬性變化時被呼叫,我們將在這個 hook 中更新我們內部的 dom 元素或者基於屬性改變後的狀態

如下是我們關於 Hello Custom Element 的實現程式碼:

class AppHello extends HTMLElement {
  constructor() {
    super();
  }
  // 這裡定義了那些需要被觀察的屬性,當這些屬性改變時,attributeChangedCallback 這個 hook 會被觸發
  static get observedAttributes() {return ['name']; }

  // getter to do a attribute -> property reflection
  get name() {
    return this.getAttribute('name');
  }

  // setter to do a property -> attribute reflection
  // 通過 setter 來完成類屬性到元素屬性的對映操作
  set name(val) {
    this.setAttribute('name', val);
  }

  connectedCallback() {
    this.div = document.createElement('div');
    this.text = document.createTextNode(this.name || '');
    this.div.appendChild(this.text);
    this.appendChild(this.div);
  }

  disconnectedCallback() {
    this.removeChild(this.div);
  }

  attributeChangedCallback(attrName, oldVal, newVal) {
    if (attrName === 'name' && this.text) {
      this.text.textContent = newVal;
    }
  }
}

customElements.define('hello-elem', AppHello);
複製程式碼

這裡是可執行例項的連結。這樣我們就實現了第一版的 Custom Element,回顧一下,這個 app-hellp 標籤包含一個文字節點,並且這個節點將會渲染通過 app-hello 標籤 name 屬性傳遞進來的任何內容,這一切僅僅基於原生 javascript。

將 Angular 元件匯出為 Custom Element

既然我們已經瞭解了關於實現一個 HTML Custom Element 所涉及的內容,讓我們來使用 Angular實現一個相同功能的元件,之後再使它成為一個可用的 Custom Element。

首先,讓我們從一個簡單的 Angular 元件開始:

import { Component, Input } from '@angular/core';

@Component({
  selector: 'app-hello',
  template: `<div>{{name}}</div>`
})
export class HelloComponent  {
  @Input() name: string;
}
複製程式碼

正如你所見,它和上面的例子在功能上一模一樣。

現在,要將這個元件包裝為一個 Custom Element,我們需要建立一個 wrapper class 並實現所有 Custom Elements 中定義的 hooks:

class HelloComponentClass extends HTMLElement {
  constructor() {
    super();
  }

  static get observedAttributes() {
  }

  connectedCallback() {
  }

  disconnectedCallback() {
  }

  attributeChangedCallback(attrName, oldVal, newVal) {
  }
}
複製程式碼

下一步,我們要做的是橋接 HelloComponentHelloComponentClass。它們之間的橋會將 Angular Component 和 Custom Element 連線起來,如圖所示:

【譯】Angular Elements 及其運作原理

要完成這座橋,讓我們來依次實現 Custom Elements API 中所要求的每個方法,並在這個方法中編寫關於繫結 Angular 的程式碼:

callback summary angular part
constructor 初始化內部狀態 進行一些準備工作
connectedCallback 初始化檢視、事件監聽器 載入 Angular 元件
disconnectedCallback 清除檢視、事件監聽器 登出 Angular 元件
attributeChangedCallback 處理屬性變化 處理 @Input 變化

1. constructor()

我們需要在 connectedCallback() 方法中初始化 HelloComponent,但是在這之前,我們需要在 constructor 方法中進行一些準備工作。

順便,關於如何動態構造 Angular 元件可以通過閱讀Dynamic Components in Angular這篇文章進行了解。它其中闡述的運作機制和我們這裡使用的一模一樣。

所以,要讓我們的 Angular 動態元件能夠正常工作(需要 componentFactory 能夠被編譯),我們需要將 HelloComponent 新增到 NgModuleentryComponents 屬性(它是一個列表)中去:

@NgModule({
  imports: [
    BrowserModule
  ],
  declarations: [HelloComponent],
  entryComponents: [HelloComponent]
})
export class CustomElementsModule {
  ngDoBootstrap() {}
}
複製程式碼

基本上,呼叫 prepare() 方法會完成兩件事:

  • 它會基於元件的定義初始化一個 factoryComponent 工廠方法
  • 它會基於 Angular 元件的 inputs 初始化 observedAttributes,以便我們在 attributeChangedCallback() 中完成我們需要做的事
class AngularCustomElementBridge {
  prepare(injector, component) {
    this.componentFactory = injector.get(ComponentFactoryResolver).resolveComponentFactory(component);

    // 我們使用 templateName 來處理 @Input('aliasName') 這種情形
    this.observedAttributes = componentFactory.inputs.map(input => input.templateName); 
  }
}
複製程式碼

2. connectedCallback()

在這個回撥函式中,我們將看到:

  • 初始化我們的 Angular 元件(就如建立動態元件那樣)
  • 設定元件的初始 input 值
  • 在渲染元件時,觸發髒檢查機制
  • 最後,將 HostView 增加到 ApplicationRef

如下是實戰程式碼:

class AngularCustomElementBridge {
  initComponent(element: HTMLElement) {
    // 首先我們需要 componentInjector 來初始化元件
    // 這裡的 injector 是 Custom Element 外部的注入器例項,呼叫者可以在這個例項中註冊
    // 他們自己的 providers
    const componentInjector = Injector.create([], this.injector);
  
    this.componentRef = this.componentFactory.create(componentInjector, null, element);

    // 然後我們要檢查是否需要初始化元件的 input 的值
    // 在本例中,在 Angular Element 被載入之前,user 可能已經設定了元素的屬性
    // 這些值被儲存在 initialInputValues 這個 map 結構中
    this.componentFactory.inputs.forEach(prop => this.componentRef.instance[prop.propName] = this.initialInputValues[prop.propName]);

    // 之後我們會觸發髒檢查,這樣元件在事件迴圈的下一個週期會被渲染
    this.changeDetectorRef.detectChanges();
    this.applicationRef = this.injector.get(ApplicationRef);

    // 最後,我們使用 attachView 方法將元件的 HostView 新增到 applicationRef 中
    this.applicationRef.attachView(this.componentRef.hostView);
  }
}
複製程式碼

3. disconnectedCallback()

這個十分容易,我們僅需要在其中登出 componentRef 即可:

class AngularCustomElementBridge {
  destroy() {
    this.componentRef.destroy();
  }
}
複製程式碼

4. attributeChangedCallback()

當元素屬性發生改變時,我們需要相應地更新 Angular 元件並觸發髒檢查:

class AngularCustomElementBridge {
  setInputValue(propName, value) {
    if (!this.componentRef) {
      this.initialInputValues[propName] = value;
      return;
    }
    if (this.componentRef[propName] === value) {
      return;
    }
    this.componentRef[propName] = value;
    this.changeDetectorRef.detectChanges();
  }
}
複製程式碼

5. Finally, we register the Custom Element

customElements.define('hello-elem', HelloComponentClass);
複製程式碼

這是一個可執行的例子連結

總結

這就是根本思想。通過在 Angular 中使用動態元件,我們簡單實現了 Angular Elements 所提供的基礎功能,重要的是,沒有使用 @angular/element 這個庫。

當然,不要誤解 —— Angular Elements 的功能十分強大。文章中所涉及的所有實現邏輯在 Angular Elements 都已被抽象化,使用這個庫可以使我們的程式碼更優雅,可讀性和維護性也更好,同時也更易於擴充套件。

以下是關於 Angular Elements 中一些模組的概要以及它們與這篇文章的關聯性:

  • create-custom-element.ts:這個模組實現了我們在這篇文章中討論的關於 Custom Element 的幾個回撥函式,同時它還會初始化一個 NgElementStrategy 策略類,這個類會作為連線 Angular Component 和 Custom Elements 的橋樑。當前,我們僅有一個策略 —— component-factory-strategy.ts —— 它的運作機制與本文例子中演示的大同小異。在將來,我們可能會有其他策略,並且我們還可以實現自定義策略。
  • component-factory-strategy.ts:這個模組使用一個 component 工廠函式來建立和銷燬元件引用。同時它還會在 input 改變時觸發髒檢查。這個運作過程在上文的例子中也有被提及。

下次我們將闡述 Angular Elements 通過 Custom Events 輸出事件。

相關文章