Angular9 元素

2020-07-01 15:13 更新

概覽

Angular 元素就是打包成自定義元素的 Angular 組件。所謂自定義元素就是一套與具體框架無關(guān)的用于定義新 HTML 元素的 Web 標(biāo)準(zhǔn)。

自定義元素這項特性目前受到了 Chrome、Edge(基于 Chromium 的版本)、Opera 和 Safari 的支持,在其它瀏覽器中也能通過膩子腳本(參見瀏覽器支持)加以支持。 自定義元素擴展了 HTML,它允許你定義一個由 JavaScript 代碼創(chuàng)建和控制的標(biāo)簽。 瀏覽器會維護一個自定義元素的注冊表 CustomElementRegistry,它把一個可實例化的 JavaScript 類映射到 HTML 標(biāo)簽上。

@angular/elements 包導(dǎo)出了一個 createCustomElement() API,它在 Angular 組件接口與變更檢測功能和內(nèi)置 DOM API 之間建立了一個橋梁。

把組件轉(zhuǎn)換成自定義元素可以讓所有所需的 Angular 基礎(chǔ)設(shè)施都在瀏覽器中可用。 創(chuàng)建自定義元素的方式簡單直觀,它會自動把你組件定義的視圖連同變更檢測與數(shù)據(jù)綁定等 Angular 的功能映射為相應(yīng)的原生 HTML 等價物。

使用自定義元素

自定義元素會自舉 —— 它們在添加到 DOM 中時就會自行啟動自己,并在從 DOM 中移除時自行銷毀自己。一旦自定義元素添加到了任何頁面的 DOM 中,它的外觀和行為就和其它的 HTML 元素一樣了,不需要對 Angular 的術(shù)語或使用約定有任何特殊的了解。

  • Angular 應(yīng)用中的簡易動態(tài)內(nèi)容

把組件轉(zhuǎn)換成自定義元素為你在 Angular 應(yīng)用中創(chuàng)建動態(tài) HTML 內(nèi)容提供了一種簡單的方式。 在 Angular 應(yīng)用中,你直接添加到 DOM 中的 HTML 內(nèi)容是不會經(jīng)過 Angular 處理的,除非你使用動態(tài)組件來借助自己的代碼把 HTML 標(biāo)簽與你的應(yīng)用數(shù)據(jù)關(guān)聯(lián)起來并參與變更檢測。而使用自定義組件,所有這些裝配工作都是自動的。

  • 富內(nèi)容應(yīng)用

如果你有一個富內(nèi)容應(yīng)用(比如正在展示本文檔的這個),自定義元素能讓你的內(nèi)容提供者使用復(fù)雜的 Angular 功能,而不要求他了解 Angular 的知識。比如,像本文檔這樣的 Angular 指南是使用 Angular 導(dǎo)航工具直接添加到 DOM 中的,但是其中可以包含特殊的元素,比如 <code-snippet>,它可以執(zhí)行復(fù)雜的操作。 你所要告訴你的內(nèi)容提供者的一切,就是這個自定義元素的語法。他們不需要了解關(guān)于 Angular 的任何知識,也不需要了解你的組件的數(shù)據(jù)結(jié)構(gòu)或?qū)崿F(xiàn)。

工作原理

使用 createCustomElement() 函數(shù)來把組件轉(zhuǎn)換成一個可注冊成瀏覽器中自定義元素的類。 注冊完這個配置好的類之后,你就可以在內(nèi)容中像內(nèi)置 HTML 元素一樣使用這個新元素了,比如直接把它加到 DOM 中:

<my-popup message="Use Angular!"></my-popup>

當(dāng)你的自定義元素放進頁面中時,瀏覽器會創(chuàng)建一個已注冊類的實例。其內(nèi)容是由組件模板提供的,它使用 Angular 模板語法,并且使用組件和 DOM 數(shù)據(jù)進行渲染。組件的輸入屬性(Property)對應(yīng)于該元素的輸入屬性(Attribute)。

把組件轉(zhuǎn)換成自定義元素

Angular 提供了 createCustomElement() 函數(shù),以支持把 Angular 組件及其依賴轉(zhuǎn)換成自定義元素。該函數(shù)會收集該組件的 Observable 型屬性,提供瀏覽器創(chuàng)建和銷毀實例時所需的 Angular 功能,還會對變更進行檢測并做出響應(yīng)。

