在Angular中操作DOM:意料之外的結果及優化技術

而井不想說話發表於2018-12-23

【翻譯】在Angular中操作DOM:意料之外的結果及優化技術

原文連結:blog.angularindepth.com/working-wit…
作者:Max Koretskyi
譯者:而井

在Angular中操作DOM:意料之外的結果及優化技術

我最近在NgConf的一個研討會上討論了Angular中的高階DOM操作的話題。我從基礎知識開始講起,例如使用模版引用和DOM查詢來訪問DOM元素,一直談到了使用檢視容器來動態渲染模版和元件。如果你還沒有看過這個演講,我鼓勵你去看看。通過一系列的實踐,你將可以快速地學會新知識,並加強認知。關於這個話題,我在NgViking 也有一個簡單地談話。

然而,如果你覺得那個版本太長了(譯者注:指演講視訊)不想看,或者比起聽,你更喜歡閱讀,那麼我在這篇文章總結了(演講的)關鍵概念。首先,我會介紹在Angular中操作DOM的工具和方法,然後再介紹一些我在研討會上沒有說過的、更高階的優化技術。

你可以在這個GitHub倉庫中找到我演講中使用過的樣例。

窺探檢視引擎

假設你有一個要將一個子元件從DOM中移除的任務。這裡有一個父元件,它的模組中有一個子元件A需要被移除:

@Component({
  ...
  template: `
    <button (click)="remove()">Remove child component</button>
    <a-comp></a-comp>
  `
})
export class AppComponent {}
複製程式碼

解決這個任務的一個錯誤的方法就是使用Renderer或者原生的DOM API來直接移除 DOM 元素:

@Component({...})
export class AppComponent {
  ...
  remove() {
    this.renderer.removeChild(
       this.hostElement.nativeElement, // parent App comp node
       this.childComps.first.nativeElement // child A comp node
     );
  }
}
複製程式碼

你可以在這裡看到整個解決方案(譯者注:樣例程式碼)。如果你通過Element tab來審查移除節點之後的HTML結果,你將看到子元件A已經不存在DOM中了。

然而,如果你接著檢查一下控制檯,Angular依然報導子元件的數量為1,而不是0。並且關於對子元件A及其子節點的變更檢測還在錯誤的執行著。這裡是控制檯輸出的日誌:

在Angular中操作DOM:意料之外的結果及優化技術

為什麼?

發生這種情況是因為,在Angular內部中,使用了通常稱為View或Component View的資料結構來代表元件。這張圖顯示了檢視和DOM之間的關係:

在Angular中操作DOM:意料之外的結果及優化技術

每個檢視都由持有對應DOM元素的檢視節點所組成。所以,當我們直接修改DOM的時候,檢視內部的檢視節點以及持有的DOM元素引用並沒有被影響。這裡有一張圖可以展示在我們從DOM中移除元件A後,DOM和檢視的狀態:

在Angular中操作DOM:意料之外的結果及優化技術

並且由於所有的變更檢測操作和對子檢視的包含,都是執行在檢視中而不是DOM上,Angular檢測與元件相關的檢視,並且報告(譯者注:元件數量)為1,而不是我們期望的0。此外,由於與元件A相關的檢視依舊存在,所以對於元件A及其子元件的變更檢測操作依然會被執行。

要正確地解決這個問題,我們需要一個能直接處理檢視的工具,在Angular中它就是檢視容器View Container

檢視容器View Container

檢視容器可以保障DOM級別的變動的安全,在Angular中,它被所有內建的結構指令所使用。在檢視內部有一種特別的檢視節點型別,它扮演著其他檢視容器的角色:

在Angular中操作DOM:意料之外的結果及優化技術

正如你所見的那樣,它持有兩種型別的檢視:嵌入檢視(embedded views)和宿主檢視(host views)。

在Angular中只有這些檢視型別,它們(檢視)主要的不同取決於用什麼輸入資料來建立它們。並且嵌入檢視只能附加(譯者注:掛載)到檢視容器中,而宿主檢視可以被附加到任何DOM元素上(通常稱其為宿主元素)。

