深入Angular:(转/翻译)Working with DOM in Angular: unexpected consequences and optimization techniques

前提概要:

本文介绍了一种巧妙的优化技术,可以应用于 ngFor 常用的场景。您将了解什么是嵌入式视图以及如何重用它而不是在每次迭代时销毁它。

Maxim koretskyi在 NgConf 上以研讨会的形式发表了关于 Angular 中高级 DOM 操作的演讲。从使用模板引用和 DOM 查询来访问 DOM 元素等基础知识,到使用视图容器动态渲染模板和组件。

原视频:https://www.youtube.com/watch?v=qWmqiYDrnDc

我总结了本文中的关键概念。我将首先解释在 Angular 中使用 DOM 的工具和方法,然后继续讨论我在研讨会期间没有接触到的更高级的优化技术。

您可以在此 github 存储库中找到演讲中使用的示例。

InDepthApp link:IndepthApp

前置知识:

<ng-container> 用于在模板中执行逻辑操作而不会添加额外的 DOM 元素。它是一个无形的容器,可以用来组织和控制模板中的结构和行为,但不会在最终渲染的 HTML 中生成任何额外的标记。

<ng-template> 则用于定义一个模板片段,以便在组件中重复使用。它可以包含任意数量的 HTML 元素和 Angular 指令,但不会在渲染时显示任何内容。它通常与结构性指令(如 *ngIf 和 *ngFor)一起使用,以便根据需要动态地插入或删除模板内容。

查看视图引擎

假设您有一个从 DOM 中删除子组件的任务。这是父组件的模板,其中包含A需要删除的子组件:

@Component({
  ...
  template: `
    <button (click)="remove()">Remove child component</button>
    <a-comp></a-comp>
  `
})
export class AppComponent {}

解决该任务的错误方法是使用渲染器或本机 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
     );
  }
}

您可以在此处查看完整的解决方案。如果删除节点后检查选项卡中生成的 HTML Elements,您将看到子A组件不再存在于 DOM 中:

但是,如果您随后检查控制台,Angular 仍将子组件的数量报告为而1不是0。更糟糕的是,更改检测仍然针对子A组件及其子组件运行。这是来自控制台的日志:

为什么? 

发生这种情况是因为 Angular 在内部使用通常称为ViewComponent View 的数据结构表示组件。下面是表示视图和 DOM 之间关系的图:

发生这种情况是因为 Angular 在内部使用通常称为ViewComponent View 的数据结构表示组件。下面是表示视图和 DOM 之间关系的图:

每个视图都由视图节点组成,这些视图节点保存对相应 DOM 元素的引用。因此,当我们直接更改 DOM 时,位于视图内部并保存对该 DOM 元素的引用的视图节点不会受到影响。A下面的图表显示了我们从 DOM 中删除组件元素后视图和 DOM 的状态:

由于所有更改检测操作(包括 ViewChildren )都在 View 而不是 DOM上运行,因此 Angular 会检测与组件对应的一个视图A并报告 number 1,而不是0按预期报告。此外,由于与组件对应的视图A存在,因此它还会对该A组件及其所有子组件运行更改检测。

这表明您不能简单地直接从 DOM 中删除子组件。事实上,您应该避免删除框架创建的任何 HTML 元素,而只删除 Angular 不知道的元素。这些可能是由您的代码或某些 3 方插件创建的元素。

为了正确地解决这个任务,我们需要一个直接与视图一起工作的工具,Angular 中的这样的工具就是View Container

查看容器 

视图容器可以安全地更改 DOM 层次结构,并由 Angular 中的所有内置结构指令使用。它是一种特殊的视图节点,位于视图内部并充当其他视图的容器:

正如您所看到的,它可以容纳两种类型的视图:嵌入式视图和宿主视图

这些是 Angular 中存在的唯一视图类型,它们的不同主要取决于用于创建它们的输入数据。此外,嵌入视图只能附加到视图容器,而宿主视图也可以附加到任何 DOM 元素(通常称为宿主元素)。

在Angular中,嵌入式视图和宿主视图是指组件之间的关系。

**宿主视图**是指包含一个或多个嵌入式视图的组件。它是一个拥有自己的模板和逻辑的组件,可以包含其他组件作为其子组件。

**嵌入式视图**是指被宿主视图包含的组件。它是由宿主视图创建和管理的,用于展示特定的内容或功能。嵌入式视图可以被多个宿主视图重复使用。

区别在于:
- 宿主视图是一个独立的组件,有自己的模板和逻辑,可以包含其他组件作为子组件。
- 嵌入式视图是被宿主视图包含的组件,由宿主视图创建和管理,用于展示特定的内容或功能。

可以将宿主视图看作是一个容器而嵌入式视图是被放置在容器中的内容。宿主视图负责创建和管理嵌入式视图,并提供上下文和数据给嵌入式视图使用。

这段文字的意思是,在Angular中,有两种视图类型:嵌入视图和宿主视图。它们的区别主要在于它们所使用的输入数据以及它们可以附加到的位置。

