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ù)語或使用約定有任何特殊的了解。
把組件轉(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)容提供者使用復(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
)。
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)中。
Property
),并在這個自定義元素上定義相應(yīng)的屬性(Attribute
)。 它把屬性名轉(zhuǎn)換成與自定義元素兼容的形式(自定義元素不區(qū)分大小寫),生成的屬性名會使用中線分隔的小寫形式。 比如,對于帶有 @Input('myInputProp') inputProp
的組件,其對應(yīng)的自定義元素會帶有一個 my-input-prop
屬性。@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*
。
以前,如果你要在運行期間把一個組件添加到應(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)換成自定義組件。
PopupComponent
:作為動態(tài)組件或作為自定義元素。注意動態(tài)組件的方式需要更多的代碼來做搭建工作。PopupComponent
添加到模塊的 entryComponents
列表中,而從編譯過程中排除它,以消除啟動時的警告和錯誤。PopupService
在運行時把這個彈窗添加到 DOM 中。在應(yīng)用運行期間,根組件的構(gòu)造函數(shù)會把 PopupComponent
轉(zhuǎn)換成自定義元素。為了對比,這個范例中同時演示了這兩種方式。一個按鈕使用動態(tài)加載的方式添加彈窗,另一個按鈕使用自定義元素的方式??梢钥吹?,兩者的結(jié)果是一樣的,其差別只是準(zhǔn)備過程不同。
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();
}
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);
}
}
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 {
}
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)換成正確的類型。要做到這一點,你可以使用 NgElement
和 WithProperties
類型(都導(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)
更多建議: