16

【翻译】在Angular中操作DOM:意料之外的结果及优化技术

原文链接:https://blog.angularindepth.c...
作者:Max Koretskyi
译者:而井

我最近在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来直接移除<a-comp> 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内部中,使用了通常称为View或Component View的数据结构来代表组件。这张图显示了视图和DOM之间的关系:

每个视图都由持有对应DOM元素的视图节点所组成。所以,当我们直接修改DOM的时候,视图内部的视图节点以及持有的DOM元素引用并没有被影响。这里有一张图可以展示在我们从DOM中移除组件A后,DOM和视图的状态:

并且由于所有的变更检测操作和对子视图的包含,都是运行在视图中而不是DOM上,Angular检测与组件相关的视图,并且报告(译者注:组件数量)为1,而不是我们期望的0。此外,由于与组件A相关的视图依旧存在,所以对于组件A及其子组件的变更检测操作依然会被执行。

要正确地解决这个问题,我们需要一个能直接处理视图的工具,在Angular中它就是视图容器View Container

视图容器View Container

视图容器可以保障DOM级别的变动的安全,在Angular中,它被所有内置的结构指令所使用。在视图内部有一种特别的视图节点类型,它扮演着其他视图容器的角色:

正如你所见的那样,它持有两种类型的视图:嵌入视图(embedded views)和宿主视图(host views)。

在Angular中只有这些视图类型,它们(视图)主要的不同取决于用什么输入数据来创建它们。并且嵌入视图只能附加(译者注:挂载)到视图容器中,而宿主视图可以被附加到任何DOM元素上(通常称其为宿主元素)。

嵌入视图可以使用TemplateRef通过模版来创建,而宿主视图得使用视图(组件)工厂来创建。例如,用于启动程序的主要组件AppComponent,在内部被当作为一个用来附加挂载组件宿主元素<app-comp>的宿主视图。

视图容器提供了用来创建、操作和移除动态视图的API。我称它们为动态视图,是为了和那些由框架在模版中发现的静态组件所创建出来的静态视图做对比。Angular不会对静态视图使用视图容器,而是在子组件特定的节点内保持一个对子视图的引用。这张图可以表明这个想法:

正如你所见,这里没有视图容器,子视图的引用是直接附加到组件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这两个创建视图的方法,都会返回被创建的视图的引用。


而井
851 声望1.8k 粉丝