CHARLIE SAYS

查理如是说
DATE 2026-08-24
THEME
SERIES / ANGULAR / P-180 · Angular 高级教程

Angular 22+ 教程 15:动态组件 Dynamic Components

多数场景下,模板里写什么组件在编译期就确定了。但总有一类需求——组件类型来自运行时数据:仪表盘配置、多步向导、弹窗内容、插件化的渲染器。本文梳理 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.finishedoutput() 返回的 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 idleon immediateon interactionon hoveron viewporton timerwhen,另支持 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() 名称一致,拼错只会在运行时静默丢失。

系列导航

← 算法 015:基数排序(Radix Sort) 目录 设计模式 015:代理(Proxy) →
← 返回文章列表