目录

用 NgRx 和 Nest 写一个全栈 todolist

本次实践我将使用 Angular 和 Nest.js 写一个带有状态管理,登录验证的全栈 todolist 应用。

后端开发

后端开发使用 Nest.js 框架,本次实践是重前端的,因此后端会做得简单一些。后端是在上一次鉴权认证的代码做部分修改。如果有不懂的代码,可以参考之前的文章 。此部分的讲解略过。这是后端部分生成的 swagger 接口文档:

/img/app-docs.png
swagger接口文档

前端开发

前端使用 Angular 框架。为什么使用 Angular? 以下几点是我选择它的原因:

  • 1.它是前端真正意义上的框架。
  • 2.它的一些理念是相当超前。
  • 3.Nest.js 和 Angular 非常像。
  • 4.前后端开发同构语言。

不过它的缺点也很显著,或者说是我的缺点:

  • 1.学习到会用有较长的时间周期。
  • 2.HMR(hot module replacement) 体验太差。
  • 3.报错信息简直了。

组件开发

导入 UI 组件库

在样式开发方面,我们使用 angular 官方提供的 Material UI。用一个 MaterialModule,导出我们需要用到的所有样式组件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@NgModule({
  exports: [
    MatButtonModule,
    MatCardModule,
    MatDialogModule,
    MatIconModule,
    MatInputModule,
    MatListModule,
    MatToolbarModule,
    MatMenuModule,
  ],
})
export class MaterialModule {}

这样在我们需要用到样式组件时,直接导入 MaterialModule 就好。

顶部导航

在 AppModule 中导入 MaterialModule, 然后在 app.component.html 中写入顶部 header 导航组件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<header>
  <mat-toolbar color="primary" class="expanded-toolbar">
    <div>
      <span>{{title}}</span>
      <button mat-button routerLink="/todos">
        <mat-icon>view_lists</mat-icon>
        Todo
      </button>
    </div>
    <div>
      <button
        mat-button
        routerLink="/login"
        *ngIf="authService.token.length == 0"
      >
        <mat-icon>account_box</mat-icon>
        Login
      </button>
      <button
        mat-button
        routerLink="/login"
        *ngIf="authService.token.length > 0"
        (click)="logout()"
      >
        <mat-icon>exit_to_app</mat-icon>
        Logout
      </button>
    </div>
  </mat-toolbar>
</header>
<main>
  <router-outlet></router-outlet>
</main>
<!-- <footer></footer> -->

顶部导航写在 header 标签中,router-outlet 是路由插座,当路由跳转时,我们导入的组件会替换掉 router-outlet 标签。登录成功后显示 Logout button,未登录或登出用户后显示 Login button。在 css 中做些样式调整,接下来是路由注册模块。新建一个 app-routing.module.ts 组件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import { NgModule } from "@angular/core";
import { Routes, RouterModule } from "@angular/router";

const routes: Routes = [
  {
    path: "login",
    loadChildren: () =>
      import("./features/auth/auth.module").then((m) => m.AuthModule),
  },
  {
    path: "todos",
    loadChildren: () =>
      import("./features/todo/todo.module").then((m) => m.TodoModule),
  },
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule],
})
export class AppRoutingModule {}

我们注册了路由,因为我们会在导入的模块中进行状态管理,因此这里会用 Angular 中的 Lazy-loading 方式导入模块。这样,当路由匹配 login 时 auth.module.ts 中的 component 成为 main,当路由匹配 todos 时 todo.module.ts 中的 component 成为 main。

登录表单

在 auth.module.ts 只用一个登录组件,导入一些我们需要用到的表单模块。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    ReactiveFormsModule,
    MaterialModule,
    AuthRoutingModule,
  ],
  declarations: [LoginComponent],
})
export class AuthModule {}

先创建出 login 组件,可以用 cli 的方式生成:$ nest g component login,也可以手动新建文件:login.component.html,“error$ | async” 是因为我们传递的是 obeservable 对象,我们先不说 login.component.ts, 这一部分在后面的状态管理中再说。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<mat-card>
  <mat-card-content>
    <form [formGroup]="form" (ngSubmit)="onSubmit()">
      <h2>Login</h2>
      <mat-error *ngIf="error$ | async">
        The username and password were not recognised
      </mat-error>
      <mat-form-field class="full-width-input">
        <input
          matInput
          placeholder="Username"
          formControlName="username"
          required
        />
        <mat-error> Please provide a valid email address </mat-error>
      </mat-form-field>
      <mat-form-field class="full-width-input">
        <input
          matInput
          type="password"
          placeholder="Password"
          formControlName="password"
          required
        />
        <mat-error> Please provide a valid password </mat-error>
      </mat-form-field>
      <button mat-raised-button color="primary">Login</button>
    </form>
  </mat-card-content>
