tc9011

如何更好地组织Angular项目

11 min
blog
blog

虽然 Angular 官网 已经给出了Angular 项目结构的建议,不过有些地方实践起来还是有需要注意的地方,这里就结合 ng-alain 来讲讲如何更好地组织一个 Angular 项目。

layout和 routes模块

首先,我们来看看 ng-alain 的目录结构。

2019-04-19_19-54-03
2019-04-19_19-54-03

可以看到, app 文件夹下面被分成了corelayoutroutesshared几个目录。

先看layoutroutes 这两个目录,它们用于整体视图层的组织,为什么要分成两个模块去组织的视图层呢,其实是为了更好地去布局。

2019-04-19_20-00-45
2019-04-19_20-00-45

如果你把导航栏和页脚在 app.component.ts 中引入,这时候如果跳转到一个没有导航栏和页脚的登陆页面时,你的实现可能会需要写一个指令去控制导航栏和页脚的展现。像 ng-alain 这样剥离布局层的代码,可以让你更方便地组织你的布局。

我们再看整个项目的路由组织,在routes-routing.module.ts 中,通过把基础布局层作为各个顶级路由的component,页面的其他业务相关的component放在children的子路由中,这样以此剥离了布局层的代码,让routes模块更加专注于更核心 UI 层的组织。

在子路由中,我们可以通过loadChildren来进行懒加载,这样保证当前页面 buddle 的大小最小,提高页面加载速度。

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { SimpleGuard } from '@delon/auth';
import { environment } from '@env/environment';
// layout
import { LayoutDefaultComponent } from '../layout/default/default.component';
import { LayoutFullScreenComponent } from '../layout/fullscreen/fullscreen.component';
import { LayoutPassportComponent } from '../layout/passport/passport.component';
// dashboard pages
import { DashboardV1Component } from './dashboard/v1/v1.component';
import { DashboardAnalysisComponent } from './dashboard/analysis/analysis.component';
import { DashboardMonitorComponent } from './dashboard/monitor/monitor.component';
import { DashboardWorkplaceComponent } from './dashboard/workplace/workplace.component';
// passport pages
import { UserLoginComponent } from './passport/login/login.component';
import { UserRegisterComponent } from './passport/register/register.component';
import { UserRegisterResultComponent } from './passport/register-result/register-result.component';
// single pages
import { CallbackComponent } from './callback/callback.component';
import { UserLockComponent } from './passport/lock/lock.component';

const routes: Routes = [
  {
    path: '',
    component: LayoutDefaultComponent,
    canActivate: [SimpleGuard],
    canActivateChild: [SimpleGuard],
    children: [
      { path: '', redirectTo: 'dashboard/v1', pathMatch: 'full' },
      { path: 'dashboard', redirectTo: 'dashboard/v1', pathMatch: 'full' },
      { path: 'dashboard/v1', component: DashboardV1Component },
      { path: 'dashboard/analysis', component: DashboardAnalysisComponent },
      { path: 'dashboard/monitor', component: DashboardMonitorComponent },
      { path: 'dashboard/workplace', component: DashboardWorkplaceComponent },
      {
        path: 'widgets',
        loadChildren: './widgets/widgets.module#WidgetsModule',
      },
      { path: 'style', loadChildren: './style/style.module#StyleModule' },
      { path: 'delon', loadChildren: './delon/delon.module#DelonModule' },
      { path: 'extras', loadChildren: './extras/extras.module#ExtrasModule' },
      { path: 'pro', loadChildren: './pro/pro.module#ProModule' },
      // Exception
      { path: 'exception', loadChildren: './exception/exception.module#ExceptionModule' },
    ],
  },
  // 全屏布局
  {
    path: 'data-v',
    component: LayoutFullScreenComponent,
    children: [
      { path: '', loadChildren: './data-v/data-v.module#DataVModule' },
    ],
  },
  // passport
  {
    path: 'passport',
    component: LayoutPassportComponent,
    children: [
      {
        path: 'login',
        component: UserLoginComponent,
        data: { title: '登录', titleI18n: 'app.login.login' },
      },
      {
        path: 'register',
        component: UserRegisterComponent,
        data: { title: '注册', titleI18n: 'app.register.register' },
      },
      {
        path: 'register-result',
        component: UserRegisterResultComponent,
        data: { title: '注册结果', titleI18n: 'app.register.register' },
      },
      {
        path: 'lock',
        component: UserLockComponent,
        data: { title: '锁屏', titleI18n: 'app.lock' },
      },
    ],
  },
  // 单页不包裹Layout
  { path: 'callback/:type', component: CallbackComponent },
  { path: '**', redirectTo: 'exception/404' },
];

@NgModule({
  imports: [
    RouterModule.forRoot(
      routes, {
        useHash: environment.useHash,
        // NOTICE: If you use `reuse-tab` component and turn on keepingScroll you can set to `disabled`
        // Pls refer to https://ng-alain.com/components/reuse-tab
        scrollPositionRestoration: 'top',
      }
    )],
  exports: [RouterModule],
})
export class RouteRoutingModule {}

这个routes-routing.module.tsexportmodule会在routes.moudle.ts中注册,而RoutesModule会和LayoutModuleSharedModuleCoreModule一起在整个 APP 的根模块(app.module.ts)中注册。这样,在根模块中完成了对整个项目基础的划分,而每个模块具体做什么,则分散到各个子模块中,在子模块中去组织相应的componentsroutesservices等。

CoreModule

Angular官网对 core 模块的描述是:

考虑把那些数量庞大、辅助性的、只用一次的类收集到核心模块中,让特性模块的结构更清晰简明。

坚持把那些“只用一次”的类收集到 CoreModule 中,并对外隐藏它们的实现细节。简化的 AppModule 会导入 CoreModule,并且把它作为整个应用的总指挥。

