详解 Angular 动态视图 (一) -- 原生 API

494 阅读8分钟

动态渲染视图是日常项目开发中常见的需求,特别是通用性的工具库开发。例如弹窗服务,需要让组件的用户决定传入什么内容进行渲染,可能是一个组件,可能是一个模板(ng-template)。这里先来看看通过 Angular API 如何去实现。

要动态的插入一个视图,Angular 提供了一套 API 负责创建容器,实例化组件,以及管理视图和数据。同时,它还额外提供了一些指令,方便快速的实现。它们各有各自适用场景,本篇详细来看看它们的使用。

完整的示例代码可以查看 GitHub 项目仓库,或者在线查看效果 在线示例

ViewContainerRef 容器

动态视图的核心是视图容器,它决定了视图的插入位置,用 ViewContainerRef 类表示。要创建它很简单,假设组件视图中有:<ng-container #dynamicHost></ng-container> 通过 @ViewChild 装饰器读取即可:

@ViewChild('dynamicHost', { read: ViewContainerRef }) container!: ViewContainerRef;

这段代码的注意点有:

  • @ViewChild 用来从组件视图中获取元素的引用,可以是 DOM 对象、子组件或者指令的实例、或是某个依赖注入的 Provider。
    第二个参数中的 read 指示具体获取哪个类型,例如从 <button mat-button> 可以获取到这个 button 元素对象,也可以是这个标签上添加的 Material 按钮指令。

  • 要获取一个容器,并非只能使用 <ng-container><ng-template> 、组件或者别的 DOM 标签都可以。

  • 虽然是叫容器,不过插入的内容可不是在这个 <ng-container> 标签内,而是在它的下方(类似 <router-outlet>)。所以使用 ng-container 是为了不渲染多余的 DOM。

ViewContainerRef 实例可以创建、插入、移除、移动或是销毁它其中的视图(ViewRef)。视图代表着 Angular中可显示元素的最小分组单位,它由组件或者模板定义。多个视图构造成了 Angular 应用的视图树。

✨简明起见,后文都将 ViewContainerRef 实例称之为 “视图容器”

以代码方式插入

视图容器有两个方法(createComponent,createEmbeddedView),用来动态创建组件视图和模板视图。

动态插入组件

首先创建一个用于动态插入的组件,这个通知组件省略了具体样式,详情可以查看源码。可以注意到,这个组件有一个输入属性,一个输出事件。以便演示动态创建的组件,如何和外界交互。

@Component({
    template: `
    <div class="alert-container mat-elevation-z2" [class]="classConfig()">
        <span class="message">{{message}}</span>
        <button mat-button (click)="emitCloseEvent()">关闭</button>
    </div>`
})
export class AlertComponent {
    @Input() message = '空消息提示';
    @Input() type = 'success';
    @Output() closeAlert = new EventEmitter();
    classConfig() {
        return {
            success: this.type === 'success',
            warning: this.type === 'warning'
        };
    }

    const

    emitCloseEvent(): void {
        this.closeAlert.emit();
    }
}

✨注意:Angular 9 后的版本默认使用 Ivy 编译器,如果是使用老版本编译器,这个需要动态插入的通知组件,需要在 Module 的 entryComponents 中声明,并且这个 Module 不能懒加载。

通知组件写好后,就可以创建并动态插入,具体分为三步:

  1. 在构造函数中注入ComponentFactoryResolver 实例:private resolver: ComponentFactoryResolver
  2. 通过 resolver.resolveComponentFactory(AlertComponent) 方法,生成这个通知组件的工厂对象。
  3. 最后一步,将这个工厂对象传入视图容器的 createComponent 方法:
    this.const componentRef = container.createComponent(factory)

通过 createComponent 方法,就可以将这个组件插入到视图中了,并返回这个组件实例。

有了组件实例,和这个通知组件交互也就不成问题了:

  • 输入属性传值:componentRef.instance.message = "外部传入的警告信息"

  • 绑定输出事件:

    componentRef.instance.closeAlert.subscribe(() => {
      const index = this.container.indexOf(componentRef.hostView);
      this.container.remove(index);
    });
    

从这个上面的绑定事件也可以看出,视图容器可以容纳任意多个视图,根据视图对象可以查询索引,或者销毁,移除,插入,移动任意视图顺序。

angular-dynamic-example-1.png

动态插入模板

和 createComponent 类似的,通过 createEmbeddedView 就可以插入模板。

首先先创建一个模板示例,这个模板根据上下文对象声明了一个 “param” 属性:

<ng-template #templateView let-param="message">
    <section class="template-wrapper">
        <span>来自 ng-template 的动态内容</span>
        <span>{{param}}</span>
    </section>
</ng-template>

要插入一个带上下文数据的模板,具体分为三步:

  1. 获取模板的引用对象:

    @ViewChild('templateView', { read: TemplateRef }) template!: TemplateRef<any>;
    
  2. 声明上下文对象:templateContext = { message: '来自模板上下文的值' };

  3. 通过视图容器的 createEmbeddedView 方法插入模板:

    const embeddedViewRef = this.container.createEmbeddedView(this.template, this.templateContext);
    

方法创建一个 EmbeddedViewRef 对象,并将它放入。通过这个对象,视图容器可以查找它的索引,所以也可以和之前组件视图的引用一样,在容器内移动顺序、移除渲染、或是被销毁。

angular-dynamic-example-2.png

以指令方式插入

除了使用视图容器的两个方法来创建和插入视图,Angular 还提供了两个指令来简化工作。

ngComponentOutlet

