原文链接: https://blog.angularindepth.com/working-with-dom-in-angular-unexpected-consequences-and-optimization-techniques-682ac09f6866
介绍
文章里有很多Angular中的术语,可以参见这篇文章
使用ViewContainerRef来操作Angular中的DOM
-
组件视图(Component View)
-
宿主视图(host view): Angular会对定义在bootstrap和entryComponents中的组件创建
宿主视图
,每个宿主视图在调用.createComponent(factory)
时负责创建组件视图(Component View)
-
内嵌视图(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
ComponentFactory
和TemplateRef
都声明了创建视图的方法;事实上,当你在调用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()
方法也是可以获得对视图的引用的。
结束! 哈!