嵌入檢視可以使用TemplateRef通過模版來建立,而宿主檢視得使用檢視(元件)工廠來建立。例如,用於啟動程式的主要元件AppComponent,在內部被當作為一個用來附加掛載元件宿主元素<app-comp>的宿主檢視。

檢視容器提供了用來建立、操作和移除動態檢視的API。我稱它們為動態檢視,是為了和那些由框架在模版中發現的靜態元件所建立出來的靜態檢視做對比。Angular不會對靜態檢視使用檢視容器,而是在子元件特定的節點內保持一個對子檢視的引用。這張圖可以表明這個想法:

在Angular中操作DOM:意料之外的結果及優化技術

正如你所見,這裡沒有檢視容器,子檢視的引用是直接附加到元件A的檢視節點上的。

操控動態檢視

在你開始建立一個檢視並將其附加到檢視容器之前,你需要引入元件模版的容器並且將其進行例項化。模版中的任何元素都可以充當檢視容器,不過,通常扮演這個角色的候選者是<ng-container>,因為在它會渲染成一個註釋節點,所以不會給DOM帶來冗餘的元素。

為了將任意元素轉化成一個檢視容器,我們需要對一個檢視查詢使用{read: ViewContainerRef}配置:

@Component({
 …
 template: `<ng-container #vc></ng-container>`
})
export class AppComponent implements AfterViewChecked {
  @ViewChild('vc', {read: ViewContainerRef}) viewContainer: ViewContainerRef;
}
複製程式碼

一旦Angular執行對應的檢視查詢並將檢視容器的的引用賦值給一個類的屬性,你就可以使用這個引用來建立一個動態檢視了。

建立一個嵌入檢視

為了建立一個嵌入檢視,你需要一個模版。在Angular中,我們會使用<ng-template>來包裹任意DOM元素和定義模版的結構。然後我們就可以簡單地用一個帶有{read: TemplateRef}引數的檢視查詢來獲取這個模版的引用:

@Component({
  ...
  template: `
    <ng-template #tpl>
        <!-- any HTML elements can go here -->
    </ng-template>
  `
})
export class AppComponent implements AfterViewChecked {
    @ViewChild('tpl', {read: TemplateRef}) tpl: TemplateRef<null>;
}
複製程式碼

一旦Angular執行這個查詢並且將模版的引用賦值給類的屬性後,我們就可以通過createEmbeddedView方法使用這個引用來建立和附加一個嵌入檢視到一個檢視容器中:

@Component({ ... })
export class AppComponent implements AfterViewInit {
    ...
    ngAfterViewInit() {
        this.viewContainer.createEmbeddedView(this.tpl);
    }
}
複製程式碼

你需要在ngAfterViewInit生命週期中實現你的邏輯,因為檢視查詢是那時完成例項化的。而且你可以給模版(譯者注:嵌入檢視的模版)中的值繫結一個上下文物件(譯者注:即模版上繫結的值隸屬於這個上下文物件)。你可以通過檢視API文件來了解更多詳情。

你可以在這裡找到建立嵌入檢視的整個樣例程式碼。

建立一個宿主檢視

要建立一個宿主檢視,你就需要一個元件工廠。如果你需要了解Angular中動態元件的話,點選這裡可以學習到更多關於元件工廠和動態元件的知識。

在Angular中,我們可以使用componentFactoryResolver這個服務來獲取一個元件工廠的引用:

@Component({ ... })
export class AppComponent implements AfterViewChecked {
  ...
  constructor(private r: ComponentFactoryResolver) {}
  ngAfterViewInit() {
    const factory = this.r.resolveComponentFactory(ComponentClass);
  }
 }
}
複製程式碼

一旦我們得到一個元件工廠,我們就可以用它來初始化元件,建立宿主檢視並將其檢視附加到檢視容器之上。為了達到這一步,我們只需簡單地呼叫createComponent方法,並且傳入一個元件工廠:

@Component({ ... })
export class AppComponent implements AfterViewChecked {
    ...
    ngAfterViewInit() {
        this.viewContainer.createComponent(this.factory);
    }
}
複製程式碼

你可以在這裡找到建立宿主檢視的樣例程式碼。

移除檢視

