首页
/ 深入理解 Angular NgModule:声明与导出、providers、forRoot/forChild 及应用引导

深入理解 Angular NgModule:声明与导出、providers、forRoot/forChild 及应用引导

2026-09-06 18:29:29作者:宣利权Counsellor

本文围绕 Angular 官方仓库(本仓库)中的 NgModules 概览指南 编写。该指南面向需要理解存量 @NgModule 代码的开发者,覆盖装饰器元数据、declarations/imports/exports 的语义、模块级 providers 的作用域、forRoot/forChild 约定以及基于 NgModule 的应用引导流程。读完本文,你将能读懂并维护任何基于 NgModule 的 Angular 应用,并清楚它与新时代 standalone components 的关系。文中所有实现层面的论断均可在本仓库源码中找到对应文件与行号作为依据。

为什么还要学习 NgModule

重要前提:Angular 官方团队明确建议,所有新代码优先使用 standalone components(独立组件),而不是 NgModule。本指南(及本文)的意义在于帮助你理解现存代码库中已经使用 @NgModule 构建的部分,例如历史项目、第三方库模块或 RouterModuleHttpClientModule 这类内置模块的 API。

在开始之前先建立一个全局图景:即便新代码改用 standalone 组件,Angular 自身的模块化语义(声明归属、可见性作用域、provider 注入器层级)依然在编译器和运行时中起着核心作用。理解了 NgModule 的这些概念,也就理解了 Angular 编译与依赖注入的底层模型。

NgModule 的本质:带元数据的类

NgModule 是一个用 @NgModule 装饰器标记的类。装饰器接收一段元数据(metadata),这些元数据告诉 Angular 两件事:如何编译组件模板,以及如何配置依赖注入。

import {NgModule} from '@angular/core';

@NgModule({
  // Metadata goes here
})
export class CustomMenuModule {}

对应到源码层面,NgModule 接口在 packages/core/src/metadata/ng_module.ts 中被定义,它显式声明的可选属性包括:

元数据属性 类型 作用
declarations Array<Type<any> | any[]> 声明本模块拥有的组件、指令、管道
imports Array<Type<any> | ModuleWithProviders<{}> | any[]> 引入其他模块或其导出的可声明对象
exports Array<Type<any> | any[]> 导出声明,使其对导入本模块的组件/模块可见
providers Array<Provider | EnvironmentProviders> 为注入器提供可注入依赖
bootstrap Array<Type<any> | any[]> 模块被引导时启动的组件集合
schemas Array<SchemaMetadata | any[]> 声明允许出现在模板中的元素/属性(NO_ERRORS_SCHEMACUSTOM_ELEMENTS_SCHEMA
id string NgModule 的唯一标识,供 getNgModuleById 查询
jit true 存在时该模块被 AOT 编译器忽略,由运行时 JIT 编译

该文件的末尾也揭示了装饰器的真实面目:NgModule 通过 makeDecorator('NgModule', ...) 创建,在类被装饰时会调用 compileNgModule(type, meta)(见 packages/core/src/metadata/ng_module.ts),从而把元数据“编译”成模块定义。

从职责上说,一个 NgModule 有两项主要责任:

  1. 声明属于该 NgModule 的组件、指令与管道;
  2. 为导入该 NgModule 的组件、指令与管道所在的注入器提供 providers

Declarations:定义模块的"成员清单"

@NgModule 元数据中的 declarations 属性声明了属于该模块的组件、指令和管道:

@NgModule({
  /* ... */
  // CustomMenu and CustomMenuItem are components.
  declarations: [CustomMenu, CustomMenuItem],
})
export class CustomMenuModule {}

上面的示例中,CustomMenuCustomMenuItem 两个组件归属于 CustomMenuModule

支持嵌套数组

declarations 还接受组件、指令、管道的 数组,而这些数组本身还可以再包含其他数组:

const MENU_COMPONENTS = [CustomMenu, CustomMenuItem];
const WIDGETS = [MENU_COMPONENTS, CustomSlider];

@NgModule({
  /* ... */
  // This NgModule declares all of CustomMenu, CustomMenuItem,
  // CustomSlider, and CustomCheckbox.
  declarations: [WIDGETS, CustomCheckbox],
})
export class CustomMenuModule {}

这一设计在大型模块中很有用:可以把一组高度相关的组件预先聚合到一个常量数组中,再整体放进 declarations,方便复用与重组。从类型定义看,declarations?: Array<Type<any> | any[]> 本身允许元素是“数组”,而数组中又可以递归出现数组,因此 Angular 会递归展平它们。

唯一归属规则:一个声明只能属于一个模块

如果 Angular 发现某个组件、指令或管道被多于一个 NgModule 声明,就会报告错误。这一约束在接口的 JSDoc 中有明确说明:"Declarables must belong to exactly one module. The compiler emits an error if you try to declare the same class in more than one module."(packages/core/src/metadata/ng_module.ts)。反过来说,不要把一个已被某模块导入的类再次声明到别的模块里——同一类同时出现在两个模块的 declarations 中即构成错误。

与 standalone 的边界:standalone: false

要想把一个组件、指令或管道放进 NgModule 的 declarations,它必须被显式标记为 standalone: false。Angular 中新建的组件默认是 standalone(standalone: true),而 standalone 对象只能被导入(imports),不能被声明(declarations)。

@Component({
  // Mark this component as `standalone: false` so that it can be declared in an NgModule.
  standalone: false,
  /* ... */
})
export class CustomMenu {
  /* ... */
}

换言之,从 Angular 15+ 开始,“能否被 NgModule 声明”是一个显式的、写在组件装饰器上的契约,而不是默认行为。对存量 NgModule 体系而言,这意味着所有加入 declarations 的可声明对象都需要确认其 standalone 取值。

imports:引入模板所需的外部依赖

声明在 NgModule 中的组件,其模板可能会依赖其他组件、指令和管道(例如别的模块导出的指令,或某个 standalone 组件)。这些依赖需要被加入 @NgModule 元数据的 imports 属性:

@NgModule({
  /* ... */
  // CustomMenu and CustomMenuItem depend on the PopupTrigger and SelectorIndicator components.
  imports: [PopupTrigger, SelectionIndicator],
  declarations: [CustomMenu, CustomMenuItem],
})
export class CustomMenuModule {}

imports 数组接受两类内容:

  • 其他 NgModule(如 CommonModuleRouterModule);
  • standalone 的组件、指令和管道

这里值得说明 Angular 的“作用域”模型:在运行时内部,每个模块会被计算成一组传递性编译作用域(transitive compile scopes),包含 compilation 作用域与 exported 作用域。compilation 是该模块自己声明的组件模板中能识别到的指令/管道集合;exported 是被该模块导出的集合——当模块 A 导入模块 B 时,B 的 exported 集合就会被并入 A 的 compilation 集合。这一内部结构定义在 packages/core/src/metadata/ng_module_def.tsNgModuleTransitiveScopes 中,字段结构如下:

export interface NgModuleTransitiveScopes {
  compilation: {directives: Set<any>; pipes: Set<any>};
  exported: {directives: Set<any>; pipes: Set<any>};
  schemas: SchemaMetadata[] | null;
}

imports 看作"把别人导出的指令/管道合并进我的可见范围",就能直观理解为什么导入关系可以传递。

exports:把能力对外开放

NgModule 可以 导出(export) 自己声明的组件、指令和管道,使它们对导入本模块的其他组件与模块可用:

@NgModule({
  imports: [PopupTrigger, SelectionIndicator],
  declarations: [CustomMenu, CustomMenuItem],

  // Make CustomMenu and CustomMenuItem available to
  // components and NgModules that import CustomMenuModule.
  exports: [CustomMenu, CustomMenuItem],
})
export class CustomMenuModule {}

默认情况下,模块内声明的对象是私有的:只有该模块内部的组件能使用它们。exports 是模块的“公共 API 面”。源码 JSDoc 对此的描述是:"Exported declarations are the module's public API"(packages/core/src/metadata/ng_module.ts)。

exports 并不局限于模块自己的声明,还可以导出模块所导入的任意组件、指令、管道或 NgModule:

@NgModule({
  imports: [PopupTrigger, SelectionIndicator],
  declarations: [CustomMenu, CustomMenuItem],

  // Also make PopupTrigger available to any component or NgModule that imports CustomMenuModule.
  exports: [CustomMenu, CustomMenuItem, PopupTrigger],
})
export class CustomMenuModule {}

这个特性的常见用法是"聚合再导出"(barrel):例如 SharedModule 同时 imports: [CommonModule, FormsModule]exports: [CommonModule, FormsModule],那么任何导入 SharedModule 的模块都无需再单独导入 CommonModule。当模块 A 导出它导入的模块 B 时,B 的所有导出也一并可用——这正是经典 SharedModule 模式的底层原理。

NgModule 的 providers:注入器的"供给端"

NgModule 可以为其注入依赖指定 providers。在源码中,providers 的类型为 Array<Provider | EnvironmentProviders>packages/core/src/metadata/ng_module.ts),其中 Provider 涵盖 TypeProviderValueProviderClassProviderConstructorProviderExistingProviderFactoryProvider,即依赖注入所支持的全部 provider 形态(见 packages/core/src/di/interface/provider.ts)。

