天天看点

探索使用 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 {}      

ngComponentOutlet

<ng-container *ngComponentOutlet="ColorComponent"></ng-container>      

总结