Angular DOM 抽象概述

2019-11-05 15:47:07 浏览数 (1)

为了能够支持跨平台,Angular 通过抽象层封装了不同平台的差异,统一了 API 接口。如定义了抽象类 Renderer2 、抽象类 RootRenderer 等。此外还定义了以下引用类型:ElementRef、TemplateRef、ViewRef 、ComponentRef 和 ViewContainerRef 等。

ElementRef

在日常工作中,Web 工程师经常需要跟 DOM 打交道。通过 DOM API 我们能够方便地获取指定元素,比如获取谷歌首页中 id 为 q 的输入框:

代码语言:javascript复制
document.querySelector("#q");

查询结果为:

代码语言:javascript复制
<input id="q" aria-hidden="true" autocomplete="off" name="q" tabindex="-1" type="url" 
   jsaction="mousedown:ntp.fkbxclk" style="opacity: 0;">

在页面完成渲染后,我们可以通过 DOM API 获取页面中的任意元素,并进行相关的操作。这在大多数情况下,是没有问题的,但如果我们开发的应用要支持跨平台的话,就不能绑定宿主环境为浏览器。

为了解决上述问题,Angular 引入ElementRef 对象,它是视图中 native 元素的包装器。

定义
代码语言:javascript复制
// angular-master/packages/core/src/linker/element_ref.ts
export class ElementRef<T = any> {
  public nativeElement: T;
  constructor(nativeElement: T) { this.nativeElement = nativeElement; }
}

根据 ElementRef 类的定义,我们知道 Angular 内部把不同平台下视图层中的 native 元素封装在 ElementRef 实例的 nativeElement 属性中。在浏览器环境中,nativeElement 属性指向的就是对应的 DOM 元素。

作用

在应用层直接操作 DOM,就会造成应用层与渲染层之间强耦合,导致我们的应用无法运行在不同环境,如 Web Worker 中,因为在 Web Worker 环境中,是不能操作 DOM。有兴趣的读者,可以阅读 Web Workers 中支持的类和方法 这篇文章。因此引入 ElementRef 类主要目的是为了实现跨平台。

示例
  1. 利用依赖注入获取宿主 ElementRef 实例
代码语言:javascript复制
import { Component, ElementRef } from "@angular/core";

@Component({
  selector: "hello-world",
  template: `
        <h3 #name>Semlinker</h3>
    `
})
export class HelloWorldComponent {
  constructor(private elementRef: ElementRef) {
    console.log(this.elementRef);
  }
}

以上代码运行后,控制台的输出结果:

代码语言:javascript复制
ElementRef {nativeElement: hello-world}
nativeElement: hello-world
  1. 利用 ViewChild 装饰器获取匹配的 ElementRef 实例
代码语言:javascript复制
import { Component, ElementRef, ViewChild, AfterViewInit } from "@angular/core";

@Component({
  selector: "hello-world",
  template: `
    <h3 #name>Semlinker</h3>
  `
})
export class HelloWorldComponent implements AfterViewInit {
  @ViewChild("name") nameElement: ElementRef;

  ngAfterViewInit(): void {
    console.dir(this.nameElement);
  }

  constructor(private elementRef: ElementRef) {
    console.log(this.elementRef);
  }
}

以上代码运行后,控制台的输出结果:

代码语言:javascript复制
ElementRef {nativeElement: hello-world}
nativeElement: hello-world
ElementRef
nativeElement: h3

TemplateRef

在介绍 TemplateRef 前,我们先来了解一下 HTML 模板元素 —— <template>。模板元素是一种机制,允许包含加载页面时不渲染,但又可以随后通过 JavaScript 进行实例化的客户端内容。我们可以将模板视作为存储在页面上稍后使用的一小段内容。

在 HTML5 标准引入 template 模板元素之前,我们都是使用 <script> 标签进行客户端模板的定义,具体如下:

代码语言:javascript复制
<script id="tpl-mock" type="text/template">
   <span>I am span in mock template</span>
</script>

对于支持 HTML5 template 模板元素的浏览器,我们可以这样创建客户端模板:

代码语言:javascript复制
<template id="tpl">
   <span>I am span in template</span>
</template>

下面我们来看一下 HTML5 template 模板元素的使用示例:

代码语言:javascript复制
<!-- Template Container -->
<div class="tpl-container"></div>
<!-- Template -->
<template id="tpl">
    <span>I am span in template</span>
</template>
<!-- Script -->
<script type="text/javascript">
    (function renderTpl() {
        // 判断当前浏览器是否支持template元素
        if ('content' in document.createElement('template')) {
            var tpl = document.querySelector('#tpl');
            var tplContainer = document.querySelector('.tpl-container');
            var tplNode = document.importNode(tpl.content, true);
            tplContainer.appendChild(tplNode); 
        } else {
            throw  new Error("Current browser doesn't support template element");
        }
    })();