嵌入视图只能附加到视图容器中,而宿主视图可以附加到任何DOM元素(通常称为宿主元素)。这意味着宿主视图可以在整个应用程序中的不同位置进行渲染,而嵌入视图则受限于容器内部。

以下是一个简单的代码示例,展示了如何创建和使用嵌入视图和宿主视图:

import { Component, ViewContainerRef, ViewChild, TemplateRef } from '@angular/core';

@Component({
  selector: 'app-example',
  template: `
    <div>
      <h1>嵌入视图</h1>
      <ng-template #embeddedView>
        <p>这是一个嵌入视图。</p>
      </ng-template>
      <ng-container *ngTemplateOutlet="embeddedView"></ng-container>
    </div>
    <div>
      <h1>宿主视图</h1>
      <ng-container #hostView></ng-container>
    </div>
  `
})
export class ExampleComponent {
  @ViewChild('hostView', { read: ViewContainerRef }) hostViewContainer: ViewContainerRef;
  @ViewChild('embeddedView', { read: TemplateRef }) embeddedViewTemplate: TemplateRef<any>;

  constructor() {}

  ngAfterViewInit() {
    // 创建宿主视图
    this.hostViewContainer.createEmbeddedView(this.embeddedViewTemplate);
  }
}

在上面的示例中,我们定义了一个组件`ExampleComponent`,包含了一个嵌入视图和一个宿主视图。使用`ViewChild`装饰器,我们获取了对宿主视图容器和嵌入视图模板的引用。

在`ngAfterViewInit`生命周期钩子中,我们通过调用`createEmbeddedView`方法,将嵌入视图模板附加到宿主视图容器中。

这样,当我们在应用程序中使用`ExampleComponent`时,将会同时显示嵌入视图和宿主视图的内容。

嵌入视图是使用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>element 来包装任何 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>;
}

您应该在生命周期挂钩内实现逻辑,ngAfterViewInit因为这是初始化视图查询的时候。此外,对于嵌入视图,您可以定义一个上下文对象,其中包含用于模板内绑定的值。

创建宿主视图

要创建宿主视图,您需要一个组件工厂。要了解有关工厂和动态组件的更多信息,请查看以下是您需要了解的有关 Angular 中的动态组件的信息

在 Angular 中,我们使用该[componentFactoryResolver](https://angular.io/api/core/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);
    }
}

您可以在此处找到创建主机视图的完整示例。

删除视图

附加到视图容器的任何视图都可以使用removedetach方法删除。这两种方法都从视图容器和 DOM 中删除视图。但是,虽然该remove方法会破坏视图,因此以后无法重新附加该视图,但该detach方法会保留视图以便将来重新使用,这对于我接下来将展示的优化技术非常重要。

因此,为了正确解决删除子组件或任何 DOM 元素的任务,有必要首先创建嵌入视图或宿主视图并将其附加到视图容器。完成此操作后,您将能够使用任何可用的 API 方法将其从视图容器和 DOM 中删除。

优化技术

有时您可能需要重复渲染和隐藏模板定义的相同组件或 HTML。在下面的示例中,通过单击不同的按钮,我们将切换组件以显示:

如果我们简单地使用上面学到的方法并将知识放入以下代码中即可实现:

@Component({...})
export class AppComponent {
  show(type) {
    ...
    // a view is destroyed
    this.viewContainer.clear();
    
    // a view is created and attached to a view container      
    this.viewContainer.createComponent(factory);
  }
}

每次单击按钮并执行show方法时,我们都会收到销毁并重新创建视图的不良后果。

createComponent在这个特定的示例中,由于我们使用组件工厂和方法,因此宿主视图被销毁并重新创建。如果我们使用该createEmbeddedView方法和TemplateRef,嵌入视图将被销毁并重新创建:

show(type) {
    ...
    // a view is destroyed
    this.viewContainer.clear();
    
    // a view is created and attached to a view container
    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;

在宿主视图的情况下,可以从 create 方法返回的 ComponentRef 中检索与组件关联的视图。它通过同名属性 hostView 公开。
一旦我们获得了视图,就可以使用 insert 方法将其附加到视图容器中。不再需要显示的其他视图可以使用 detach 方法移除并保留。因此,应该像这样实现切换组件任务的优化解决方案:

showView2() {
    ...
    // Existing view 1 is removed from a view container and the DOM
    this.viewContainer.detach();
    // Existing view 2 is attached to a view container and the DOM
    this.viewContainer.insert(view);
}

再次注意,我们使用detach方法而不是clearorremove来保留视图以供以后重用。您可以在这里找到完整的实现。

创建嵌入视图

如果是基于模板创建的嵌入视图,则直接通过方法返回视图createEmbeddedView

view1: ViewRef;
view2: ViewRef;
ngAfterViewInit() {
    this.view1 = this.t1.createEmbeddedView(null);
    this.view2 = this.t2.createEmbeddedView(null);
}

然后,与前面的示例类似,可以从视图容器中删除一个视图,然后重新附加另一个视图。您再次可以在这里找到完整的实现。

有趣的是,视图创建方法createEmbeddedViewcreateComponent视图容器都返回对所创建视图的引用。

猜你喜欢

转载自blog.csdn.net/dongnihao/article/details/134626128