写 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 一次,被当成应用级基础设施层

可以放的内容:

  • 全局服务 / 单例 比如:AuthServiceUserServiceAppConfigService 等。

  • 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.ts
  • xxx-routing.module.ts(路由表)
  • 自己的 shared/pages/components/

这样做的好处是:你看路由就能大致知道系统有哪些功能块。

pages/components/

这是一个非常实用的小约定:

  • pages/ 下的组件: 对应“页面级”路由,通常直接挂在路由上,比如 /heroes/heroes/:id。 它们可以:

    • 处理路由参数
    • 组装多个展示组件
    • 注入业务服务,触发加载 / 保存等逻辑
  • components/ 下的组件: 更偏“展示型”或可复用组件,比如 HeroesListComponentHeroDetailsComponent。 它们:

    • 尽量通过 @Input() / @Output() 接收数据和发出事件
    • 不直接关心路由,只负责显示和交互

这样的好处是: 页面组件负责“业务流程”,展示组件负责“长得好看”。以后如果要换一个列表样式,往往只改 components/ 的内容。

业务内的 shared

heroes.service.tshero.ts 这种只在英雄模块里用到的东西,放在 heroes/shared/ 就够了,不需要提升到全局的 app/shared/

经验法则:

只在多个业务模块都会用到的内容,才进全局 shared

某个 service / model 只跟当前业务强相关,就留在本模块里,避免到处都 import 它。

路由与懒加载

一般会有一个单独的 app-routing.module.ts

app/
  app.module.ts
  app-routing.module.ts

里面做两件事:

  1. 定义基础路由(比如首页、404 页)。
  2. loadChildren 懒加载 Feature Module:

    const routes: Routes = [
      {
        path: "heroes",
        loadChildren: () =>
          import("./heroes/heroes.module").then((m) => m.HeroesModule),
      },
      // ...
    ];
    

这样,用户第一次打开页面时,只会加载核心模块和当前需要的这个业务块,其他业务块会在需要时再加载。

常见的实践:

  • 每个大业务模块都对应一个懒加载 route;
  • 如果某个模块极其轻量,且首屏就会用到,也可以直接提前加载,没必要强行懒加载。