</script>

针对以上的应用场景,Angular 为我们开发者提供了 <ng-template> 元素,在 Angular 内部它主要应用在结构指令中,比如 *ngIf*ngFor 等。

接下来我们先来介绍 TemplateRef,它表示可用于实例化内嵌视图的内嵌模板。

定义

TemplateRef_

代码语言:javascript复制
class TemplateRef_ extends TemplateRef<any> implements TemplateData {
  _projectedViews !: ViewData[];

  constructor(private _parentView: ViewData, private _def: NodeDef) { super(); }

  createEmbeddedView(context: any): EmbeddedViewRef<any> {
    return new ViewRef_(Services.createEmbeddedView(
        this._parentView, this._def, this._def.element !.template !, context));
  }

  get elementRef(): ElementRef {
    return new ElementRef(asElementData(this._parentView, 
      this._def.nodeIndex).renderElement);
  }
}

TemplateRef

代码语言:javascript复制
// angular-master/packages/core/src/linker/template_ref.ts
export abstract class TemplateRef<C> {
  abstract get elementRef(): ElementRef;
  abstract createEmbeddedView(context: C): EmbeddedViewRef<C>;
}

(备注:抽象类与普通类的区别是抽象类有包含抽象方法,不能直接实例化抽象类,只能实例化该抽象类的子类)

作用

利用 TemplateRef 实例,我们可以灵活地创建内嵌视图。

示例

前面我们已经介绍了如何使用 HTML5 template 模板元素,下面我们来看一下如何使用 <ng-template> 元素。

代码语言:javascript复制
@Component({
  selector: "hello-world",
  template: `
    <h3>Hello World</h3>
    <ng-template #tpl>
      <span>I am span in template</span>
    </ng-template>
    `
})
export class HelloWorldComponent implements AfterViewInit {
  @ViewChild("tpl")
  tplRef: TemplateRef<HTMLElement>;

  ngAfterViewInit(): void {
    // 模板中的<ng-template>元素会被编译为<!---->元素
    let commentElement = this.tplRef.elementRef.nativeElement;
    // 创建内嵌视图
    let embeddedView = this.tplRef.createEmbeddedView(null);
    // 动态添加子节点
    embeddedView.rootNodes.forEach(node => {
      commentElement.parentNode.insertBefore(node, commentElement.nextSibling);
    });
  }
}

以上示例的核心处理流程如下:

  • 创建内嵌视图(embedded view)
  • 遍历内嵌视图中的 rootNodes,动态的插入 node

虽然我们已经成功的显示出 template 模板元素中的内容,但发现整个流程还是太复杂了,那有没有简单地方式呢 ?是时候请我们 ViewContainerRef 对象出场了。

ViewContainerRef

假设你的任务是添加一个新的段落作为当前元素的兄弟元素:

代码语言:javascript复制
<p class="one">Element one</p>

使用 jQuery 简单实现上述功能:

代码语言:javascript复制
$('<p>Element two</p>').insertAfter('.one');

当你需要添加新的 DOM 元素 (例如,组件、模板),你需要指定元素插入的地方。Angular 没有什么神奇之处,如果你想要插入新的组件或元素,你需要告诉 Angular 在哪里插入新的元素。

ViewContainerRef 就是这样的:

一个视图容器,可以把新组件作为这个元素的兄弟。

定义

ViewContainerRef_

代码语言:javascript复制
// angular-master/packages/core/src/view/refs.ts
class ViewContainerRef_ implements ViewContainerData {
  _embeddedViews: ViewData[] = [];
  constructor(private _view: ViewData, private _elDef: NodeDef,
    private _data: ElementData) {}

  get element(): ElementRef { return new ElementRef(this._data.renderElement); }

  get injector(): Injector { return new Injector_(this._view, this._elDef); }

  createEmbeddedView<C>(templateRef: TemplateRef<C>, context?: C, index?: number):
      EmbeddedViewRef<C> {
    const viewRef = templateRef.createEmbeddedView(context || <any>{});
    this.insert(viewRef, index);
    return viewRef;
  }
  // ...
  }
}

ViewContainerRef

代码语言:javascript复制
// angular-master/packages/core/src/linker/view_container_ref.ts
export abstract class ViewContainerRef {
  abstract get injector(): Injector;

  abstract get parentInjector(): Injector;

  abstract createEmbeddedView<C>(templateRef: TemplateRef<C>, 
    context?: C, index?: number): EmbeddedViewRef<C>;
  
  abstract createComponent<C>(
      componentFactory: ComponentFactory<C>, index?: number, injector?: Injector,
      projectableNodes?: any[][], ngModule?: NgModuleRef<any>): ComponentRef<C>;
  
