【譯】AngularElements及其運作原理

NO IMAGE

原文: 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:

callbacksummary
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 連接起來,如圖所示:

【譯】AngularElements及其運作原理

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

callbacksummaryangular 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 輸出事件。

相關文章

高級Vue組件模式3

高級vue組件模式2

高級vue組件模式1

30分鐘理解CORB是什麼