首先,再创建另一个示例组件,和前面的哪个通知组件不同,它没有输入输出属性,但是多了一个需要注入的依赖项,以便演示带依赖注入的组件插入:

@Component({
    template: `
    <section class="template-wrapper">
        <span>来自另一个动态组件:{{param.message}}</span>
    </section>`
})
export class AnotherComponent {
    constructor(public param: ExampleService) { }
}

使用指令的方式创建组件就简单多了,只需要两步:

  1. 引入这个组件类,并赋值给一个属性:

    import { AnotherComponent } from '../shared/another-component';
    export class ViewContainerExampleComponent implements OnInit, OnDestroy {
         public anotherComponent = AnotherComponent;
    }
    
  2. 在视图中声明即可:

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

传入依赖注入器

正常情况下,这样就把组件插入指定位置了,不过如果动态组件所声明的依赖项,需要由这个组件本身提供呢?

这里就要再给这个组件传入注入对象:
<ng-container *ngComponentOutlet="anotherComponent;injector:costumeInjector">

这个 costumeInjector 可以通过 Injector 类的静态方法创建:

constructor(
    injector: Injector
) {
    this.costumeInjector = Injector.create({ 
        providers: [{ provide: ExampleService, deps: [] }], 
        parent: injector 
    });
}

这样一来,每个动态创建的组件,都会拥有一个独立的 ExampleService 实例。

✨ 视图容器的 createComponent 方法同样可以指定依赖注入器,效果是一样的,前面只是为了简明而省略。

当然,常见的情况依旧是给 ExampleService 的装饰器声明为全局服务:@Injectable({ providedIn:'root'})

传入内容映射

除了可以指定注入器,还可以传入内容映射。

先给组件做一点小修改,新增一个 <ng-content> 标记,使得这个组件可以接收外部内容映射:

<section class="template-wrapper">
    <span>来自另一个动态组件:{{param.message}}</span>
    <ng-content></ng-content>
</section>`

要插入映射的 DOM 内容,只需要额外给指令的表达式再传一个参数:<ng-container *ngComponentOutlet="anotherComponent;content:costumeContent">

这个 costumeContent 是一个数组,因为组件内可以有多个 ng-content。数组内每项也是一个数组,因为每个 ng-content 位置,可以插入多个 DOM 内容块。

const spanContent = document.createElement('span');
const divContent = document.createElement('div');
spanContent.innerHTML = 'hello, world';
divContent.innerHTML = '<span>locotor</span>';
this.costumeContent = [[spanContent, divContent]];

ngTemplateOutlet

模板的指令只有两个输入属性:模板的引用对象、模板的上下文对象。

所以要插入一个带上下文数据的模板,具体步骤如下:

  1. 给模板添加引用名:

    <ng-template #templateView let-param="message">
         <!-- 省略内容 -->
    </ng-template>
    
  2. 声明上下文对象:templateContext = { message: '来自模板上下文的值' };

  3. 传入 ngTemplateOutlet 指令中:

    <ng-container *ngTemplateOutlet="templateView; context: templateContext"></ng-container>
    

对比一下

前面介绍了两种插入视图的方式,效果都是类似的,但是也有些许不同之处。了解它们的差异,才能根据场景使用合适的实现方式。

指令和 ViewContainerRef 对象实例的差异主要有两个:

  • 多视图:相比指令的方式来插入视图,通过 ViewContainerRef 的创建方法,在一个视图容器中,可以创建任意多个视图。也因此,通过视图容器还具有对视图的管理能力,例如将某个视图移到容器的顶部,或是销毁某一个视图及其相关数据。

  • 组件实例:通过 createComponent 方法插入组件视图的同时,还可以得到这个组件类的实例。有了它,就可以给组件传参,或是注册它的输出事件。

除了上述差异,其他的地方都是相同的。对比一下插入组件时,使用指令的方式:

<ng-container *ngComponentOutlet="componentTypeExpression;
                                  injector: injectorExpression;
                                  content: contentNodesExpression;
                                  ngModuleFactory: moduleFactory;">
</ng-container>

可以指定要插入组件类,组件的注入器,映射内容,以及模块工厂对象(允许动态加载其他模块)。

所对应的代码方式插入:

createComponent<C>(componentFactory: ComponentFactory<C>, index?: number, injector?: Injector, projectableNodes?: any[][], ngModule?: NgModuleRef<any>): ComponentRef<C>

除了第二个参数是指定插入到容器的顺序序号以外,其他的参数都是一一对应的。

和组件的情况类似,模板插入除了视图容器支持多个模板以外,可以支持指定插入序号外,和指令的方式完全一样的。都是两个参数,一个是模板引用对象,一个是模板上下文对象。

总结

本篇总结了 Angular API 实现动态视图插入的方式。ViewContainerRef 可以支持任意多个视图的插入,对它们进行管理。它插入的组件可以拿到组件实例,能够执行输入输出交互。指令的方式可以便捷的插入视图,但是只能在一个容器内插入一个视图,对组件的输入输出交互支持不足。

不过这都是在 Angular 上下文环境中的动态视图,如果是要插入到 Angular 应用的外部呢(例如常见的弹窗,内容和遮罩插入 <body> 元素下)?插入后又将如何和 Angular 的组件通信?

所以可以看到,通过 Angular 原生 API 已经可以实现动态视图功能,不过还没有解决在 Angular 应用外插入内容的需求,并且如果能结合指令式的便捷,再兼顾组件交互就好了。好在 Material 开发组还提供了一套 Angular CDK(组件开发套件),它的 Portal 模块,封装了原生 API,可以更方便的实现动态视图,我们下篇见!😎