  abstract insert(viewRef: ViewRef, index?: number): ViewRef;

  abstract move(viewRef: ViewRef, currentIndex: number): ViewRef;
  // ...
}
作用

ViewContainerRef 对象用于表示一个视图容器,可添加一个或多个视图。通过 ViewContainer Ref 实例,我们可以基于 TemplateRef 实例创建内嵌视图,并能指定内嵌视图的插入位置,也可以方便对视图容器中已有的视图进行管理。简而言之,ViewContainerRef 的主要作用是创建和管理内嵌视图或组件视图。

示例

了解完 ViewContainerRef 对象的作用,我们来更新一下之前的 HelloWorldComponent 组件:

代码语言:javascript复制
@Component({
  selector: "hello-world",
  template: `
    <h3>Hello World</h3>
    <ng-template #tpl>
      <span>I am span in template</span>
    </ng-template>
    `
})
export class HelloWorldComponent implements AfterViewInit {
  @ViewChild("tpl") tplRef: TemplateRef<HTMLElement>;

  @ViewChild("tpl", { read: ViewContainerRef })
  tplVcRef: ViewContainerRef;

  ngAfterViewInit(): void {
    this.tplVcRef.createEmbeddedView(this.tplRef);
  }
}

对比一下之前的代码,是不是觉得 ViewContainerRef 如此强大。

ViewRef

ViewRef 是一种抽象类型,用于表示 Angular 视图。在 Angular 中,视图是构建应用程序 UI 界面基础构建块。

在 Angular 中支持两种类型视图:

  • Embedded Views - Template 模板元素
  • Host Views - Component 组件
创建 Embedded View
代码语言:javascript复制
ngAfterViewInit() {
  let view = this.tpl.createEmbeddedView(null);
}
创建 Host View
代码语言:javascript复制
constructor(
  private injector: Injector,
  private r: ComponentFactoryResolver
) {
    let factory = this.r.resolveComponentFactory(HelloWorldComponent);
    let componentRef = factory.create(injector);
    let view = componentRef.hostView;
}

ng-container

作为 Angular 的初学者,可能会在某个标签上同时使用 *ngIf*ngFor 指令,比如:

代码语言:javascript复制
<div class="lesson" *ngIf="lessons" *ngFor="let lesson of lessons">
    <div class="lesson-detail">
        {{lesson | json}}
    </div>
</div>

当以上代码运行后,你将会看到以下报错信息:

代码语言:javascript复制
Uncaught Error: Template parse errors:
Can't have multiple template bindings on one element. Use only one attribute 
named 'template' or prefixed with *

这意味着不可能将两个结构指令应用于同一个元素。为了实现这个需求,我们必须做类似的事情:

代码语言:javascript复制
<div *ngIf="lessons">
    <div class="lesson" *ngFor="let lesson of lessons">
        <div class="lesson-detail">
            {{lesson | json}}
        </div>
    </div>
</div>

在这个例子中,我们将 ngIf 指令移动到外部 div 元素上,但为了满足上述需求,我们必须创建额外的 div 元素。那么有没有办法不用创建一个额外的元素呢?答案是有的,就是使用 <ng-container> 元素。

示例
代码语言:javascript复制
<ng-container *ngIf="lessons">
    <div class="lesson" *ngFor="let lesson of lessons">
        <div class="lesson-detail">
            {{lesson | json}}
        </div>
    </div>
</ng-container>

ngTemplateOutlet

ngTemplateOutlet 指令用于标识指定的 DOM 元素作为视图容器,然后自动地插入设定的内嵌视图,而不用像 ViewContainerRef 章节中示例那样,需要手动创建内嵌视图。

下面我们来使用 ngTemplateOutlet 指令,改写 ViewContainerRef 章节中示例:

代码语言:javascript复制
@Component({
  selector: "hello-world",
  template: `
    <h3>Hello World</h3>
    <ng-container *ngTemplateOutlet="tpl"></ng-container>
    <ng-template #tpl>
      <span>I am span in template</span>
    </ng-template>
    `
})
export class HelloWorldComponent{}

可以发现通过 ngTemplateOutlet 指令,大大减轻了我们的工作量,接下来让我们看一下 ngTemplateOutlet 指令的定义:

代码语言:javascript复制
@Directive({selector: '[ngTemplateOutlet]'})
export class NgTemplateOutlet implements OnChanges {
  // TODO(issue/24571): remove '!'.
  private _viewRef !: EmbeddedViewRef<any>;

  // TODO(issue/24571): remove '!'.
  @Input() public ngTemplateOutletContext !: Object;