一个 NgModule 提供的依赖对以下对象可用:

  • 任何导入了该 NgModule 的 standalone 组件、指令或管道
  • 任何导入了该 NgModule 的其他 NgModuledeclarationsproviders
@NgModule({
  imports: [PopupTrigger, SelectionIndicator],
  declarations: [CustomMenu, CustomMenuItem],

  // Provide the OverlayManager service
  providers: [OverlayManager],
  /* ... */
})
export class CustomMenuModule {}

@NgModule({
  imports: [CustomMenuModule],
  declarations: [UserProfile],
  providers: [UserDataClient],
})
export class UserProfileModule {}

上面示例中发生的事实:

  • CustomMenuModule 提供 OverlayManager
  • CustomMenuCustomMenuItem 之所以能注入 OverlayManager,是因为它们声明在 CustomMenuModule 中;
  • UserProfile 能注入 OverlayManager,因为它所在的 UserProfileModule 导入了 CustomMenuModule
  • UserDataClient 也能注入 OverlayManager,同样是因为其模块导入了 CustomMenuModule

需要注意的是模块 provider 的注入器归属:模块级 provider 并不进入组件注入器层级,而是落在与模块关联的注入器上。对于根模块/被引导模块,provider 会进入应用根注入器;对于懒加载模块,则会建立属于该模块的子注入器,从而让懒加载特性拥有自己的服务实例,与根注入器中的同名服务隔离。源码对此有一段非常直观的说明:"A lazy-loaded module has its own injector, typically a child of the app root injector. Lazy-loaded services are scoped to the lazy-loaded module's injector."(packages/core/src/metadata/ng_module.ts)。

提示:关于依赖注入与 provider 的更完整讨论,参见 依赖注入指南(分层 DI)依赖注入指南(创建与使用服务)

forRoot 与 forChild:带配置的 providers 约定

有些 NgModule 会定义静态方法 forRoot,它接收一些配置并返回一个 providers 数组(严格来说是返回带 provider 的模块包装对象)。“forRoot”是一个约定名称,用于表达:这些 providers 意图在应用启动期间、被唯一地添加到应用的根部(root)

例如,在一个基于 standalone 引导的应用中,可以通过 providers 数组把 forRoot() 的结果注入根环境:

bootstrapApplication(MyApplicationRoot, {
  providers: [CustomMenuModule.forRoot(/* some config */)],
});

提示:bootstrapApplication 是新代码推荐的引导 API,相关内容可参考 platform-browser 的引导 API 以及组件指南。forRoot 返回类型 ModuleWithProviders 的接口定义(ngModule + 可选 providers)在 packages/core/src/di/interface/provider.ts

同样地,某些 NgModule 还会定义静态方法 forChild,表示这些 providers 被预期添加给应用层级中的某些组件

@Component({
  /* ... */
  providers: [CustomMenuModule.forChild(/* some config */)],
})
export class UserProfile {
  /* ... */
}

为什么要有 forRoot / forChild 之分

两个方法名的差异背后是 Angular 注入器的层级特性与**“服务只需初始化一次”**原则:

  • forRoot 中返回的 providers 只应在根注入器中出现一次,例如全局唯一的 Router、全局唯一的连接配置等;
  • forChild 返回的通常只是"指令/路由的声明",不会重复创建全局服务。

Angular 自带的 RouterModule 是这一约定最典型的实现,其源码位于 packages/router/src/router_module.ts。源码注释明确说明:

  • forRoot() 创建包含全部指令、给定路由以及 Router 服务本身 的 NgModule;
  • forChild() 创建的 NgModule 只包含指令与路由,不包含 Router 服务

更妙的是,RouterModule 通过一个名为 ROUTER_FORROOT_GUARD 的注入令牌实现了"防重复 forRoot"守卫(packages/router/src/router_module.ts):当同一个 Router 被提供不止一次时,会抛出 RuntimeError(错误码 FOR_ROOT_CALLED_TWICE),错误信息会提示"懒加载模块应该使用 RouterModule.forChild() 代替"(packages/router/src/router_module.ts)。这个守卫机制本身就是对 forRoot/forChild 约定的一种运行时强制:根级 provider 请用 forRoot,子模块请用 forChild

性能代价:forRoot providers 会被急切加载

