Angular 项目结构最佳实践
写 Angular 的第一个中大型项目时,我踩过一个很典型的坑:代码本身都不算复杂,真正让人头疼的是, 过了几个月,再打开仓库,已经很难一眼看出“谁管全局、谁管业务、哪些东西是可以到处复用的”。
那时候我才真正体会到:项目结构 本身也是代码的一部分,它决定了未来你是愉快加需求,
还是在一堆 *.component.ts 里考古。
为什么要在意“项目结构”?
用 Angular 写一个 Todo Demo,其实完全可以一个 AppModule 打天下,所有组件、服务丢进去也能跑。
但现实里的项目活得更久,团队成员会换人,需求会膨胀,半年之后你再打开仓库,如果看到的是:
app/
app.component.ts
app.component.css
app.module.ts
data.service.ts
hero.service.ts
auth.service.ts
list.component.ts
detail.component.ts
...
那大概率会有一种“我这是在拆别人写的 jQuery 吗”的错觉。
项目结构的目标很简单:让后来的人一眼就能找到东西、敢改代码。 Angular 给了我们 NgModule 这个工具,我们要做的就是,用它来划清职责边界。
从 NgModule 开始
NgModule 有很多属性,但对“项目结构”来说,最关键就两件事:
它决定了谁能用谁
你在 declarations 里声明组件,在 exports 里暴露出去,其他模块只有在 imports 它之后,才能用这些组件。
它决定了服务怎么注入
传统上,服务是挂在模块的 providers 上,从 Angular 6 开始,更推荐在 @Injectable({ providedIn: 'root' }) 里直接声明。
理解了这两个 scope 概念,你就知道为什么要拆模块,因为模块就是边界。
- Core 模块:应用级别的边界(单例、全局服务)。
- Shared 模块:可复用 UI 的边界。
- Feature 模块:业务域(domain)的边界。
接下来围绕一份目录结构,具体看怎么落地。
一份可落地的目录示例
一般的实际项目,大致会长这样:
app/
app.module.ts
app-routing.module.ts
core/
auth/
auth.module.ts
auth.service.ts
index.ts
othermoduleofglobalservice/
ui/
carousel/
carousel.module.ts
index.ts
carousel/
carousel.component.ts
carousel.component.css
othermoduleofreusablecomponents/
heroes/
heroes.module.ts
heroes-routing.module.ts
shared/
heroes.service.ts
hero.ts
pages/
heroes/
heroes.component.ts
heroes.component.css
hero/
hero.component.ts
hero.component.css
components/
heroes-list/
heroes-list.component.ts
heroes-list.component.css
hero-details/
hero-details.component.ts
hero-details.component.css
other-module-of-pages/
下面我们按模块和文件夹,一块块拆开。
应用级单例和基础设施
core/ 一般只会在 AppModule 里被 import 一次,被当成应用级基础设施层:
可以放的内容:
-
全局服务 / 单例 比如:
AuthService、UserService、AppConfigService等。 -
HTTP 拦截器 Token 注入、统一错误处理。
-
全局布局组件(可选) 比如顶栏 / 侧边栏。但很多团队会把这些放在
AppComponent里,视个人习惯。
关于 auth/ 下面那个 auth.module.ts,一个常见做法是:
如果认证相关功能比较复杂(独立一套登录、注册、忘记密码页面),可以用一个 AuthModule 来承载这些页面和路由。
如果只是一个 AuthService 加几个 Guard,可以不额外拆模块,直接在 core/ 下定义服务即可,也完全可以用:
@Injectable({ providedIn: 'root' })
export class AuthService { ... }
来省掉模块里的 providers 配置。
别把 SharedModule 变成垃圾桶
shared/ 或 ui/ 一般用于存放可复用组件、指令和管道,比如这里的 carousel:
ui/
carousel/
carousel.module.ts
index.ts
carousel/
carousel.component.ts
carousel.component.css
一些实践经验:
-
SharedModule / UiModule 不放服务 否则很容易陷入“我到底是从 Core 导入,还是从 Shared 导入?”的困惑。 服务放 core(或根注入),UI 放 shared/ui,这样边界清晰。
-
按功能再拆子模块 比如把所有通用 UI 放在
ui/,里面再分:buttons/forms/layout/carousel/
-
用
index.ts做 barrel,但别滥用index.ts的好处是缩短 import 路径:import { CarouselModule } from "@app/ui/carousel";坏处是,如果乱 export 一通,很容易引入循环依赖。所以建议只 export 对外暴露的模块和组件,不要在 barrel 里互相引用其他 barrel。
一个简单的使用原则:
当你发现某个组件被复制到了第 3 个业务模块时,停一下,把它提到 shared/ui/ 或 ui/ 里去。
按业务拆 Feature Module
heroes/ 这一整块,就是典型的 Feature Module(业务模块):
heroes/
heroes.module.ts
heroes-routing.module.ts
shared/
heroes.service.ts
hero.ts
pages/
heroes/
heroes.component.ts
hero/
hero.component.ts
components/
heroes-list/
hero-details/
这里有几个关键点:
一个业务一个模块
比如:
heroes/:英雄管理orders/:订单admin/:后台配置
每个业务模块有自己的:
xxx.module.tsxxx-routing.module.ts(路由表)- 自己的
shared/、pages/、components/
这样做的好处是:你看路由就能大致知道系统有哪些功能块。
pages/ 和 components/
这是一个非常实用的小约定:
-
pages/下的组件: 对应“页面级”路由,通常直接挂在路由上,比如/heroes、/heroes/:id。 它们可以:- 处理路由参数
- 组装多个展示组件
- 注入业务服务,触发加载 / 保存等逻辑
-
components/下的组件: 更偏“展示型”或可复用组件,比如HeroesListComponent、HeroDetailsComponent。 它们:- 尽量通过
@Input()/@Output()接收数据和发出事件 - 不直接关心路由,只负责显示和交互
- 尽量通过
这样的好处是:
页面组件负责“业务流程”,展示组件负责“长得好看”。以后如果要换一个列表样式,往往只改 components/ 的内容。
业务内的 shared
像 heroes.service.ts、hero.ts 这种只在英雄模块里用到的东西,放在 heroes/shared/ 就够了,不需要提升到全局的 app/shared/。
经验法则:
只在多个业务模块都会用到的内容,才进全局 shared。
某个 service / model 只跟当前业务强相关,就留在本模块里,避免到处都 import 它。
路由与懒加载
一般会有一个单独的 app-routing.module.ts:
app/
app.module.ts
app-routing.module.ts
里面做两件事:
- 定义基础路由(比如首页、404 页)。
-
用
loadChildren懒加载 Feature Module:const routes: Routes = [ { path: "heroes", loadChildren: () => import("./heroes/heroes.module").then((m) => m.HeroesModule), }, // ... ];
这样,用户第一次打开页面时,只会加载核心模块和当前需要的这个业务块,其他业务块会在需要时再加载。
常见的实践:
- 每个大业务模块都对应一个懒加载 route;
- 如果某个模块极其轻量,且首屏就会用到,也可以直接提前加载,没必要强行懒加载。