這個轉(zhuǎn)換過程實現(xiàn)了 NgElementConstructor 接口,并創(chuàng)建了一個構(gòu)造器類,用于生成該組件的一個自舉型實例。

然后用 JavaScript 的 customElements.define() 函數(shù)把這個配置好的構(gòu)造器和相關(guān)的自定義元素標(biāo)簽注冊到瀏覽器的 CustomElementRegistry 中。 當(dāng)瀏覽器遇到這個已注冊元素的標(biāo)簽時,就會使用該構(gòu)造器來創(chuàng)建一個自定義元素的實例。

映射

寄宿著 Angular 組件的自定義元素在組件中定義的"數(shù)據(jù)及邏輯"和標(biāo)準(zhǔn)的 DOM API 之間建立了一座橋梁。組件的屬性和邏輯會直接映射到 HTML 屬性和瀏覽器的事件系統(tǒng)中。

  • 用于創(chuàng)建的 API 會解析該組件,以查找輸入屬性(Property),并在這個自定義元素上定義相應(yīng)的屬性(Attribute)。 它把屬性名轉(zhuǎn)換成與自定義元素兼容的形式(自定義元素不區(qū)分大小寫),生成的屬性名會使用中線分隔的小寫形式。 比如,對于帶有 @Input('myInputProp') inputProp 的組件,其對應(yīng)的自定義元素會帶有一個 my-input-prop 屬性。

  • 組件的輸出屬性會用 HTML 自定義事件的形式進行分發(fā),自定義事件的名字就是這個輸出屬性的名字。 比如,對于帶有 @Output() valueChanged = new EventEmitter() 屬性的組件,其相應(yīng)的自定義元素將會分發(fā)名叫 "valueChanged" 的事件,事件中所攜帶的數(shù)據(jù)存儲在該事件對象的 detail 屬性中。 如果你提供了別名,就改用這個別名。比如,@Output('myClick') clicks = new EventEmitter<string>(); 會導(dǎo)致分發(fā)名為 "myClick" 事件。

自定義元素的瀏覽器支持

最近開發(fā)的 Web 平臺特性:自定義元素目前在一些瀏覽器中實現(xiàn)了原生支持,而其它瀏覽器或者尚未決定,或者已經(jīng)制訂了計劃。

瀏覽器 自定義元素支持
Chrome 原生支持。
Edge (基于 Chromium 的) 原生支持。
Firefox 原生支持。
Opera 原生支持。
Safari 原生支持。

對于原生支持了自定義元素的瀏覽器,該規(guī)范要求開發(fā)人員使用 ES2016 的類來定義自定義元素 —— 開發(fā)人員可以在項目的 TypeScript 配置文件中設(shè)置 target: "es2015" 屬性來滿足這一要求。并不是所有瀏覽器都支持自定義元素和 ES2015,開發(fā)人員也可以選擇使用膩子腳本來讓它支持老式瀏覽器和 ES5 的代碼。

使用 Angular CLI 可以自動為你的項目添加正確的膩子腳本:ng add @angular/elements --project=*your_project_name*。

范例:彈窗服務(wù)

以前,如果你要在運行期間把一個組件添加到應(yīng)用中,就不得不定義動態(tài)組件。你還要把動態(tài)組件添加到模塊的 entryComponents 列表中,以便應(yīng)用在啟動時能找到它,然后還要加載它、把它附加到 DOM 中的元素上,并且裝配所有的依賴、變更檢測和事件處理,詳見動態(tài)組件加載器。

用 Angular 自定義組件會讓這個過程更簡單、更透明。它會自動提供所有基礎(chǔ)設(shè)施和框架,而你要做的就是定義所需的各種事件處理邏輯。(如果你不準(zhǔn)備在應(yīng)用中直接用它,還要把該組件在編譯時排除出去。)

