动态渲染视图是日常项目开发中常见的需求,特别是通用性的工具库开发。例如弹窗服务,需要让组件的用户决定传入什么内容进行渲染,可能是一个组件,可能是一个模板(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 不能懒加载。
通知组件写好后,就可以创建并动态插入,具体分为三步:
- 在构造函数中注入
ComponentFactoryResolver
实例:private resolver: ComponentFactoryResolver
。 - 通过
resolver.resolveComponentFactory(AlertComponent)
方法,生成这个通知组件的工厂对象。 - 最后一步,将这个工厂对象传入视图容器的 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); });
从这个上面的绑定事件也可以看出,视图容器可以容纳任意多个视图,根据视图对象可以查询索引,或者销毁,移除,插入,移动任意视图顺序。
动态插入模板
和 createComponent 类似的,通过 createEmbeddedView 就可以插入模板。
首先先创建一个模板示例,这个模板根据上下文对象声明了一个 “param” 属性:
<ng-template #templateView let-param="message">
<section class="template-wrapper">
<span>来自 ng-template 的动态内容</span>
<span>{{param}}</span>
</section>
</ng-template>
要插入一个带上下文数据的模板,具体分为三步:
获取模板的引用对象:
@ViewChild('templateView', { read: TemplateRef }) template!: TemplateRef<any>;
- 声明上下文对象:
templateContext = { message: '来自模板上下文的值' };
通过视图容器的 createEmbeddedView 方法插入模板:
const embeddedViewRef = this.container.createEmbeddedView(this.template, this.templateContext);
方法创建一个 EmbeddedViewRef 对象,并将它放入。通过这个对象,视图容器可以查找它的索引,所以也可以和之前组件视图的引用一样,在容器内移动顺序、移除渲染、或是被销毁。
以指令方式插入
除了使用视图容器的两个方法来创建和插入视图,Angular 还提供了两个指令来简化工作。
ngComponentOutlet
首先,再创建另一个示例组件,和前面的哪个通知组件不同,它没有输入输出属性,但是多了一个需要注入的依赖项,以便演示带依赖注入的组件插入:
@Component({
template: `
<section class="template-wrapper">
<span>来自另一个动态组件:{{param.message}}</span>
</section>`
})
export class AnotherComponent {
constructor(public param: ExampleService) { }
}
使用指令的方式创建组件就简单多了,只需要两步:
引入这个组件类,并赋值给一个属性:
import { AnotherComponent } from '../shared/another-component'; export class ViewContainerExampleComponent implements OnInit, OnDestroy { public anotherComponent = AnotherComponent; }
在视图中声明即可:
<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
模板的指令只有两个输入属性:模板的引用对象、模板的上下文对象。
所以要插入一个带上下文数据的模板,具体步骤如下:
给模板添加引用名:
<ng-template #templateView let-param="message"> <!-- 省略内容 --> </ng-template>
- 声明上下文对象:
templateContext = { message: '来自模板上下文的值' };
传入 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,可以更方便的实现动态视图,我们下篇见!😎
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。