如果你在 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–v22 | Zoneless 稳定、新 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 + bootstrapModule | bootstrapApplication(App, { providers }) |
BrowserModule | 内建,无需导入 |
HttpClientModule | 默认可用,按需 provideHttpClient(...) 配置 |
AppRoutingModule / RouterModule.forRoot | provideRouter(routes, ...features) |
FormsModule / ReactiveFormsModule | 直接在组件 imports 中按需引入 |
BrowserAnimationsModule | provideAnimationsAsync() |
模块 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。
手工迁移一个模块的步骤(理解原理比跑工具更重要):
- 把
declarations里的每个组件/指令/管道改为 standalone,将原模块imports的内容下沉到各组件的imports providers里的服务改为@Service()或providedIn: 'root'- 路由里的
loadChildren模块加载改为 Routes 加载 - 删除模块文件,修正引用
- 跑测试与 build,处理”原来隐式可用、现在需要显式导入”的编译错误
实践建议:从叶子模块(SharedModule、小组件模块)开始迁移,根模块最后动;每迁移一块就提交一次,不要攒大爆炸。
什么时候还会见到 NgModule
v22 中 NgModule 并没有被移除,这些场景仍然真实存在:
- 遗留企业代码:大型应用渐进迁移中,Standalone 与 NgModule 可以共存(Standalone 组件可以出现在 NgModule 的 imports 里,反之亦然)
- 旧版本三方库:还没迁移的组件库以 NgModule 形式导出,兼容层会继续工作
- TestBed:
TestBed.configureTestingModule({ imports: [...] })仍然是单测里组织测试环境的惯用形态——它是动态创建的测试模块,与新写法(TestBed.runInInjectionContext等)并行不悖 - 确认模块级服务作用域的旧设计:显式
standalone: false保留模块语义
识别旧代码的心智模型依然有价值:看到 declarations 就知道那是编译作用域声明,看到模块 providers 就知道那是模块级单例。