這個彈窗服務(wù)的范例應(yīng)用(見后面)定義了一個組件,你可以動態(tài)加載它也可以把它轉(zhuǎn)換成自定義組件。

  • "popup.component.ts" 定義了一個簡單的彈窗元素,用于顯示一條輸入消息,附帶一些動畫和樣式。

  • "popup.service.ts" 創(chuàng)建了一個可注入的服務(wù),它提供了兩種方式來執(zhí)行 PopupComponent:作為動態(tài)組件或作為自定義元素。注意動態(tài)組件的方式需要更多的代碼來做搭建工作。

  • "app.module.ts" 把 PopupComponent 添加到模塊的 entryComponents 列表中,而從編譯過程中排除它,以消除啟動時的警告和錯誤。

  • "app.component.ts" 定義了該應(yīng)用的根組件,它借助 PopupService 在運行時把這個彈窗添加到 DOM 中。在應(yīng)用運行期間,根組件的構(gòu)造函數(shù)會把 PopupComponent 轉(zhuǎn)換成自定義元素。

為了對比,這個范例中同時演示了這兩種方式。一個按鈕使用動態(tài)加載的方式添加彈窗,另一個按鈕使用自定義元素的方式??梢钥吹?,兩者的結(jié)果是一樣的,其差別只是準(zhǔn)備過程不同。

  1. Path:"src/app/popup.component.ts"。

    import { Component, EventEmitter, Input, Output } from '@angular/core';
    import { animate, state, style, transition, trigger } from '@angular/animations';


    @Component({
      selector: 'my-popup',
      template: `
        <span>Popup: {{message}}</span>
        <button (click)="closed.next()">✖</button>
      `,
      host: {
        '[@state]': 'state',
      },
      animations: [
        trigger('state', [
          state('opened', style({transform: 'translateY(0%)'})),
          state('void, closed', style({transform: 'translateY(100%)', opacity: 0})),
          transition('* => *', animate('100ms ease-in')),
        ])
      ],
      styles: [`
        :host {
          position: absolute;
          bottom: 0;
          left: 0;
          right: 0;
          background: #009cff;
          height: 48px;
          padding: 16px;
          display: flex;
          justify-content: space-between;
          align-items: center;
          border-top: 1px solid black;
          font-size: 24px;
        }


        button {
          border-radius: 50%;
        }
      `]
    })
    export class PopupComponent {
      state: 'opened' | 'closed' = 'closed';


      @Input()
      set message(message: string) {
        this._message = message;
        this.state = 'opened';
      }
      get message(): string { return this._message; }
      _message: string;


      @Output()
      closed = new EventEmitter();
    }

  1. Path:"src/app/popup.service.ts"。

    import { ApplicationRef, ComponentFactoryResolver, Injectable, Injector } from '@angular/core';
    import { NgElement, WithProperties } from '@angular/elements';
    import { PopupComponent } from './popup.component';




    @Injectable()
    export class PopupService {
      constructor(private injector: Injector,
                  private applicationRef: ApplicationRef,
                  private componentFactoryResolver: ComponentFactoryResolver) {}


      // Previous dynamic-loading method required you to set up infrastructure
      // before adding the popup to the DOM.
      showAsComponent(message: string) {
        // Create element
        const popup = document.createElement('popup-component');


        // Create the component and wire it up with the element
        const factory = this.componentFactoryResolver.resolveComponentFactory(PopupComponent);
        const popupComponentRef = factory.create(this.injector, [], popup);


        // Attach to the view so that the change detector knows to run
        this.applicationRef.attachView(popupComponentRef.hostView);


        // Listen to the close event
        popupComponentRef.instance.closed.subscribe(() => {
          document.body.removeChild(popup);
          this.applicationRef.detachView(popupComponentRef.hostView);
        });


        // Set the message
        popupComponentRef.instance.message = message;


        // Add to the DOM
        document.body.appendChild(popup);
      }


      // This uses the new custom-element method to add the popup to the DOM.
      showAsElement(message: string) {
        // Create element
        const popupEl: NgElement & WithProperties<PopupComponent> = document.createElement('popup-element') as any;


        // Listen to the close event
        popupEl.addEventListener('closed', () => document.body.removeChild(popupEl));


        // Set the message
        popupEl.message = message;


        // Add to the DOM
        document.body.appendChild(popupEl);
      }
    }

  1. Path:"src/app/app.module.ts"。

    import { NgModule } from '@angular/core';
    import { BrowserModule } from '@angular/platform-browser';
    import { BrowserAnimationsModule } from '@angular/platform-browser/animations';


    import { AppComponent } from './app.component';
    import { PopupComponent } from './popup.component';
    import { PopupService } from './popup.service';


    // Include the `PopupService` provider,
    // but exclude `PopupComponent` from compilation,
    // because it will be added dynamically.


    @NgModule({
      imports: [BrowserModule, BrowserAnimationsModule],
      providers: [PopupService],
      declarations: [AppComponent, PopupComponent],
      bootstrap: [AppComponent],
      entryComponents: [PopupComponent],
    })
    export class AppModule {
    }

  1. Path:"app.component.ts"。

    import { Component, Injector } from '@angular/core';
    import { createCustomElement } from '@angular/elements';
    import { PopupService } from './popup.service';
    import { PopupComponent } from './popup.component';


    @Component({
      selector: 'app-root',
      template: `
        <input #input value="Message">
        <button (click)="popup.showAsComponent(input.value)">Show as component</button>
        <button (click)="popup.showAsElement(input.value)">Show as element</button>
      `,
    })
    export class AppComponent {
      constructor(injector: Injector, public popup: PopupService) {
        // Convert `PopupComponent` to a custom element.
        const PopupElement = createCustomElement(PopupComponent, {injector});
        // Register the custom element with the browser.
        customElements.define('popup-element', PopupElement);
      }
    }

