[Angular]在Angular中和DOM打交道的正確姿勢

我愛吃南瓜發表於2019-03-01

原文連結: https://blog.angularindepth.com/working-with-dom-in-angular-unexpected-consequences-and-optimization-techniques-682ac09f6866

介紹

文章裡有很多Angular中的術語,可以參見這篇文章

使用ViewContainerRef來操作Angular中的DOM

  1. 元件檢視(Component View)

  2. 宿主檢視(host view): Angular會對定義在bootstrap和entryComponents中的元件建立宿主檢視,每個宿主檢視在呼叫.createComponent(factory)時負責建立元件檢視(Component View)

  3. 內嵌檢視(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

ComponentFactoryTemplateRef都宣告瞭建立檢視的方法;事實上,當你在呼叫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()方法也是可以獲得對檢視的引用的。

結束! 哈!

相關文章