探索使用 ViewContainerRef 的 Angular DOM 操控技术
探索使用 ViewContainerRef 的 Angular DOM 操控技术
https://indepth.dev/posts/1052/exploring-angular-dom-manipulation-techniques-using-viewcontainerref
每当我阅读关于在 Angular 中处理 DOM 的文章的时候,我总是看到这些类型中的某些被提到:
- ElementRef
- TemplateRef
- ViewContainerRef
- ......
不幸的是,尽管它们在 Angular 的文档中被说明了,我还是没有找到对概念模型进行全面说明的文档和示例,以及它们是如何被组合使用的。本文尝试说明 Angular 的概念模型。
如果你正在寻找在 Angular 中使用 Renderer 和 View 来操作 DOM 的深入讨论,请查阅 my talk at NgVikings。或者阅读深入讨论动态 DOM 操控的文章 Working with DOM in Angular: unexpected consequences and optimization techniques
如果原来使用过 angular.js,你就会知道,处理 DOM 是非常简单的事情。Angular 将 DOM 元素 element 传递给 link() 函数,你可以查询组件模板内的任何节点,增加或者删除子节点,修改样式等等。不过,这种方式有一个重要的缺陷 - 它紧密耦合到浏览器平台上。
新的 Angular 运行在多种平台上 - 浏览器,移动平台,或者运行在 Web worker 上。所以需要一个抽象层来从平台特定的 API 中抽象出来框架的接口。在 Angular 中,这些抽象通过这些引用类型表示:
- ElementRef
- TemplateRef
- ViewRef
- ComponentRef
- ViewContainerRef
在本文中,我们将深入演练这些抽象类型中每一个,并展示如何使用它们来操控 DOM。
@ViewChild
在开始说明这些 DOM 抽象之前,让我们先理解一下,如何在 Component/Directive 类中访问这些抽象。Angular 提供了被称为 DOM query 的机制。它使用了 @ViewChild 和 @ViewChildren 装饰器。两种的行为类似,只是前一种只返回一个引用,而后一种以 QueryList 对象的形式返回多个引用。在本文的示例中,我将主要使用 ViewChild 装饰器,并忽略这个 @ 符号。
一般来说,这些装饰器与 template reference variables 配套使用,template reference variable 是用来在模板中简单地引用 DOM 元素的方式。你可以想象它类似于 html 元素所提供的 id 特性。使用 template reference variable 来标记一个 DOM 元素,然后在类中使用 ViewChild 装饰器来查询到它。下面是一个基本的示例:
@Component({
selector: 'sample',
template: `
<span #tref>I am span</span>
`
})
export class SampleComponent implements AfterViewInit {
@ViewChild("tref", {read: ElementRef}) tref: ElementRef;
ngAfterViewInit(): void {
// outputs `I am span`
console.log(this.tref.nativeElement.textContent);
}
}
@ViewChild 基本的使用语法如下:
@ViewChild([reference from template], {read: [reference type]});
在上面的示例中,你可以看到我指定了 tref
作为在 html 中的模板引用名称,并通过 ElementRef 类型关联到该元素。第 2 个参数 read 并不总是必须的,因为 Angular 可以通过 DOM 元素的类型推断出来引用类型。例如,如果它是一个简单的 html 元素,例如这里的 <span> 元素,Angular 就返回一个 ElementRef。如果它是一个 <template> 元素,就会返回一个 TemplateRef。有些引用,比如 ViewContainerRef 不能被推断出来,就必须在 read 参数中指定。另外,ViewRef 是不能通过 DOM 返回的,它必须手动构造出来。
好了,现在我们知道了如何查询到这些引用,现在可以开始说明它们了。
ElementRef
它是最为基本的抽象了。如果你查看它的类结构,你会发现它仅仅持有它关联到的原生元素。对于访问原生 DOM 元素来说,它非常有用:
// outputs `I am span`
console.log(this.tref.nativeElement.textContent);
不过,这样的用法是不被 Angular 所鼓励使用的。不仅是带来的安全风险,它还将你的应用程序与渲染层绑定在一起,使得难以运行在其它平台上。我相信访问 nativeElement 不仅破坏了抽象,还使用了特定的 DOM API,比如 textContent。不过随我我们会看到,在 Angular 中的概念模型中,很难用到如此低级的操作。
ElementRef 可以通过任何 DOM 元素通过 ViewChild 装饰器获得。
因为所有的 Component 都是寄宿在一个自定义的 DOM 元素之中,而所有的指令都需要通过 DOM 元素来应用,所以,Component 和 Directive 可以通过依赖注入而得到一个其关联寄宿元素的 ElementRef 的实例。
@Component({
selector: 'sample',
...
export class SampleComponent{
constructor(private hostElement: ElementRef) {
//outputs <sample>...</sample>
console.log(this.hostElement.nativeElement.outerHTML);
}
所以,Component 是通过 DI 来访问其计算的元素,而 ViewChild 装饰器更多用于获得 Component 内部模板中的 DOM 元素的引用。不过,对指令则不是,它们是没有模板的,指令直接工作在它们应用的元素之上。
TemplateRef
大多数的 Web 开发者都应该熟悉 template。它是一组 DOM 元素,可以跨整个应用程序在视图中重用。在 HTML5 标准引入 <template> 之前,很多模式是通过使用 script 在浏览器中实现的,这需要使用 type 一些变体。
<script id="tpl" type="text/template">
<span>I am span in template</span>
</script>
这种方式存在多种缺陷,比如语义和需要手动创建 DOM 模型。使用 <template>,浏览器可以解析其中的 HTML 并创建 DOM 树,而不需要渲染它。以后它可以通过 content 属性访问。
<script>
let tpl = document.querySelector('#tpl');
let container = document.querySelector('.insert-after-me');
insertAfter(container, tpl.content);
</script>
<div class="insert-after-me"></div>
<ng-template id="tpl">
<span>I am span in template</span>
</ng-template>
Angular 拥抱这种方式,并通过 TemplateRef 类来使用 <template>。下面是如何使用的示例。
@Component({
selector: 'sample',
template: `
<ng-template #tpl>
<span>I am span in template</span>
</ng-template>
`
})
export class SampleComponent implements AfterViewInit {
@ViewChild("tpl") tpl: TemplateRef<any>;
ngAfterViewInit() {
let elementRef = this.tpl.elementRef;
// outputs `template bindings={}`
console.log(elementRef.nativeElement.textContent);
}
}
Angular 框架会从 DOM 中删除 <template> 元素,然后在它的位置插入一个注释。这里是渲染的结果:
<sample>
<!--template bindings={}-->
</sample>
对于 TemplateRef 类型,它只是一个简单的类。通过属性 elementRef 持有其宿主元素的引用,还有一个方法:createEmbeddedView()。不过,该方法非常有用,因为它支持我们创建 View 并返回一个对该 View 的引用 ViewRef。
ViewRef
在 Angular 中,ViewRef 表示 Angular 视图 (View) 的抽象表示。在 Angular 的世界中,View 是应用程序的基本构建块。它是在一起被创建或者销毁的最小元素组单位。Angular 哲学鼓励开发者将 UI 界面看作 View 的聚合。而不要看作标准的 HTML 元素树。
Angular 支持两种 View:
- Embedded View,指 Template
- Host View,指 Component
创建 embedded view
Template 用来简单地持有一个 View 的蓝图。View 可以通过 createEmbeddedView() 方法,通过 template 实例化出来。
指通过 <template>元素来创建出来模板,然后通过 createEmbeddedView() 方法实例化出来。
ngAfterViewInit() {
let view = this.tpl.createEmbeddedView(null);
}
创建 host view
Host View 通过组件动态实例化。组件可以使用 ComponentFactoryResolver() 方法动态创建出来。
constructor(private injector: Injector,
private r: ComponentFactoryResolver) {
let factory = this.r.resolveComponentFactory(ColorComponent);
let componentRef = factory.create(injector);
let view = componentRef.hostView;
}
在 Angular 中,每个 Component 都绑定到一个 Injector 的实例上,所以,当创建这个 Component 的时候,我们将当前组件的 Injector 传递进去。另外,不要忘了,动态实例化的 Component 需要加入到 Module 或者托管的 Component 的 EntryComponents 中。
所以,我们已经看到了可以创建的 embeded 和 host 两种 View。一旦 View 被创建出来,它就可以使用 ViewContainer 插入到 DOM 中。下一节我们就介绍它的功能。
ViewContainerRef
ViewContainerRef 表示可以容纳一个或者多个 View 的容器。
首先需要提醒的是,任何 DOM 元素都可以作为 View 的容器。有趣的是,Angular 不是将 View 插入到元素中,而是绑定到元素的 ViewContainer 中。这类似于 router-outlet 如何插入 Component。
通常,比较好的将一个位置标记为 ViewContainer 的方式,是创建一个 <ng-container> 元素。它会被渲染为一条 comment,所以不会带来多于的 HTML 元素到 DOM 中。下面是一个示例,演示了在 Component 的模板中创建 ViewContainer。
@Component({
selector: 'sample',
template: `
<span>I am first span</span>
<ng-container #vc></ng-container>
<span>I am last span</span>
`
})
export class SampleComponent implements AfterViewInit {
@ViewChild("vc", {read: ViewContainerRef}) vc: ViewContainerRef;
ngAfterViewInit(): void {
// outputs `template bindings={}`
console.log(this.vc.element.nativeElement.textContent);
}
}
与其它的 DOM 抽象类似,ViewContainer 也通过 element 属性绑定以特定的 DOM 元素。在上面的示例中,就是 ng-container 元素,它被渲染为一个 comment,所以输出就成为 template bindings={}
。
操控 Views
ViewContainer 提供了一系列便捷的 API 来操作 View。
class ViewContainerRef {
...
clear() : void
insert(viewRef: ViewRef, index?: number) : ViewRef
get(index: number) : ViewRef
indexOf(viewRef: ViewRef) : number
detach(index?: number) : ViewRef
move(viewRef: ViewRef, currentIndex: number) : ViewRef
}
前面我们已经看到过,如何手工创建两种类型的 View,分别是通过 <template> 和 Component。一旦创建了 View,我们就可以使用 insert() 方法将它们插入到容器中。下面是使用 <template> 创建嵌入的 View,并插入到使用 ng-container 元素指定的特定位置。
@Component({
selector: 'sample',
template: `
<span>I am first span</span>
<ng-container #vc></ng-container>
<span>I am last span</span>
<ng-template #tpl>
<span>I am span in template</span>
</ng-template>
`
})
export class SampleComponent implements AfterViewInit {
@ViewChild("vc", {read: ViewContainerRef}) vc: ViewContainerRef;
@ViewChild("tpl") tpl: TemplateRef<any>;
ngAfterViewInit() {
let view = this.tpl.createEmbeddedView(null);
this.vc.insert(view);
}
}
从 DOM 中删除 View,也就是从 ViewContainer 中删除 View,可以使用 detach() 方法。所有其它方法都是自解释的,可以通过下标获得相关的 View,将 View 移动到其它位置,或者删除 Container 中所有的 View。
创建 View
ViewContainer 还提供了一个自动创建 View 的 API
class ViewContainerRef {
element: ElementRef
length: number
createComponent(componentFactory...): ComponentRef<C>
createEmbeddedView(templateRef...): EmbeddedViewRef<C>
...
}
它们是上面手工创建方式的简单封装。通过 Template 或者 Component 创建 View,并插入到特定的位置。
ngTemplateOutlet 和 ngComponentOutlet
尽管理解底层是如何工作的很重要,通常期望的使用方式总是简单。有两个指令实现快捷操作
- ngTemplateOutlet
- ngComponentOutlet
非常好理解它们的作用。
ngTemplateOutlet
它将一个 DOM 元素标记为 ViewContainer,创建 <template> 的 View 实例,并将这个 Embeded View 插入到其中,而不需要在 Component 类中显式用代码完成。这意味着,上面的示例可以重写为如下形式。
@Component({
selector: 'sample',
template: `
<span>I am first span</span>
<ng-container [ngTemplateOutlet]="tpl"></ng-container>
<span>I am last span</span>
<ng-template #tpl>
<span>I am span in template</span>
</ng-template>
`
})
export class SampleComponent {}
如你所见,我们不再需要任何实例化 View 的代码,特别方便。
ngComponentOutlet
与 ngTemplateOutlet 指令类似,该指令创建 Host View ( Component 的示例),而不是 Embeded View,你可以仿照下面的示例使用。
<ng-container *ngComponentOutlet="ColorComponent"></ng-container>
总结
对 Angular 中对通过 View 来操作 DOM,有一个清晰的概念模型非常重要。