原文連結:Exploring Angular DOM manipulation techniques using ViewContainerRef
如果想深入學習 Angular 如何使用 Renderer 和 View Containers 技術操作 DOM,可以查閱 YouTube 視訊 my talk at NgVikings。
每次我讀到 Angular 如何操作 DOM 相關文章時,總會發現這些文章提到 ElementRef
、TemplateRef
、ViewContainerRef
和其他的類。儘管這些類在 Angular 官方文件或相關文章會有涉及,但是很少會去描述整體思路,這些類如何一起作用的相關示例也很少,而本文就主要描述這些內容。
如果你來自於 angular.js 世界,很容易明白如何使用 angular.js 操作 DOM。angular.js 會在 link
函式中注入 DOM element
,你可以在元件模板裡查詢任何節點(node),新增或刪除節點(node),修改樣式(styles),等等。然而這種方式有個主要缺陷:與瀏覽器平臺緊耦合。
新版本 Angular 需要在不同平臺上執行,如 Browser 平臺,Mobile 平臺或者 Web Worker 平臺,所以,就需要在特定平臺的 API 和框架介面之間進行一層抽象(abstraction)。Angular 中的這層抽象就包括這些引用型別:ElementRef
、TemplateRef
、ViewRef
、ComponentRef
和 ViewContainerRef
。本文將詳細講解每一個引用型別(reference type)和該引用型別如何操作 DOM。
@ViewChild
在探索 DOM 抽象類前,先了解下如何在元件/指令中獲取這些抽象類。Angular 提供了一種叫做 DOM Query 的技術,主要來源於 @ViewChild
和 @ViewChildren
裝飾器(decorators)。兩者基本功能相同,唯一區別是 @ViewChild
返回單個引用,@ViewChildren
返回由 QueryList 物件包裝好的多個引用。本文示例中主要以 ViewChild
為例,並且描述時省略 @
。
通常這兩個裝飾器與模板引用變數(template reference variable)一起使用,模板引用變數僅僅是對模板(template)內 DOM 元素命名式引用(a named reference),類似於 html
元素的 id
屬性。你可以使用模板引用(template reference)來標記一個 DOM 元素,並在元件/指令類中使用 ViewChild
裝飾器查詢(query)它,比如:
@Component({
selector: 'sample',
template: `
<span #tref>I am span</span>
`
})
export class SampleComponent implements AfterViewInit {
@ViewChild("tref", {read: ElementRef}) tref: ElementRef;
ngAfterViewInit(): void {
// outputs `I am span`
console.log(this.tref.nativeElement.textContent);
}
}
複製程式碼
ViewChild
裝飾器基本語法是:
@ViewChild([reference from template], {read: [reference type]});
複製程式碼
上例中你可以看到,我把 tref
作為模板引用名稱,並將 ElementRef
與該元素聯絡起來。第二個引數 read
是可選的,因為 Angular 會根據 DOM 元素的型別推斷出該引用型別。例如,如果它(#tref)掛載的是類似 span
的簡單 html 元素,Angular 返回 ElementRef
;如果它掛載的是 template
元素,Angular 返回 TemplateRef
。一些引用型別如 ViewContainerRef
就不可以被 Angular 推斷出來,所以必須在 read
引數中顯式申明。其他的如 ViewRef
不可以掛載在 DOM 元素中,所以必須手動在建構函式中編碼構造出來。
現在,讓我們看看應該如何獲取這些引用,一起去探索吧。
ElementRef
這是最基本的抽象類,如果你檢視它的類結構,就發現它只包含所掛載的元素物件,這對訪問原生 DOM 元素很有用,比如:
// outputs `I am span`
console.log(this.tref.nativeElement.textContent);
複製程式碼
然而,Angular 團隊不鼓勵這種寫法,不但因為這種方式會暴露安全風險,而且還會讓你的程式與渲染層(rendering layers)緊耦合,這樣就很難在多平臺執行你的程式。我認為這個問題並不是使用 nativeElement
而是使用特定的 DOM API 造成的,如 textContent
。但是後文你會看到,Angular 實現了操作 DOM 的整體思路模型,這樣就不再需要低階 API,如 textContent
。
使用 ViewChild
裝飾的 DOM 元素會返回 ElementRef
,但是由於所有元件掛載於自定義 DOM 元素,所有指令作用於 DOM 元素,所以元件和指令都可以通過 DI(Dependency Injection)獲取宿主元素的ElementRef
物件。比如:
@Component({
selector: 'sample',
...
export class SampleComponent{
constructor(private hostElement: ElementRef) {
//outputs <sample>...</sample>
console.log(this.hostElement.nativeElement.outerHTML);
}
...
複製程式碼
所以元件通過 DI(Dependency Injection)可以訪問到它的宿主元素,但 ViewChild
裝飾器經常被用來獲取模板檢視中的 DOM 元素。然而指令卻相反,因為指令沒有檢視模板,所以主要用來獲取指令掛載的宿主元素。
TemplateRef
對於大部分開發者來說,模板概念很熟悉,就是跨程式檢視內一堆 DOM 元素的組合。在 HTML5 引入 template 標籤前,瀏覽器通過在 script
標籤內設定 type
屬性來引入模板,比如:
<script id="tpl" type="text/template">
<span>I am span in template</span>
</script>
複製程式碼
這種方式不僅有語義缺陷,還需要手動建立 DOM 模型,然而通過 template
標籤,瀏覽器可以解析 html
並建立 DOM
樹,但不會渲染它,該 DOM 樹可以通過 content
屬性訪問,比如:
<script>
let tpl = document.querySelector('#tpl');
let container = document.querySelector('.insert-after-me');
insertAfter(container, tpl.content);
</script>
<div class="insert-after-me"></div>
<ng-template id="tpl">
<span>I am span in template</span>
</ng-template>
複製程式碼
Angular 採用 template
標籤這種方式,實現了 TemplateRef
抽象類來和 template
標籤一起合作,看看它是如何使用的(譯者注:ng-template 是 Angular 提供的類似於 template 原生 html 標籤):
@Component({
selector: 'sample',
template: `
<ng-template #tpl>
<span>I am span in template</span>
</ng-template>
`
})
export class SampleComponent implements AfterViewInit {
@ViewChild("tpl") tpl: TemplateRef<any>;
ngAfterViewInit() {
let elementRef = this.tpl.elementRef;
// outputs `template bindings={}`
console.log(elementRef.nativeElement.textContent);
}
}
複製程式碼
Angular 框架從 DOM 中移除 template
元素,並在其位置插入註釋,這是渲染後的樣子:
<sample>
<!--template bindings={}-->
</sample>
複製程式碼
TemplateRef
是一個結構簡單的抽象類,它的 elementRef
屬性是對其宿主元素的引用,還有一個 createEmbeddedView
方法。然而 createEmbeddedView
方法很有用,因為它可以建立一個檢視(view)並返回該檢視的引用物件 ViewRef
。
ViewRef
該抽象表示一個 Angular 檢視(View),在 Angular 世界裡,檢視(View)是一堆元素的組合,一起被建立和銷燬,是構建程式 UI 的基石。Angular 鼓勵開發者把 UI 作為一堆檢視(View)的組合,而不僅僅是 html 標籤組成的樹。
Angular 支援兩種型別檢視:
- 嵌入檢視(Embedded View),由
Template
提供 - 宿主檢視(Host View),由
Component
提供
建立嵌入檢視
模板僅僅是檢視的藍圖,可以通過之前提到的 createEmbeddedView
方法建立檢視,比如:
ngAfterViewInit() {
let view = this.tpl.createEmbeddedView(null);
}
複製程式碼
建立宿主檢視
宿主檢視是在元件動態例項化時建立的,一個動態元件(dynamic component)可以通過 ComponentFactoryResolver
建立:
constructor(private injector: Injector,
private r: ComponentFactoryResolver) {
let factory = this.r.resolveComponentFactory(ColorComponent);
let componentRef = factory.create(injector);
let view = componentRef.hostView;
}
複製程式碼
在 Angular 中,每一個元件繫結著一個注入器(Injector)例項,所以建立 ColorComponent
元件時傳入當前元件(即 SampleComponent)的注入器。另外,別忘了,動態建立元件時需要在模組(module)或宿主元件的 EntryComponents 屬性新增被建立的元件。
現在,我們已經看到嵌入檢視和宿主檢視是如何被建立的,一旦檢視被建立,它就可以使用 ViewContainer
插入 DOM 樹中。下文主要探索這個功能。
ViewContainerRef
檢視容器就是掛載一個或多個檢視的容器。
首先需要說的是,任何 DOM 元素都可以作為檢視容器,然而有趣的是,對於繫結 ViewContainer
的 DOM 元素,Angular 不會把檢視插入該元素的內部,而是追加到該元素後面,這類似於 router-outlet
插入元件的方式。
通常,比較好的方式是把 ViewContainer
繫結在 ng-container
元素上,因為 ng-container
元素會被渲染為註釋,從而不會在 DOM 中引入多餘的 html 元素。下面示例描述在組建模板中如何建立 ViewContainer
:
@Component({
selector: 'sample',
template: `
<span>I am first span</span>
<ng-container #vc></ng-container>
<span>I am last span</span>
`
})
export class SampleComponent implements AfterViewInit {
@ViewChild("vc", {read: ViewContainerRef}) vc: ViewContainerRef;
ngAfterViewInit(): void {
// outputs `template bindings={}`
console.log(this.vc.element.nativeElement.textContent);
}
}
複製程式碼
如同其他抽象類一樣,ViewContainer
通過 element
屬性繫結 DOM 元素,比如上例中,繫結的是 會被渲染為註釋的 ng-container
元素,所以輸出也將是 template bindings={}
。
操作檢視
ViewContainer
提供了一些操作檢視 API:
class ViewContainerRef {
...
clear() : void
insert(viewRef: ViewRef, index?: number) : ViewRef
get(index: number) : ViewRef
indexOf(viewRef: ViewRef) : number
detach(index?: number) : ViewRef
move(viewRef: ViewRef, currentIndex: number) : ViewRef
}
複製程式碼
從上文我們已經知道如何通過模板和元件建立兩種型別檢視,即嵌入檢視和元件檢視。一旦有了檢視,就可以通過 insert
方法插入 DOM 中。下面示例描述如何通過模板建立嵌入檢視,並在 ng-container
標記的地方插入該檢視(譯者注:從上文中知道是追加到ng-container
後面,而不是插入到該 DOM 元素內部)。
@Component({
selector: 'sample',
template: `
<span>I am first span</span>
<ng-container #vc></ng-container>
<span>I am last span</span>
<ng-template #tpl>
<span>I am span in template</span>
</ng-template>
`
})
export class SampleComponent implements AfterViewInit {
@ViewChild("vc", {read: ViewContainerRef}) vc: ViewContainerRef;
@ViewChild("tpl") tpl: TemplateRef<any>;
ngAfterViewInit() {
let view = this.tpl.createEmbeddedView(null);
this.vc.insert(view);
}
}
複製程式碼
通過上面的實現,最後的 html
看起來是:
<sample>
<span>I am first span</span>
<!--template bindings={}-->
<span>I am span in template</span>
<span>I am last span</span>
<!--template bindings={}-->
</sample>
複製程式碼
可以通過 detach
方法從檢視中移除 DOM,其他的方法可以通過方法名知道其含義,如通過索引獲取檢視引用物件,移動檢視位置,或者從檢視容器中移除所有檢視。
建立檢視
ViewContainer
也提供了手動建立檢視 API :
class ViewContainerRef {
element: ElementRef
length: number
createComponent(componentFactory...): ComponentRef<C>
createEmbeddedView(templateRef...): EmbeddedViewRef<C>
...
}
複製程式碼
上面兩個方法是個很好的封裝,可以傳入模板引用物件或元件工廠物件來建立檢視,並將該檢視插入檢視容器中特定位置。
ngTemplateOutlet 和 ngComponentOutlet
儘管知道 Angular 操作 DOM 的內部機制是好事,但是要是有某種快捷方式就更好了啊。沒錯,Angular 提供了兩種快捷指令:ngTemplateOutlet
和 ngComponentOutlet
。寫作本文時這兩個指令都是實驗性的,ngComponentOutlet
也將在版本 4 中可用(譯者注:現在版本 5.* 也是實驗性的,也都可用)。如果你讀完了上文,就很容易知道這兩個指令是做什麼的。
ngTemplateOutlet
該指令會把 DOM 元素標記為 ViewContainer
,並插入由模板建立的嵌入檢視,從而不需要在元件類中顯式建立該嵌入檢視。這樣,上面例項中,針對建立嵌入檢視並插入 #vc
DOM 元素的程式碼就可以重寫:
@Component({
selector: 'sample',
template: `
<span>I am first span</span>
<ng-container [ngTemplateOutlet]="tpl"></ng-container>
<span>I am last span</span>
<ng-template #tpl>
<span>I am span in template</span>
</ng-template>
`
})
export class SampleComponent {}
複製程式碼
從上面示例看到我們不需要在元件類中寫任何例項化檢視的程式碼。非常方便,對不對。
ngComponentOutlet
這個指令與 ngTemplateOutlet
很相似,區別是 ngComponentOutlet
建立的是由元件例項化生成的宿主檢視,不是嵌入檢視。你可以這麼使用:
<ng-container *ngComponentOutlet="ColorComponent"></ng-container>
複製程式碼
總結
看似有很多新知識需要消化啊,但實際上 Angular 通過檢視操作 DOM 的思路模型是很清晰和連貫的。你可以使用 ViewChild
查詢模板引用變數來獲得 Angular DOM 抽象類。DOM 元素的最簡單封裝是 ElementRef
;而對於模板,你可以使用 TemplateRef
來建立嵌入檢視;而對於元件,可以使用 ComponentRef
來建立宿主檢視,同時又可以使用 ComponentFactoryResolver
建立 ComponentRef
。這兩個建立的檢視(即嵌入檢視和宿主檢視)又會被 ViewContainerRef
管理。最後,Angular 又提供了兩個快捷指令自動化這個過程:ngTemplateOutlet
指令使用模板建立嵌入檢視;ngComponentOutlet
使用動態元件建立宿主檢視。