一個檢視容器中的任何附加檢視,都可以通過removedetach方法來刪除。兩個方法都會將檢視從檢視容器和DOM中移除。但是remove方法會銷燬檢視,所以之後不能重新附加(譯者注:即從快取中獲取再附加,不用重新建立),detach方法會保持檢視的引用,以便未來可以重新使用,這個對於我接下來要講的優化技術很重要。

所以,為了正確地解決移除一個子元件或任意DOM元素這個問題,首先有必要建立一個嵌入檢視或宿主檢視,並將其附加到檢視容器上。然後你才有辦法使用任何可用的API方法來將檢視從檢視容器和DOM中移除。

優化技術

有時你需要重複地渲染和隱藏模版中定義好的相同元件或HTML。在下面這個例子中,通過點選不同的按鈕,我們可以切換要顯示的元件:

如果我們把之前學過的知識簡單地應用一下,那程式碼將會如下所示:

@Component({...})
export class AppComponent {
  show(type) {
    ...
    // 檢視被銷燬
    this.viewContainer.clear();
    
    // 檢視被建立並附加到檢視容器之上   
    this.viewContainer.createComponent(factory);
  }
}
複製程式碼

最終,我們會得一個不想要的結果:每當按鈕被點選、show方法被執行時,檢視都會被銷燬和重新建立。

在這個例子中,宿主檢視會因為我們使用元件工廠和createComponent方法,而銷燬和重複建立。如果我們使用createEmbeddedView方法和TemplateRef,那嵌入檢視也會被銷燬和重複建立:

show(type) {
    ...
    // 檢視被銷燬
    this.viewContainer.clear();
    // 檢視被建立並附加到檢視容器之上   
    this.viewContainer.createEmbeddedView(this.tpl);
}
複製程式碼

理想狀況下,我們只需建立檢視一次,之後在我們需要的時候複用它。有一個檢視容器的API,它提供了將已經存在的檢視附加到檢視容器之上、移除檢視卻不銷燬檢視的辦法。

ViewRef

ComponentFactoryTemplateRef都實現了用來建立檢視的建立方法。事實上,當你呼叫createEmbeddedViewcreateComponent 方法並傳入輸入資料時,檢視容器在底層內部使用了這些建立方法。有一個好訊息就是我們可以自己呼叫這些方法來建立一個嵌入或宿主檢視、獲取檢視的引用。在Angular中,檢視可以通過ViewRef及其子型別來引用。

建立一個宿主檢視

所以通過這樣,你可以使用一個元件工廠來建立一個宿主檢視和獲取它的引用:

aComponentFactory = resolver.resolveComponentFactory(AComponent);
aComponentRef = aComponentFactory.create(this.injector);
view: ViewRef = aComponentRef.hostView;
複製程式碼

在宿主檢視情況下,檢視與元件的關聯(引用)可以通過ComponentRef呼叫create方法來獲取。通過一個hostView屬性來暴露。

一旦我們獲得到這個檢視,它就可以通過insert方法附加到一個檢視容器之上。另外一個你不想顯示的檢視可以通過detach方法來從檢視中移除並保持引用。所以可以通過這樣來解決元件切換顯示問題:

showView2() {
    ...
    //  檢視1將會從檢視容器和DOM中移除
    this.viewContainer.detach();
    // 檢視2將會被附加於檢視容器和DOM之上
    this.viewContainer.insert(view);
}
複製程式碼

注意,我們使用detach方法來代替clearremove方法,為之後的複用保持檢視(的引用)。你可以在這裡找到整個實現。

建立一個嵌入檢視

在以一個模版為基礎來建立一個嵌入檢視的情況下,檢視(引用)可以直接通過createEmbeddedView方法來返回:

view1: ViewRef;
view2: ViewRef;
ngAfterViewInit() {
    this.view1 = this.t1.createEmbeddedView(null);
    this.view2 = this.t2.createEmbeddedView(null);
}
複製程式碼

與之前的例子類似,有一個檢視將會從檢視容器移除,另外一個檢視將會被重新附加到檢視容器之上。你可以在這裡找到整個實現。

有趣的是,檢視容器(譯者注:ViewContainerRef型別)的createEmbeddedViewcreateComponent這兩個建立檢視的方法,都會返回被建立的檢視的引用。

相關文章