為自定義元素添加類型支持

一般的 DOM API,比如 document.createElement()document.querySelector(),會返回一個與指定的參數(shù)相匹配的元素類型。比如,調(diào)用 document.createElement('a') 會返回 HTMLAnchorElement,這樣 TypeScript 就會知道它有一個 href 屬性,而 document.createElement('div') 會返回 HTMLDivElement,這樣 TypeScript 就會知道它沒有 href 屬性。

當(dāng)調(diào)用未知元素(比如自定義的元素名 popup-element)時,該方法會返回泛化類型,比如 HTMLELement,這時候 TypeScript 就無法推斷出所返回元素的正確類型。

用 Angular 創(chuàng)建的自定義元素會擴展 NgElement 類型(而它擴展了 HTMLElement)。除此之外,這些自定義元素還擁有相應(yīng)組件的每個輸入屬性。比如,popup-element 元素具有一個 string 型的 message 屬性。

如果你要讓你的自定義元素獲得正確的類型,還可使用一些選項。假設(shè)你要創(chuàng)建一個基于下列組件的自定義元素 my-dialog

@Component(...)
class MyDialog {
  @Input() content: string;
}

獲得精確類型的最簡單方式是把相關(guān) DOM 方法的返回值轉(zhuǎn)換成正確的類型。要做到這一點,你可以使用 NgElementWithProperties 類型(都導(dǎo)出自 @angular/elements):

const aDialog = document.createElement('my-dialog') as NgElement & WithProperties<{content: string}>;
aDialog.content = 'Hello, world!';
aDialog.content = 123;  // <-- ERROR: TypeScript knows this should be a string.
aDialog.body = 'News';  // <-- ERROR: TypeScript knows there is no `body` property on `aDialog`.

這是一種讓你的自定義元素快速獲得 TypeScript 特性(比如類型檢查和自動完成支持)的好辦法,不過如果你要在多個地方使用它,可能會有點啰嗦,因為不得不在每個地方對返回類型做轉(zhuǎn)換。

另一種方式可以對每個自定義元素的類型只聲明一次。你可以擴展 HTMLElementTagNameMap,TypeScript 會在 DOM 方法(如 document.createElement()document.querySelector() 等)中用它來根據(jù)標(biāo)簽名推斷返回元素的類型。

declare global {
  interface HTMLElementTagNameMap {
    'my-dialog': NgElement & WithProperties<{content: string}>;
    'my-other-element': NgElement & WithProperties<{foo: 'bar'}>;
    ...
  }
}

現(xiàn)在,TypeScript 就可以像內(nèi)置元素一樣推斷出它的正確類型了:

document.createElement('div')               //--> HTMLDivElement (built-in element)
document.querySelector('foo')               //--> Element        (unknown element)
document.createElement('my-dialog')         //--> NgElement & WithProperties<{content: string}> (custom element)
document.querySelector('my-other-element')  //--> NgElement & WithProperties<{foo: 'bar'}>      (custom element)
以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號