[Angular]在Angular中和DOM打交道的正确姿势

3,236 阅读7分钟

原文链接: 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()方法也是可以获得对视图的引用的。

结束! 哈!