坚持core 目录下创建一个名叫 CoreModule 的特性模块(例如在 app/core/core.module.ts 中定义 CoreModule)。

坚持把要共享给整个应用的单例服务放进 CoreModule 中(例如 ExceptionServiceLoggerService)。

坚持导入 CoreModule 中的资产所需要的全部模块(例如 CommonModuleFormsModule)。

为何? CoreModule 提供了一个或多个单例服务。Angular 使用应用的根注入器注册这些服务提供商,让每个服务的这个单例对象对所有需要它们的组件都是可用的,而不用管该组件是通过主动加载还是惰性加载的方式加载的。

为何?CoreModule 将包含一些单例服务。而如果是由惰性加载模块来导入这些服务,它就会得到一个新实例,而不是所期望的全应用级单例。

坚持把应用级、只用一次的组件收集到 CoreModule 中。 只在应用启动时从 AppModule 中导入它一次,以后再也不要导入它(例如 NavComponentSpinnerComponent)。

**为何?**真实世界中的应用会有很多只用一次的组件(例如加载动画、消息浮层、模态框等),它们只会在 AppComponent 的模板中出现。 不会在其它地方导入它们,所以没有共享的价值。 然而它们又太大了,放在根目录中就会显得乱七八糟的。

避免AppModule 之外的任何地方导入 CoreModule

**为何?**如果惰性加载的特性模块直接导入 CoreModule,就会创建它自己的服务副本,并导致意料之外的后果。

**为何?**主动加载的特性模块已经准备好了访问 AppModule 的注入器,因此也能取得 CoreModule 中的服务。

坚持CoreModule 中导出 AppModule 需导入的所有符号,使它们在所有特性模块中可用。

为何?CoreModule 的存在就要让常用的单例服务在所有其它模块中可用。

**为何?**你希望整个应用都使用这个单例服务。 你不希望每个模块都有这个单例服务的单独的实例。 然而,如果 CoreModule 中提供了一个服务,就可能偶尔导致这种后果。

所以从描述来看,CoreModule 应该只会在 AppModule 中被导入,所以在 ng-alain 的模块注册指导原则中把CoreModule 认为应该是纯服务类模块,通常会放HTTP 拦截器、路由守卫等一些全局性的服务。对于防止CoreModule 被多次导入,官方也给出了解决方案

坚持防范多次导入 CoreModule,并通过添加守卫逻辑来尽快失败。

**为何?**守卫可以阻止对 CoreModule 的多次导入。

**为何?**守卫会禁止创建单例服务的多个实例。

// core/module-import-guard.ts
export function throwIfAlreadyLoaded(parentModule: any, moduleName: string) {
    if (parentModule) {
      throw new Error(`${moduleName} has already been loaded. Import Core modules in the AppModule only.`);
    }
}

// core/core.module.ts
import { NgModule, Optional, SkipSelf } from '@angular/core';
import { CommonModule } from '@angular/common';
 
import { LoggerService } from './logger.service';
import { NavComponent } from './nav/nav.component';
import { throwIfAlreadyLoaded } from './module-import-guard';
 
@NgModule({
    imports: [
      CommonModule // we use ngFor
    ],
    exports: [NavComponent],
    declarations: [NavComponent],
    providers: [LoggerService]
})
export class CoreModule {
    constructor( @Optional() @SkipSelf() parentModule: CoreModule) {
      throwIfAlreadyLoaded(parentModule, 'CoreModule');
    }
}

SharedModule

CoreModule相比,SharedModule正好相反,它不应该包含服务,因为SharedModule会在不同业务模块中导入,一旦包含了服务,就会产生不同的实例,有可能会对应用产生负面的影响,所以尽量保证服务的单一性。

SharedModule中正如官网所说,应该包含所有组件(自己写的非业务相关的通用组件)、指令、管道以及其他模块所需要的资产(例如 CommonModuleFormsModuleRouterModuleReactiveFormsModule和第三方通用依赖模块)。

Service

对于服务,应该承担应用的数据操作和数据交互的作用,所以类似于 http 请求、storage 的操作、复杂数据的计算等都应交给服务,让组件聚焦于视图,去组织视图层的展示和服务计算数据的收集,而不是承担了较重的数据操作和交互。业务层的服务尽量跟着对应的组件,通常我会在对应组件文件夹下新建一个services的文件夹,存放对应的服务。

Styles

至于样式,通常我会把全局性的变量、通用的 css 样式(和业务无关的样式,例如 css reset、自适应相关的全局样式)放在src/styles.scss下,而和业务相关的通用 css 样式(例如某几个组件共用的样式、mixin 等)都会放在assets/css目录下。

总结

  • AppModule 应该 导入 SharedModuleCoreModuleLayoutModuleRouterModule、Angular 模块(例如:BrowserModuleBrowserAnimationsModuleHttpClientModule);
  • LayoutModule 应该 导入 SharedModule
  • LayoutModule 应该 导出所有 layout component;
  • LayoutModule 不应该 导入和声明任何路由;
  • RouterModule 应该 导入 SharedModuleCoreModuleLayoutModule以及RouteRoutingModule
  • CoreModule应该 只保留providers属性;
  • SharedModule 应该 包含 Angular 通用模块(例如:CommonModuleFormsModuleRouterModuleReactiveFormsModule)、第三方通用依赖模块、所有组件(自己写的非业务相关的通用组件)、指令、管道;
  • SharedModule应该导出所有包含模块;
  • SharedModule 不应该providers属性;
  • Service 应该 承担应用的数据操作和数据交互;
  • Component应该 组织视图层的展示和服务计算数据的收集
  • 样式分层
  • 本文作者: tc9011
  • 本文链接: https://tc9011.com/posts/2019/如何更好地组织angular项目/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!