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

2次阅读

共计 5528 个字符,预计需要花费 14 分钟才能阅读完成。

【翻译】在 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);
}
}
你可以在这里找到创建宿主视图的样例代码。
移除视图
一个视图容器中的任何附加视图,都可以通过 remove 和 detach 方法来删除。两个方法都会将视图从视图容器和 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
ComponentFactory 和 TemplateRef 都实现了用来创建视图的创建方法。事实上,当你调用 createEmbeddedView 和 createComponent 方法并传入输入数据时,视图容器在底层内部使用了这些创建方法。有一个好消息就是我们可以自己调用这些方法来创建一个嵌入或宿主视图、获取视图的引用。在 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 方法来代替 clear 或 remove 方法,为之后的复用保持视图(的引用)。你可以在这里找到整个实现。
创建一个嵌入视图
在以一个模版为基础来创建一个嵌入视图的情况下,视图(引用)可以直接通过 createEmbeddedView 方法来返回:
view1: ViewRef;
view2: ViewRef;
ngAfterViewInit() {
this.view1 = this.t1.createEmbeddedView(null);
this.view2 = this.t2.createEmbeddedView(null);
}
与之前的例子类似,有一个视图将会从视图容器移除,另外一个视图将会被重新附加到视图容器之上。你可以在这里找到整个实现。
有趣的是,视图容器(译者注:ViewContainerRef 类型)的 createEmbeddedView 和 createComponent 这两个创建视图的方法,都会返回被创建的视图的引用。

正文完
 0