原文連結: https://blog.angularindepth.com/working-with-dom-in-angular-unexpected-consequences-and-optimization-techniques-682ac09f6866
介紹
文章裡有很多Angular中的術語,可以參見這篇文章
使用ViewContainerRef來操作Angular中的DOM
-
元件檢視(Component View)
-
宿主檢視(host view): Angular會對定義在bootstrap和entryComponents中的元件建立
宿主檢視
,每個宿主檢視在呼叫.createComponent(factory)
時負責建立元件檢視(Component View)
-
內嵌檢視(Embedded View): 內嵌檢視是由
<ng-template></ng-template>
元素宣告的。
初探檢視引擎
假設現在你有這樣一個需求,需要從DOM中移除某個元件
@Component({
...
template: `
<button (click)="remove()">Remove child component</button>
<a-comp></a-comp>
`
})
export class AppComponent {}
複製程式碼
有個錯誤的方法就是用Renderer的removeChild()
方法或者原生DOM API來移除<a-comp></a-comp>
元素;
如下
// 這是一個錯誤示例!!!
import { AfterViewChecked, Component, ElementRef, QueryList, Renderer2, ViewChildren } from `@angular/core`;
@Component({
selector: `app-root`,
template: `
<button (click)="remove()">Remove child component</button>
<a-comp #c></a-comp>
`
})
export class AppComponent implements AfterViewChecked {
@ViewChildren(`c`, {read: ElementRef}) childComps: QueryList<ElementRef>;
constructor(private hostElement: ElementRef, private renderer: Renderer2) {
}
ngAfterViewChecked() {
console.log(`number of child components: ` + this.childComps.length);
}
remove() {
this.renderer.removeChild(
this.hostElement.nativeElement,
this.childComps.first.nativeElement
);
}
}
複製程式碼
當然,在執行完remove()方法後,審查元素中這個元件自然是消失了,但是尷尬的是ngAfterViewChecked()
生命週期鉤子中仍顯示子元件的個數是1
,更為尷尬的是這個元件的變更檢測也仍然在執行,這當然不是我們要的結果。
why
這樣的現象是因為Angular內部使用了一個View類或者ComponentView類來描述一個元件;每個檢視都包括了很多與DOM元素所關聯的檢視節點,但是檢視到DOM之間的繫結的是單向的,也就是說修改View會影響到DOM渲染,但是對DOM的操作並不會影響到View或者ComponentVIew;
Angular的變更檢測都是繫結在View上的,而不是DOM上,所以自然會有上面的現象。
由此可見你不能從DOM層面去試圖刪除一個元件;事實上,你不應該刪除任何由框架本身生成的html元素。當然,那些由你自己的程式碼或者第三方外掛生成的元素你可以隨意刪除。
View Container(檢視容器)
為了解決這個問題,首先我們要來了解一下檢視容器;
檢視容器的存在使得對DOM的操作變得高度安全,事實上Angular很多內建指令的實現也是依靠檢視容器完成的。這是View中一個特殊的View Node,通常是作為其他檢視的容器。
檢視容器中可以放置Angular中有且僅有的兩種型別的檢視,內嵌式圖(embedded view)和宿主檢視(host view),他倆的區別主要在於建立的時候傳遞進去的引數的不通;另外,內嵌檢視只能依附於檢視容器,宿主檢視不僅可以依附於檢視容器,也可以依附於其他宿主元素(DOM元素);
內嵌檢視是由模板通過TemplateRef建立的,宿主檢視通常是由檢視(元件)工廠建立的,舉個例子,AppComponent就是一個宿主檢視,依附於這個元件的宿主元素<app></app>
;
動態控制檢視
建立一個內嵌式圖(embedded view)
要建立一個內嵌式圖(embedded view),首先我們得有一個模板(template),在Angular中,我們一般使用<ng-template></ng-template>
標籤來包裹一些DOM元素,從而定義一個**模板(template)的結構。然後我們就可以通過@ViewChild
獲取對這個模板(template)**的引用;
一旦Angular完成了對這個查詢的解析,我們就可以用createEmbeddedView()
方法在**檢視容器(view container)**上建立一個內嵌檢視
import { Component, AfterViewInit, ViewChild, ViewContainerRef } from `@angular/core`;
@Component({
selector: `app-test-dom`,
template: `
<ng-template #tpl let-name="name">
{{name}}
<div>ng template works</div>
</ng-template>
`
})
export class TestDomComponent implements AfterViewInit {
@ViewChild(`tpl`) tpl;
constructor(
private viewContainer: ViewContainerRef,
) { }
ngAfterViewInit() {
console.log(this.tpl);
this.viewContainer.createEmbeddedView(this.tpl, {name: `yyz`});
}
}
複製程式碼
建立內嵌檢視的邏輯應該放在AfterViewInit生命週期中執行,因為此時所有的檢視查詢才被初始化。當然,對內嵌檢視而言,你也可以在建立的時候傳遞一個**上下文物件(context object)**用以模板內的的資料繫結;具體見上例中第二個引數{name: `yyz`}
;此方法的詳細api參見https://angular.io/api/core/ViewContainerRef#createEmbeddedView
建立一個宿主檢視(host view)
要建立宿主檢視,我們需要一個元件工廠(component factory),有關元件工廠的更多資訊,可以檢視https://blog.angularindepth.com/here-is-what-you-need-to-know-about-dynamic-components-in-angular-ac1e96167f9e
;
在Angular中,我們使用componentFactoryResolver
服務來獲取對一個元件工廠的引用;獲取這個元件的factory引用後,我們就能用它來初始化這個元件、建立**宿主檢視(host view)**並把這個檢視附加到檢視容器上,只需要呼叫ViewContainerRef
中的createComponent()
方法,將組建工廠傳遞進去即可
// app.module.ts
@NgModule({
...
entryComponents: [
aComponent,
],
})
// app.component.ts
...
import { aCompoenent } from `./a.component.ts`;
@Component({ ... })
export class AppComponent implements AfterViewInit {
...
constructor(private r: ComponentFactoryResolver) {}
ngAfterViewInit() {
const factory = this.r.resolveComponentFactory(aComponent);
this.viewContainer.createComponent(factory);
}
}
複製程式碼
移除一個檢視
所有被新增在檢視容器上的檢視都可以通過remove()
或者detach()
方法來移除;這兩個方法都將檢視從檢視容器和DOM上移除。他倆的區別就在於:remove()
方法會將這個檢視銷燬掉,從而以後不能再次使用,但是detach()
會將這個檢視儲存起來。這也對下面要介紹的有關技術優化非常重要。
優化方法
有時候我們會很頻繁的去渲染和隱藏相同的元件或者模板定義的html。如果我們只是去簡單的把ViewContainerclear()
然後createComponent()
,或者ViewContainerclear()
然後createEmbeddedView()
。這樣的效能開銷是比較大的。
// bad code
@Component({...})
export class AppComponent {
showHostView(type) {
...
// a view is destroyed
this.viewContainer.clear();
// a view is created and attached to a view container
this.viewContainer.createComponent(factory);
}
showEmbeddedView(type) {
...
// a view is destroyed
this.viewContainer.clear();
// a view is created and attached to a view container
this.viewContainer.createEmbeddedView(this.tpl);
}
}
複製程式碼
理想情況下,我們應該只建立一次檢視,然後複用。而不是一次又一次的建立並銷燬。View Container提供了將已有檢視附加到檢視容器上和移除時不銷燬檢視的API。
ViewRef
ComponentFactory
和TemplateRef
都宣告瞭建立檢視的方法;事實上,當你在呼叫createEmbeddedView()
和createComponent()
方法時,檢視容器也呼叫了這些方法來建立。當然我們也可以手動呼叫這些方法建立內嵌檢視或者宿主檢視,從而獲得對檢視的引用。@angular/core中提供了ViewRef類來解決這個問題。
建立宿主檢視
通過組建工廠我們可以輕鬆的建立一個宿主檢視並獲取對它的引用;
// 通過組建工廠的create()方法建立
aComponentFactory = resolver.resolveComponentFactory(aComponent);
aComponentRef: ComponentRef = aComponentFactory.create(this.injector);
view: ViewRef = aComponentRef.hostView;
// 獲取檢視後,我們就可以在檢視容器上進行操作
showView() {
...
// 使用detach()方法而不是clear()或者remove()方法,從而儲存對檢視的引用
this.viewContainer.detach();
this.viewContainer.insert(view)
}
複製程式碼
建立內嵌檢視
內嵌檢視是由模板建立出來的,createEmbeddedView()
方法直接就返回了對檢視的引用;
import {AfterViewInit, Component, TemplateRef, ViewChild, ViewContainerRef, ViewRef} from `@angular/core`;
@Component({
selector: `app-root`,
template: `
<button (click)="show(`1`)">Show Template 1</button>
<button (click)="show(`2`)">Show Template 2</button>
<div>
<ng-container #vc></ng-container>
</div>
<ng-template #t1><span>I am SPAN from template 1</span></ng-template>
<ng-template #t2><span>I am SPAN from template 2</span></ng-template>
`
})
export class AppComponent implements AfterViewInit {
@ViewChild(`vc`, {read: ViewContainerRef}) vc: ViewContainerRef;
@ViewChild(`t1`, {read: TemplateRef}) t1: TemplateRef<null>;
@ViewChild(`t2`, {read: TemplateRef}) t2: TemplateRef<null>;
view1: ViewRef;
view2: ViewRef;
ngAfterViewInit() {
this.view1 = this.t1.createEmbeddedView(null);
this.view2 = this.t2.createEmbeddedView(null);
}
show(type) {
const view = type === `1` ? this.view1 : this.view2;
this.vc.detach();
this.vc.insert(view);
}
}
複製程式碼
當然,不光光是元件工廠的create()
方法和模板的createEmbeddedView()
方法,一個檢視容器的createEmbeddedView()
和createConponent()
方法也是可以獲得對檢視的引用的。
結束! 哈!