多数场景下,模板里写什么组件在编译期就确定了。但总有一类需求——组件类型来自运行时数据:仪表盘配置、多步向导、弹窗内容、插件化的渲染器。本文梳理 v22 下动态组件的四种方案、各自边界,以及用 injectAsync + 动态 import() 把重组件挡在主 bundle 之外的完整做法。
先做选型
| 方案 | 适用场景 | 特点 |
|---|---|---|
@if / @switch 静态分支 | 类型编译期已知、数量有限 | 最简单,无懒加载 |
@defer | 类型已知,但要懒加载/占位/兜底 | 声明式,自动代码切分 |
NgComponentOutlet | 组件类型来自数据(注册表、配置) | 声明式,支持 inputs / 投影 |
ViewContainerRef.createComponent | 完全命令式控制(弹窗、分层面板、动态步骤) | 最灵活,需手动管理生命周期 |
选择顺序建议:能静态就静态;要懒加载用 @defer;类型真的来自数据再上 NgComponentOutlet;需要命令式编排才用 createComponent。
NgComponentOutlet:声明式出口
import { NgComponentOutlet } from '@angular/common';
import { Component, Type, input, signal } from '@angular/core';
@Component({
selector: 'app-dashboard',
imports: [NgComponentOutlet],
template: `
<ng-container [ngComponentOutlet]="panel()"
[ngComponentOutletInputs]="{ range: range() }" />
`,
})
export class DashboardComponent {
panel = signal<Type<unknown>>(SalesPanelComponent);
range = input<'week' | 'month'>('week');
}
要点:
ngComponentOutletInputs(v17.1 起)支持响应式绑定,inputs 变化会同步到动态组件。- 还提供
ngComponentOutletContent(传入投影节点)与ngComponentOutletInjector(自定义注入器)两个输入,覆盖进阶需求。 - 组件类型来自静态注册表时(如
Record<string, Type<unknown>>),这是最干净的表达。
createComponent:命令式创建
当创建时机、插入位置、销毁时机都由代码控制时,用 ViewContainerRef.createComponent:
import {
Component, ComponentRef, EnvironmentInjector, Type,
inject, input, viewChild,
} from '@angular/core';
@Component({
selector: 'app-wizard',
template: `<ng-container #host />`,
})
export class WizardComponent {
private host = viewChild.required('host', { read: ViewContainerRef });
private envInjector = inject(EnvironmentInjector);
steps: Type<unknown>[] = [StepAComponent, StepBComponent, StepCComponent];
current = signal(0);
next() {
const vcr = this.host(); // 用户点击时视图早已就绪
vcr.clear();
const step = this.steps[this.current()];
const ref = vcr.createComponent(step, {
environmentInjector: this.envInjector,
});
ref.setInput('stepIndex', this.current() + 1);
ref.instance.finished.subscribe(() => this.goNext());
}
private goNext() {
if (this.current() < this.steps.length - 1) {
this.current.set(this.current() + 1);
this.next();
}
}
}
工程要点:
- standalone 时代
createComponent必须提供environmentInjector(通常注入当前环境的),否则动态组件无法解析 root 级服务。 ref.setInput()走的是正式输入通道,会正确触发组件的输入绑定;直接改ref.instance.xxx绕过了变更检测语义,尽量避免。ref.instance.finished是output()返回的OutputEmitterRef,可以像订阅 Observable 一样subscribe;在组件销毁前记得退订或用takeUntilDestroyed。- 清理:
vcr.clear()或ref.destroy()。命令式创建的资源不会被框架自动回收,这是它比声明式方案多出的心智负担。
@defer:懒加载优先方案
如果只是”组件重、想按需加载”,@defer 比手写动态组件简单得多:
@defer (when chartVisible()) {
<app-heavy-chart [data]="series()" />
} @placeholder (minimum 500ms) {
<div class="chart-skeleton">图表区域</div>
} @loading (minimum 200ms) {
<app-spinner label="图表生成中" />
} @error {
<p>图表加载失败,请重试</p>
}
触发器(可组合):on idle、on immediate、on interaction、on hover、on viewport、on timer、when,另支持 prefetch 单独控制预取时机。被 @defer 包裹的组件及其依赖会自动切分为独立 chunk,与 @if 的区别是它同时管理”加载”这件生命周期。
injectAsync + 动态 import:懒加载重组件
场景:报表页要用的图表组件打包体积巨大,只有进入管理后台的用户才会用到。用 injectAsync 配合动态 import(),让组件类型在需要时才下载:
import {
Component, Type, injectAsync, onIdle, signal,
} from '@angular/core';
@Component({
selector: 'app-admin-report',
imports: [NgComponentOutlet, SpinnerComponent],
template: `
@if (chartType(); as chart) {
<ng-container [ngComponentOutlet]="chart"
[ngComponentOutletInputs]="{ data: rows() }" />
} @else {
<app-spinner label="加载图表引擎" />
}
`,
})
export class AdminReportComponent {
rows = input.required<ReportRow[]>();
chartType = signal<Type<unknown> | null>(null);
private loadChart = injectAsync(
() => import('./heavy-chart.component').then(m => m.HeavyChartComponent),
{ prefetch: onIdle },
);
constructor() {
this.loadChart.then(type => this.chartType.set(type));
}
}
三个细节:
- 动态
import()让HeavyChartComponent及其依赖(图表库、d3 等)进入独立 chunk,不进主 bundle。 prefetch: onIdle表示浏览器空闲时就开始预取,用户真正看到页面时几乎零等待;不指定 prefetch 则保持惰性,直到被 await 时才加载。injectAsync必须在注入上下文中调用(字段初始化器或构造器),返回 Promise;它把”懒加载”纳入了 DI 体系,而不是裸用import()。
服务依赖同样适用这个模式:
@Service()
export class ReportService {
private loadExporter = injectAsync(
() => import('./pdf/pdf-exporter').then(m => m.PdfExporter),
{ prefetch: onIdle },
);
async export(rows: ReportRow[]) {
const Exporter = await this.loadExporter;
return new Exporter().render(rows);
}
}
与路由懒加载的分工
| 机制 | 粒度 | 触发 |
|---|---|---|
路由 loadComponent / loadChildren | 页面级 | 导航到路由 |
@defer | 模板块级 | 触发器条件满足 |
injectAsync + 动态 import() | 组件/服务/依赖级 | 代码显式 await |
三者互不冲突:路由管页面切分,@defer 管视口内懒渲染,injectAsync 管代码级懒依赖。
常见坑
- 忘传
environmentInjector:动态组件注入服务时抛NullInjectorError。 - 命令式创建不销毁:反复
createComponent而不clear(),会累积视图与订阅,引发内存泄漏(参见第 27 篇)。 - 在 effect 或构造器同步代码里读取
viewChild.required宿主位置:视图尚未渲染,required 查询会抛错;把创建动作放到交互回调或afterNextRender中。 - 用
NgComponentOutlet传 inputs 时键名必须与目标组件的input()名称一致,拼错只会在运行时静默丢失。