CHARLIE SAYS

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

Angular 22+ 教程 28:NgModule 到 Standalone 的演进

如果你在 2020 年学过 Angular,你写的第一个文件很可能是 AppModule。而今天(v22)新建的应用里已经找不到任何 @NgModule。这段演进跨越了 Angular 的整个现代史,理解它,既是为了写好新代码,也是为了看懂大量仍在服役的旧代码。

NgModule 的历史使命

Angular 2.0(2016)用 NgModule 同时解决了三个问题:

  • 编译作用域declarations 数组声明”本模块有哪些组件/指令/管道”,模板里能用的标签由所属模块的编译上下文决定
  • 依赖注入的组织providers 数组集中注册服务,模块级单例是那个时代的主流作用域
  • 打包与懒加载单元loadChildren 按模块切分代码

一个典型的旧时代功能模块长这样:

@NgModule({
  declarations: [UserListComponent, UserDetailComponent],
  imports: [CommonModule, FormsModule, SharedModule],
  exports: [UserListComponent],
  providers: [UserService],
})
export class UserModule {}

根模块还要负责引导启动:

@NgModule({
  imports: [BrowserModule, HttpClientModule, AppRoutingModule],
  declarations: [AppComponent],
  bootstrap: [AppComponent],
})
export class AppModule {}

platformBrowserDynamic().bootstrapModule(AppModule);

这套模型的痛点随时间暴露:

  • 间接性:组件”能用什么”取决于它被声明在哪个模块、那个模块导入了什么。模板报”不是已知元素”时,排查链条很长
  • 树摇杀手:模块导入是粗粒度的,没用到的指令/管道也会进 bundle
  • 样板代码:每个功能至少一个 module 文件,多数只是转发 imports

Standalone 的默认化过程

Standalone 组件的演进是一条清晰的路线:

版本变化
v14(2022)引入 standalone: true,组件自带 imports,可脱离 NgModule 使用
v15官方迁移 schematics,provideRouter 等函数式 provider 就绪
v17(2023)新应用脚手架默认 Standalone,不再生成 NgModule
v19(2024)standalone: true 成为默认值——不写这个字段就是 Standalone,要 NgModule 需显式 standalone: false
v21–v22Zoneless 稳定、新 API 全部按 Standalone 设计;NgModule 进入维护状态(仍支持,不再演进)

Standalone 的写法

组件的 imports 数组

指令、管道、组件直接声明在组件的 imports 里,作用域精确到”这个组件的模板”:

import { Component, input } from '@angular/core';
import { DatePipe } from '@angular/common';
import { UserCardComponent } from './user-card.component';

@Component({
  selector: 'app-user-list',
  imports: [UserCardComponent, DatePipe],
  template: `
    <app-user-card *appFor="let user of users()" [user]="user" />
    <p>更新于 {{ updatedAt() | date:'HH:mm' }}</p>
  `,
})
export class UserListComponent {
  readonly users = input.required<User[]>();
  readonly updatedAt = input<Date>();
}

好处直接可见:模板能用的东西就在眼前;UserCardComponent 没被导入就不可用;没导入的内容不进 bundle。

函数式 provider 替代 AppModule

根模块的各项职责全部由函数式 provider 接管,bootstrapApplication 直接引导根组件:

import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes),          // 路由(替代 AppRoutingModule)
    // provideHttpClient() v21 起非必需,仅配置拦截器/XHR 后端时使用
    provideAnimationsAsync(),       // 动画(按需)
  ],
});

常见职责对照:

NgModule 时代Standalone 时代
AppModule + bootstrapModulebootstrapApplication(App, { providers })
BrowserModule内建,无需导入
HttpClientModule默认可用,按需 provideHttpClient(...) 配置
AppRoutingModule / RouterModule.forRootprovideRouter(routes, ...features)
FormsModule / ReactiveFormsModule直接在组件 imports 中按需引入
BrowserAnimationsModuleprovideAnimationsAsync()
模块 providers: [UserService]@Service() / @Injectable({ providedIn: 'root' })
SharedModule(公共指令管道)导出 standalone 指令/管道,使用方按需 imports

顺带一提 v22 的服务写法:@Service() 装饰器等价于 @Injectable({ providedIn: 'root' }),且强制使用 inject() 注入,是新的默认风格:

@Service()
export class UserService {
  private readonly http = inject(HttpClient);
}

懒加载形态的变化

NgModule 时代用 loadChildren: () => import('./x.module').then(m => m.XModule) 按模块懒加载;Standalone 时代直接懒加载路由配置,粒度更细:

// 单组件
{ path: 'profile', loadComponent: () => import('./profile.component').then(m => m.ProfileComponent) }

// 整棵子树(一个 Routes 数组)
{ path: 'admin', loadChildren: () => import('./admin.routes').then(m => m.ADMIN_ROUTES) }

不再需要为了懒加载而”造一个模块装组件”。

迁移策略

官方路径是 ng update 驱动的自动化迁移,跨多个大版本时它会分步执行:

ng update @angular/core

迁移 schematics 会处理大部分机械工作:移除 standalone: true 冗余标注、把 bootstrapModule 改为 bootstrapApplication、将无状态 NgModule 展开为函数式 provider、把声明迁移到组件 imports

手工迁移一个模块的步骤(理解原理比跑工具更重要):

  1. declarations 里的每个组件/指令/管道改为 standalone,将原模块 imports 的内容下沉到各组件的 imports
  2. providers 里的服务改为 @Service()providedIn: 'root'
  3. 路由里的 loadChildren 模块加载改为 Routes 加载
  4. 删除模块文件,修正引用
  5. 跑测试与 build,处理”原来隐式可用、现在需要显式导入”的编译错误

实践建议:从叶子模块(SharedModule、小组件模块)开始迁移,根模块最后动;每迁移一块就提交一次,不要攒大爆炸。

什么时候还会见到 NgModule

v22 中 NgModule 并没有被移除,这些场景仍然真实存在:

  • 遗留企业代码:大型应用渐进迁移中,Standalone 与 NgModule 可以共存(Standalone 组件可以出现在 NgModule 的 imports 里,反之亦然)
  • 旧版本三方库:还没迁移的组件库以 NgModule 形式导出,兼容层会继续工作
  • TestBedTestBed.configureTestingModule({ imports: [...] }) 仍然是单测里组织测试环境的惯用形态——它是动态创建的测试模块,与新写法(TestBed.runInInjectionContext 等)并行不悖
  • 确认模块级服务作用域的旧设计:显式 standalone: false 保留模块语义

识别旧代码的心智模型依然有价值:看到 declarations 就知道那是编译作用域声明,看到模块 providers 就知道那是模块级单例。

系列导航

← 算法 028:算法思想知识体系详解 目录 JHipster 开发 28:生产上线 Checklist →
← 返回文章列表