</mat-card>

然后注册路由 auth-routing.module.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import { NgModule } from "@angular/core";
import { Routes, RouterModule } from "@angular/router";
import { LoginComponent } from "./login/login.component";

const routes: Routes = [
  {
    path: "",
    component: LoginComponent,
  },
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule],
})
export class AuthRoutingModule {}

todolist 列表

创建 todo.component.html, 捕获 todolist 对应的增删改查事件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<mat-card>
  <ng-container *ngIf="vm$ | async as vm">
    <h2>Todo Lists</h2>
    <mat-action-list role="list">
      <app-todo-list-item
        *ngFor="let todo of vm.todos"
        [todo]="todo"
        [loading]="vm.loading"
        (update)="showEditDialog($event)"
        (remove)="showRemoveDialog($event)"
      ></app-todo-list-item>
    </mat-action-list>
  </ng-container>
</mat-card>
<div class="fab-bottom-right">
  <button mat-fab color="accent" aria-label="Add" (click)="showCreateDialog()">
    <mat-icon>add</mat-icon>
  </button>
</div>

todo 单项

todolist 模板中的 remove, update 不是 js 原生事件, 我们需要注册对应的 EventEmitter,它是自定义事件触发与事件监听器功能的封装。todolist 列表是父组件,todo 单项是子组件。父子组件通讯需要用到 @Input() 和 @Output() 装饰器, @Input() 是父传子,@Output() 是子传父。todo-list-item.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@Component({
  selector: "app-todo-list-item",
  templateUrl: "./todo-list-item.component.html",
  styleUrls: ["./todo-list-item.component.scss"],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TodoListItemComponent {
  @Input() loading: boolean;
  @Input() todo: Todo;
  @Output() update = new EventEmitter<Todo>();
  @Output() remove = new EventEmitter<string>();
}

自定义事件的触发方法是 EventEmitter.emit(),todo-list-item.html:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<mat-list-item role="listitem" (click)="update.emit(todo)">
  <p matLine>{{ todo.text }}</p>
  <button
    mat-icon-button
    color="warn"
    aria-label="Remove"
    (click)="remove.emit(todo.id); $event.stopPropagation()"
  >
    <mat-icon>delete_outlined</mat-icon>
  </button>
</mat-list-item>

页面弹窗

在处理 todo 事项时,我们希望弹出一个 dialog:

/img/app-dialog.png
页面弹窗

这样,能监听组件变化,我们的状态变化会显得更加直观。todo-create-dialog.component.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<h2 mat-dialog-title>Create</h2>
<mat-error *ngIf="authService.token.length == 0"> Please login! </mat-error>
<form
  class="form"
  [formGroup]="form"
  (ngSubmit)="form.valid && save()"
  novalidate
>
  <mat-dialog-content>
    <mat-form-field>
      <input matInput formControlName="text" placeholder="Todo" />
    </mat-form-field>
  </mat-dialog-content>
  <mat-dialog-actions align="end">
    <button type="button" mat-button mat-dialog-close>Cancel</button>
    <button
      type="submit"
      mat-button
      color="primary"
      [disabled]="form.invalid || (loading$ | async)"
    >
      Save
    </button>
  </mat-dialog-actions>
</form>

用同样的方式创建修改会话框和删除会话框,在弹出修改会话框时,我们做一个数据的双项绑定。todo-edit-dialog.component.ts:

1
2
3
4
5
6
7
...

<mat-dialog-content>
  <mat-form-field>
    <input matInput formControlName="text" placeholder="Todo" />
  </mat-form-field>
</mat-dialog-content>

在弹出删除弹出框时,我们只需弹出一个提示语句。todo-delete-dialog.component.ts:

1
2
3
4
5
...

<mat-dialog-content>
  <p>Are you sure you want to remove this item?</p>
</mat-dialog-content>

登录验证

新建一个 services 文件夹,在其中新建 auth.service.ts 文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Injectable({
  providedIn: "root",
})
export class AuthService {
  private api: string = environment.apiUrl + "/auth";

  constructor(
    private http: HttpClient,
    private router: Router,
    private store: Store
  ) {}

  login(user: Partial<User>) {
    return this.http.post<User>(`${this.api}/signIn`, user).pipe(
      mergeMap((user: User) => {
        this.token = user.access_token || "";
        this.router.navigate(["todos"]);
        return of(user);
      })
    );
  }

  get token() {
    return localStorage.getItem("access_token") || "";
  }

  set token(val: string) {
    if (val.length > 0) {
      localStorage.setItem("access_token", val);
    }
  }

  logout() {
    this.store.dispatch(UserActions.logout());
    localStorage.clear();
  }
}

为 token 创建一个 setter, getter 方法,登录成功后,为 token 赋值,登出后,将 token 置空。providedIn: ‘root’ 表示我们的 service 是根级作用域。客服端判断用户是否登录成功的方法就是判断我们的 token 是否为空。而服务端将 token 作为验证用户的凭据。用户登录成功后,每次需要用户验证的请求都要求验证 token。我们可以使用拦截器(Interceptor) 对网络请求重新封装。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  constructor(public authService: AuthService) {}

  intercept(
    req: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
    const authToken = this.authService.token;
    const authReq = req.clone({
      headers: req.headers.set("Authorization", "Bearer " + authToken),
    });
    return next.handle(authReq);
  }
}

当我们设置拦截器后,我们需要注册在根模块 app.module.ts 中。

1
2
3
4
5
6
7
  providers: [
    {
      provide: HTTP_INTERCEPTORS,
      useClass: AuthInterceptor,
      multi: true
    }
  ],

添加了全局拦截器后我们就不必在每个请求接口都手动添加 header 的 bear 参数了。

状态管理

状态管理是为了组件之间的数据共享。组件数据发生变化后,不通过不同组件传值这样的方式,而通过一个叫 store 的东西统一管理状态数据,监听状态数据变化。

打印日志

新建一个 store 文件夹,里面新建 app.state.ts 文件,将路由变化 router 加入到 AppState 中。

1
2
3
4
5
import { RouterReducerState } from "@ngrx/router-store";

export interface AppState {
  router: RouterReducerState;
}

创建一个 reducers 文件夹, 里面新建 logger.reducer.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import { ActionReducer } from "@ngrx/store";
import { AppState } from "../app.state";

export function logger(
  reducer: ActionReducer<AppState>
): ActionReducer<AppState> {
  return (state, action) => {
    const result = reducer(state, action);
    console.groupCollapsed(action.type);
    console.log("prev state", state);
    console.log("action", action);
    console.log("next state", result);
    console.groupEnd();

    return result;
  };
}

这是参考了 NgRx 在 github 上的官方示例 的做法,实现一个控制台的日志打印,当然我们也可以在浏览器安装 redux 扩展程序。新建 index.ts,开发环境下就有日志打印:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import { ActionReducerMap, MetaReducer } from "@ngrx/store";
import * as fromRouter from "@ngrx/router-store";

import { environment } from "../../../environments/environment";
import { logger } from "./logger.reducer";
import { AppState } from "../app.state";

export const reducers: ActionReducerMap<AppState> = {
  router: fromRouter.routerReducer,
};

export const metaReducers: MetaReducer<AppState>[] = !environment.production
  ? [logger]
  : [];

理解状态管理

写本篇文章的目的之一是将状态管理实践验证。之前我有说前端的状态管理相当于后端的数据库。接下来我将一步一步的验证这个说法。下面以用户状态来讲解。

定义初始状态类似于数据库的创建,action 是 store 数据的来源入口,从 NgRx 官网示意图中我们可以看到它可以来自用户操作(component),也可以来自网络请求(effects),reducer 类似于数据表,那 adapter, selector, effects 又分别是什么呢?

/img/ngrx.png
ngrx状态管理

按照 Redux 的约定,reducer 必须是纯函数,接收旧状态,返回新状态。adapter 是 reducer 数据表的一行(记录),selector 类似于 sql 中的 select 语句。而 effects 负责传递数据,和一些 DOM 操作,使用 NgRx 这套流程时,component 唯一能做的是 dispatch action, 额外的事情(副作用)就交给 effects 来做。

下面给出代码实例,可以结合代码来理解这段话。

代码实例

初始化 reducer 表

初始状态(initialState)是定义在 reducer 中的,user.state.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import { EntityState, createEntityAdapter } from "@ngrx/entity";
import { User } from "src/app/models/user.model";

export const featureName = "user";

export interface State extends EntityState<User> {
  needAuth: boolean;
  selectedId?: string;
  error?: any;
}

export const adapter = createEntityAdapter<User>();

export const initialState: State = adapter.getInitialState({
  needAuth: false,
});

注册 reducer 表

featureName 是我们的 reducer 表名,根模块注册时,StoreModule.forRoot(featureName, reducer)。 其他模块注册时,StoreModule.forFeature(featureName, reducer), 当在其他模块注册时,仍然需要在根模块配置 StoreModule.forRoot({})。

action 数据源

定义 Action 作为 store 数据来源,props 接收数据参数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import { createAction, props } from "@ngrx/store";
import { User } from "src/app/models/user.model";

export const login = createAction(
  "[Auth Page] User Login",
  props<{ user: Partial<User> }>()
);

export const loginSuccess = createAction(
  "[Auth Page] Login Success",
  props<{ user: User }>()
);

export const loginFailure = createAction(
  "[Auth Page] Login Failure",
  props<{ error: any }>()
);

export const logout = createAction("[Auth Page] User Logut");

effects 钩子

effects 钩取相应 action(login) 后,发送网络请求,并触发新的 action(loginSuccess 或者 loginFailure)。effects 与 action 数据交互是双向的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@Injectable()
export class UserEffects {
  constructor(private actions$: Actions, private authService: AuthService) {}

  login$ = createEffect(() =>
    this.actions$.pipe(
      ofType(UserActions.login),
      concatMap(({ user }) =>
        this.authService.login(user).pipe(
          map((result) => UserActions.loginSuccess({ user: result })),
          catchError((error) => of(UserActions.loginFailure({ error })))
        )
      )
    )
  );
}

adapter 记录

action 传递数据给 reducer 表, reducer 表在 loginSuccess 时就增加一行记录。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
export const reducer = createReducer(
  initialState,
  on(UserActions.login, (state) => {
    return { ...state, needAuth: true };
  }),
  on(UserActions.loginSuccess, (state, { user }) => {
    return adapter.addOne(user, { ...state, needAuth: false });
  }),
  on(UserActions.loginFailure, (state, { error }) => {
    return { ...state, needAuth: true, error };
  }),
  on(UserActions.logout, (state) => {
    return { ...state, needAuth: true };
  })
);

selector 状态切片

我们要将新的状态作用到 component 上,这个时候就需要 selector 在 reducer 中查询,创建 user.selector.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
const getUserState = createFeatureSelector<State>(featureName);
const { selectEntities } = adapter.getSelectors();

export const getLogin = createSelector(getUserState, (state) => state.needAuth);

export const getError = createSelector(getUserState, (state) => state.error);

export const getSelectedId = createSelector(
  getUserState,
  (state) => state.selectedId
);

export const getUserEntities = createSelector(getUserState, selectEntities);

export const getUser = createSelector(
  getSelectedId,
  getUserEntities,
  (id, entities) => (id ? entities[id] : undefined)
);

在 login.component.ts 中得到新的状态:

1
2
error$ = this.store.pipe(select(UserSelectors.getError));
needAuth$ = this.store.pipe(select(UserSelectors.getLogin));

变量中的 $ 表示得到的数据是 Observable 对象。我们可以直接将 Observable 渲染在 html 中:

1
2
3
<mat-error *ngIf="error$ | async">
  The username and password were not recognised
</mat-error>

也可以取订阅后的布尔值 error$.subscribe(data => data)。 在 component 中填写完登录表单后, 只需 dispatch action:

1
this.store.dispatch(UserActions.login({ user }));

状态数据就在 component, store 之间来回传递。最后的程序如图:

/img/app-state.png
监听页面状态变化

思考

看到这里,你会发现,我们绕了一圈做了一件简单的事。但你可以想象,前端框架的组件化开发方式,组件之间的通信基本都是单向数据流,要是没有状态管理,父子组件还好,相隔很远的组件,只能通过一级一级向上传,再一级一级向下传,会更加麻烦。

如果需要共享的状态较少,可以直接定义在 service 中,通过 DI(依赖注入) 的方式能够很方便地传递到不同的 component 中,并不需要状态管理。

附:源码地址

参阅资料