通过 forRoot 加入的任何 providers 都会在应用启动时被急切加载(eagerly loaded),从而增加首屏 JavaScript 包体大小。因此:

  • 只把启动即必须存在的服务放入 forRoot(如 Router);
  • 不要滥用 forRoot 把体积很大的业务模块一次性塞进根注入器;
  • 可按需延迟加载的部分,应交给懒加载路由与各特性模块自己的 providers。

引导一个基于 NgModule 的应用

重要前提:对新代码,官方建议使用 bootstrapApplication(配合独立组件)而不是 bootstrapModule。这里的内容是为了帮助理解现存的、以 @NgModule 引导的应用。

@NgModule 装饰器接收一个可选的 bootstrap 数组,其中可以包含一个或多个组件:

import {platformBrowser} from '@angular/platform-browser';

@NgModule({
  bootstrap: [MyApplication],
})
export class MyApplicationModule {}

platformBrowser().bootstrapModule(MyApplicationModule);

执行流程是:通过 platformBrowser()(浏览器端)或 platformServer()(服务端渲染)获取平台实例,再调用其上的 bootstrapModule(MyApplicationModule) 方法启动应用。运行时,Angular 会在页面中查找与 bootstrap 列出的组件 CSS 选择器匹配的 DOM 元素,并把组件渲染到这些位置上。

启动引导需要留意三点:

  1. bootstrap 组件会被自动加入 declarations——你无需在 declarations 里重复列出引导组件;
  2. 收集并急切加载 providers:当你从一个 NgModule 引导应用时,该模块自身的全部 providers 以及它 imports 的所有模块的 providers 都会被收集起来并急切加载,整个应用都可注入它们;
  3. 由于引导模块的 providers 最终进入根注入器,它们天然对所有组件、指令、管道与服务可见——这解释了为什么传统应用中 AppModuleproviders 声明的服务随处可注入。

从源码看,@NgModule 的运行时数据会被记录为 NgModuleDef,其中 declarationsimportsexportsbootstrap 都以数组或"惰性计算函数"的形式存在,并被 compileNgModuleDefs 逐项写入模块类型(见 packages/core/src/render3/jit/module.tspackages/core/src/metadata/ng_module_def.ts)。这也是了解 Angular 内部如何“记住”一个模块的关键入口。

迁移视角:从 NgModule 走向 standalone

回到文档开头的重要提示:虽然本文通篇在讲 NgModule,但 Angular 官方路线是把 standalone 组件作为默认范式,@NgModule 相关能力则逐步退居“兼容存量代码”的角色。如果你正在维护一个新项目并参考本文时,请遵循以下判断:

  • 新代码:一律使用 standalone 组件,并通过 imports 直接引用所需的组件/指令/管道与少量必要的 NgModule(如 RouterModule.forRoot 场景下的配置模块);
  • 存量代码:需要理解 NgModule 的声明归属、导入导出作用域与 provider 注入器归属,才能安全地重构;
  • 渐进迁移:Angular 提供了官方的 standalone 迁移工具与指导,仓库内相关文档可参考 standalone migration 指南组件指南

关键源码索引

本文所述全部实现细节,均可按下表深入本仓库对应源码查阅:

主题 源码位置
NgModule 接口全部元数据属性与 JSDoc packages/core/src/metadata/ng_module.ts
NgModule 装饰器创建与 compileNgModule 调用 packages/core/src/metadata/ng_module.ts
传递编译作用域(compilation/exported)模型 packages/core/src/metadata/ng_module_def.ts
NgModuleDef 运行时数据结构 packages/core/src/metadata/ng_module_def.ts
JIT 编译模块定义 packages/core/src/render3/jit/module.ts
ModuleWithProviders 类型 packages/core/src/di/interface/provider.ts
Provider 联合类型 packages/core/src/di/interface/provider.ts
RouterModule.forRoot/forChild 与防重复守卫 packages/router/src/router_module.ts
本文对应官方文档原文 adev/src/content/guide/ngmodules/overview.md

小结

NgModule 的四个核心元数据属性形成了一套清晰的可见性体系:

  • declarations 决定谁属于我(并且只能属于一个模块,且须 standalone: false);
  • imports 决定我能看到谁的导出(其他 NgModule 或 standalone 对象);
  • exports 决定我的哪些能力对外开放(可传递导出);
  • providers 决定我为注入器提供什么,且随模块所在层级(根模块、懒加载模块)确定注入器归属。

forRoot/forChild 是贯穿 Angular 生态的 provider 组织约定,其"根级唯一、子级可复用"的语义在 RouterModule 中通过运行时守卫得到强制保证。理解这套模型,你就能顺畅地阅读、维护与渐进重构任何基于 @NgModule 的 Angular 代码库。

登录后查看全文
热门项目推荐
相关项目推荐