  // TODO(issue/24571): remove '!'.
  @Input() public ngTemplateOutlet !: TemplateRef<any>;

  constructor(private _viewContainerRef: ViewContainerRef) {}

  ngOnChanges(changes: SimpleChanges) { }
}

我们发现 ngTemplateOutlet 指令除了支持 ngTemplateOutlet 输入属性之外,还支持 ngTemplateOutletContext 输入属性。ngTemplateOutletContext 顾名思义是用于设置指令的上下文。

代码语言:javascript复制
@Component({
  selector: "hello-world",
  template: `
    <h3>Hello World</h3>
    <ng-container *ngTemplateOutlet="tpl; context: ctx"></ng-container>
    <ng-template #tpl let-name let-location="location">
      <span>I am {{name}} in {{location}}</span>
    </ng-template>
    `
})
export class HelloWorldComponent {
  ctx = {
    $implict: "span",
    location: "template"
  };
}

ngComponentOutlet

有些场景下,我们希望根据条件动态的创建组件。动态创建组件的流程如下:

  • 获取装载动态组件的容器
  • 在组件类的构造函数中,注入 ComponentFactoryResolver 对象
  • 调用 ComponentFactoryResolver 对象的 resolveComponentFactory() 方法创建 ComponentFactory 对象
  • 调用组件容器对象的 createComponent() 方法创建组件并自动添加动态组件到组件容器中
  • 基于返回的 ComponentRef 组件实例,配置组件相关属性 (可选)
  • 在模块 Metadata 对象的 entryComponents 属性中添加动态组件
    • declarations - 用于指定属于该模块的指令和管道列表。
    • entryComponents - 用于指定在模块定义时,需要编译的组件列表。对于列表中声明的每个组件,Angular 将会创建对应的一个 ComponentFactory 对象,并将其存储在 ComponentFactoryResolver 对象中。
代码语言:javascript复制
@Component({
  selector: "app-root",
  template: `
    <div>
      <div #entry></div>
    </div>
  `
})
export class AppComponent implements AfterContentInit {
  @ViewChild("entry", { read: ViewContainerRef })
  entry: ViewContainerRef;

  constructor(private resolver: ComponentFactoryResolver) {}

  ngAfterContentInit() {
    const authFormFactory = this.resolver.resolveComponentFactory(
      AuthFormComponent
    );
    this.entry.createComponent(authFormFactory);
  }
}

通过 ComponentFactoryResolver 对象,我们实现了动态创建组件的功能。但创建的过程还是有点繁琐,为了提高开发者体验和开发效率,Angular 引入了 ngComponentOutlet 指令。 好的,我们马上来体验一下 ngComponentOutlet 指令。

代码语言:javascript复制
@Component({
  selector: "app-root",
  template: `
    <div>
      <div *ngComponentOutlet="authFormComponent"></div>
    </div>
  `
})
export class AppComponent {
  authFormComponent = AuthFormComponent
}

ngComponentOutlet 指令除了支持 ngComponentOutlet 输入属性之外,它还含有另外 3 个输入属性:

代码语言:javascript复制
// angular-master/packages/common/src/directives/ng_component_outlet.ts
@Directive({selector: '[ngComponentOutlet]'})
export class NgComponentOutlet implements OnChanges, OnDestroy {
  // TODO(issue/24571): remove '!'.
  @Input() ngComponentOutlet !: Type<any>;
  // TODO(issue/24571): remove '!'.
  @Input() ngComponentOutletInjector !: Injector;
  // TODO(issue/24571): remove '!'.
  @Input() ngComponentOutletContent !: any[][];
  // TODO(issue/24571): remove '!'.
  @Input() ngComponentOutletNgModuleFactory !: NgModuleFactory<any>;

  private _componentRef: ComponentRef<any>|null = null;
  private _moduleRef: NgModuleRef<any>|null = null;

  constructor(private _viewContainerRef: ViewContainerRef) {}

  ngOnChanges(changes: SimpleChanges) {}

  ngOnDestroy() {
    if (this._moduleRef) this._moduleRef.destroy();
  }
}

总结

本文主要介绍了 Angular 中常见的引用类型,如 ElementRef、TemplateRef、ViewRef 等。实际工作中,还需要利用 ViewChild、ViewChildren、ContentChild 和 ContentChildren 装饰器,或者基于 Angular 依赖注入特性,通过构造注入的方式,获取相关的对象。此外,在获取匹配的元素后,我们往往需要需要对返回的对象进行相应操作。在浏览器环境中,虽然通过 ElementRef 的 nativeElement 属性,我们可以方便地获取对应的 DOM 元素,但我们最好不要利用 DOM API 进行 DOM 操作,最好通过 Angular 提供的 Renderer2 对象,进行相关的操作。

0 人点赞