译:在 Angular 中使用 Redux 来管理用户界面

文章出处:https://www.pluralsight.com/guides/front-end-javascript/ui-state-management-with-redux-in-angular-4
在此感谢 Hristo Georgiev 先生的原文许可
本文分上下两节,上节主要是指引读者初始化一个 Angular 4(2) + Redux 的架构,并且用一个示例来方便读者进行实践;下节主要是一些功能的具体实现。
(感觉分两节会有更多时间缓冲一点,下节对熟悉 ngb 的筒子也不si很有必要,想看完整文章的童鞋点击原文吧!)

​ 尽管各种用于构建 web 界面的先进技术在过去几年相继出现,但 DOM 和应用 UI 的管理仍然在很大程度上依赖 jQuery——一个已经有历尽10年沧桑的代码库。目前的对它的应用已经和它初生之时所肩负的使命有所不同,尽管这不一定是一件不好的事情。但是从结果上来看,如今的开发者们对 jQuery 的应用方式已经产生了一些问题。随着大量低耦合甚至无耦合的组件、封装后的视图组件及其他各类元素集成在一起,前端应用变得越来越复杂。

​ 在这篇文章里,我们将探索一个基于 Redux 的解决方案,用于目前在 anguar2 构建的应用中极具挑战性的 UI 状态管理。接下来,我们会通过理论和一个实例来学习如何通过使用 reducer 的方法来处理应用 UI 状态的逻辑。

掌控你的应用界面布局( UI Layout )

​ 自从 Redux 推出以来,前端应用的状态管理得到了革命性的进步。我和我的团队实例测试结果显示,Redux 的集成对 Angular 2 应用的生产效率有极大的提升。

Redux 不仅仅是加快了数据的流速,它还通过将关键性的逻辑封装在独立的区域来从整体上提升了代码的可维护性,并且为应用的结构测试提供了方便。

​ 对 Redux 的沉迷让我们希望用她来管理所有事务。在我们最近的工作中,其中一个项目对 UI 有着相当强的依赖,因而我们决定试验基于这个需求来给予 Reducer 多一点的职责而不仅仅是管理数据。

使用 Redux 管理 UI 的三个要点

  • 在切换路由的时候保持 UI 的状态,譬如保持 sidebar 的展开或者收起

  • 在应用的任何节点控制 UI 的状态,而不用考虑如何进行组件之间的通信或者通过具体的 service 注入来控制 UI

    (意即不用通过组件间的通讯来控制组件的装入和卸载,而是在应用生命周期的任何时刻进行灵活的控制)

  • 将非 UI 行为的事件与 UI 的状态改变关联起来,如路由的改变或者处理来自服务端的数据时

初始化工程

以下的示例是基于目前最流行的 bootstrap 4 风格定制的 Angular 2 组件库 “ng-bootstrap” 构建的。你也可以在这个示例中实践其他 UI 组件库,如声名远扬(译者自加的)的 Material Design。敲代码时通过遵循相同的设计原则和做一些局部的适配来让当前的组件库能够顺利运行。

依赖库


(译者注:以下示例中用到的框架已由最新的 Angular 版本对应实现,有些 api 已经进行了调整——如 StoreModule.provideStore() 在 Angular4
的 @ngrx/store 版本中已经更新为更为标准化的StoreModule.forRoot(),因而请关注相关文档避免兼容问题)
开始运行前你需要按顺序安装以下的依赖

Redux

  • @ngrx/store + @ngrx/core
  • @ngrx/effects
  • reselect
  • ngrx-store-logger

Bootstrap

  • Bootstrap 4
  • ng-bootstrap

安装


为了有一个流畅的安装过程,我们使用 Angular CLI 来初始化项目架构。执行前请确认你已经全局安装过它。

在你的终端中键入一下命令来初始化一个 Angular 2 项目

$ ng new redux-layout-tutorial-app
$ cd redux-layout-tutorial-app
$ yarn add bootstrap@4.0.0-alpha.6
//or by npm
$ npm i --save bootstrap@4.0.0-alpha.6

你需要在项目的根目录下打开 angular-cli.json 来添加 Bootstrap 的资源库

apps: [ 
  { 
    //.. 
    "styles": [ "../node_modules/bootstrap/dist/css/bootstrap.css" 
    ], 
    //... 
    "environments": { 
      //... 
      "scripts": [ 
        "../node_modules/jquery/dist/jquery.js", 
        "../node_modules/tether/dist/js/tether.js", 
        "../node_modules/bootstrap/dist/js/bootstrap.js" 
      ] 
    }

这会让你 angular-cli 从你的 Bootstrap 安装目录下确定 javascript 和 css 文件的位置,并在项目生成时加入他们(即形成依赖)

下一步,安装 ng-bootstrap

$ yarn add @ng-bootstrap/ng-bootstrap
// or by npm
$ npm i --save @ng-bootstrap/ng-bootstrap

然后在你的应用的根目录下的模块文件中引入(即 app.module.ts 文件):

import {NgbModule} from "@ng-bootstrap/ng-bootstrap"; 

@NgModule({ 
  //.. 
  imports: [ NgbModule.forRoot() ], 
  //.. 
})

初始化应用的 store 和基本的 reducer


接下来,我们将构建一个基础的 Redux 架构,之后的操作中的用例都会基于这个架构

从安装 Redux 应用 store 的核心依赖库开始把!

$ yarn add @ngrx/core
$ yarn add @ngrx/store
// or by npm
$ npm i --save @ngrx/core
$ npm i --save @ngrx/store

对于异步事件,譬如 pagination 或 loading bars 的控制,我们需要引入中间件来处理:

$ yarn add @ngrx/effects
// or by npm
$ npm i --save @ngrx/effects

我们使用reselect来实现高效的state存取操作。我们将使用reselectcreateSelector方法来创建高效的选择器,这个选择器能被存储且仅在参数更改的时候才会重构:

$ yarn add reselect
// or by npm
$ npm i --save reselect

为了让开发更加方便并易于调试,我们添加能够在控制台记录actionstate的更新的store-logger来帮助我们:

$ yarn add ngrx-store-logger
// or by npm
$ npm i --save ngrx-store-logger

我们将与 redux 相关联的文件都存放在 src/app/common 下来使应用架构更加合理一些:

$ mkdir src/app/common

创建界面状态


接着上面的步骤,创建 common/layout 目录用于放置所有与界面状态相关的actioneffectreducer

$ mkdir src/app/common/layout
$ cd src/app/common/layout

我们在这个目录下创建三个与界面状态相关的文件:

$ touch layout.actions.ts

layout.actions.ts

这些action会在用户行为发生(打开或关闭sidebar,打开或关闭modal元素或其他操作)或者一个相关的事件(页面缩放)发生时被调用:

import {Action} from '@ngrx/store';

/*
 Layout actions are defined here
 */

export const LayoutActionTypes = {};

/*
 The action classes will be added here once they are defined
 */

export type LayoutActions = null;

layout.reducer.ts

$ touch layout.reducer.ts

负责界面状态的 reducer 会在每次界面状态的改变的时候更新 state

import * as layout from './layout.actions'

export interface State {
  /*
   界面状态的描述符接口定义
   */
}

const initialState: State = {
  /*
   界面状态在这里进行值的初始化
   */
};

/*
 reducer 的主控函数,在状态改变的时候返回新的 state
 */

export function reducer(state = initialState, action: layout.LayoutActions): State {
  switch(action.type) {
    default: return state;
  }
}

创建 reducer


界面状态初始化配置完成后,最后一步就是添加 reducer,它会随着@ngrx/store提供的StoreModule变化在操作流的末端进行更新

$ touch src/app/common/index.ts

index.ts

/* 
  引入之前提到的用于创建高效选择器的工具 createSelector
 */

import { createSelector } from 'reselect';

/* 
 同理引入 store-logger
 */

import { storeLogger } from 'ngrx-store-logger';

/*
 引入界面状态
 */

import * as fromLayout from './layout/layout.reducer'
import { compose } from '@ngrx/core'
import { combineReducers } from '@ngrx/store'

export interface AppState {
  layout: fromLayout.State
}

export const reducers = {
  layout: fromLayout.reducer
}

const developmentReducer: Function = compose(storeLogger(), combineReducers)(reducers);

export function metaReducer(state: any, action: any) {
  return developementReducer(state, action);
}

/*
 创建界面状态的选择器
 */

export const getLayoutState = (state: AppState) => state.layout;

最后,metaReducer注入到根模块的imports数组中的StoreModule

import { StoreModule } from '@ngrx/store'
import { metaReducer } from './common/index'
//...

@NgModule({
  //...
  imports: [
    StoreModule.provideStore(metaReducer)
  ],
  //...
})

export class AppModule { }
”机智的”容器与“哑巴”组件

如果你熟悉 Redux 的使用,你一定知道有两种类型的组件——视觉组件(即 UI 组件)容器组件

在实现整个界面状态的时候,最好的实践是将逻辑绑定在指令(directive)中,以保证逻辑的DRY原则。举个例子,你并不需要给每一个容器组件中的 sidebar 都重复一次相同的控制逻辑

另一种方式是将逻辑写在组件内部,当然,只有特殊情况下才会将逻辑写在表现界面元素的组件(即 UI 组件)中。

在这个示例中,容器组件为 AppComponent。我们将layout.actions引入到根组件AppComponentimports中,以将状态关联到应用内部,使其可以触发action

import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';

/*
 将初始状态引入到组件中,以操作其中的各部分状态
 */
import * as fromRoot from './common/index';
/*
 引入界面状态相关的 action 等待调用
 */
import * as layout from './common/layout/layout.actions';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  constructor(
    private store: Store<fromRoot.AppState>
  ) { }
}

Modals(模态)


实现一个模态框的最简单方式是用一个常量来标记它。毕竟一个情景下界面只应有一个模态框(除非你试着实践一些黑魔法),每个模态框应当引用自一个唯一的modalName

那我们就从定义action开始吧!

用户应该具有触发和关闭模态框的能力,所有我们想这样来定义模态框的action

Adding to the state

layout.actions.ts

export const LayoutActionTypes = {
  OPEN_MODAL: '[Layout] Open modal',
  CLOSE_MODAL: '[Layout] Close modal'
};

/*
 模态框的 action
 */
export class OpenModalAction implements Action {
  type = LayoutActionTypes.OPEN_MODAL;
  
  constructor(
    public payload:string
  ) { }
}

export class CloseModalAction implements Action {
  type = LayoutActionTypes.CLOSE_MODAL;
  constructor() { }
}

export type LayoutActions = CloseModalAction | OpenModalAction

我们继续往下写来实现action的处理器reducerlayout.reducer.ts

import * as layout from './layout.actions';

export interface State {
  openedModalName: string;
}

const initialState: State = {
  openedModalName: null
};

export function reducer(state = initialState, action: layout.LayoutActions): State {
  switch(action.type) {
    /*
     模态框的 case
     */
    case layout.LayoutActionTypes.OPEN_MODAL: {
      const name = action.payload;
      return Object.assign({}, state, {
        openedModalName: name
      }); // 此处用 Object.assign 的原因请探寻 redux 的状态更新原理
    }
      
    case layout.LayoutActionTypes.CLOSE_MODAL: {
      return Object.assign({}, state, {
        openedModalName: null
      })
    }
      
    default: return state;
  }
}

export const getOpenedModalName = (state: State) => state.openedModalName;

当前模态框的标识符(也就是它的名字)会被openedModalName存储下来,然后根据调用的action来变化。我们需要一个选择器(getOpenedModalName)来操作state中的openedModalName属性。

index.ts

export const getLayoutState = (state: AppState) => state.layout;

//...
export const getLayoutOpenedModalName = createSelector(getLayoutState, fromLayout.getOpenedModalName);

使用


我们创建一个简单的模态框来看一下它到底是如何运作的:

$ ng g component template-modal

template-modal.component.ts

import {
  Component,
  ChangeDelectionStrategy,
  Output,
  ViewChild,
  EventEmiter,
  Input,
  ElementRef
} from '@angular/core'
import {
  NgbModal,
  NgbModalRef
} from '@ng-bootstrap/ng-bootstrap'

@Component({
  selector: 'template-modal',
  templateUrl: 'template-modal.component.html'
})

export class TemplateModalComponent {
  private modalName: string = 'templateFormModal';
  private modalRef: NgbMOdalRef;

  @ViewChild('content') _templateModal: ElementRef;
  @Input() set modalState(_modalState: any) {
    if (_modalState == this.modalName) {
      this.openModal()
    } else if (this.modalRef) {
      this.closeModal();
    }
  }

  @Output() onCloseModal = new EventEmitter<any>();

  constructor(private modalService: NgbModal) {}

  openModal() {
    this.modalRef = this.modalService.open(this._templateModal, {
      backdrop: 'static',
      keyboard: false,
      size: 'sm'
    })
  }

  closeModal() {
    this.modalRef.close();
  }
}


每当用户尝试去关闭模态框的时候,onCloseModal会被template出发并传递到容器组件(一个EventEmitter的原理)。

在容器组件中需要一个处理器来处理openedModalName的更替并调用action来控制模态框:

app.component.ts

export class AppComponent {
  public openedModalName$: Obeservable<any>;
  
  constructor(
    private store: Store<fromRoot.AppState>
  ) {
    // 用选择器直接操作开启的模态框
    this.openedModalName$ = store.select(fromRoot.getLayoutOpenedModalName);
  }

  // 调用 action 以开启模态框
  handleOpenModal(modalName: string) {
    this.store.dispatch(new layout.OpenedModalAction(modalName));
  }

  // 调用 action 以关闭模态框() {
    this.store.dispatch(new layout.CloseModalAction());
  }
}

可以看到,我们能重用handleOpenModalhandleCloseModal来控制无论多少的模态框,只要这些模态框有唯一的标识符。

译者:当然,这是以一种非常'DRY'的方式来控制模态框,我们也可以根据实际情况来改变这个架构

app.component.html

<!-- 我们通过异步流的方式来响应式的获取组件中最新的相关值 -->
<template-modal [modalState]="this.openedModalName$ | async" (onCloseModal)="handleCloseModal()"></template-modal>
<button class="btn btn-outline-primary" (click)="handleOpenModal('templateFormModal')">
  Open modal with template
</button>

<!-- 别忘了把这个写上 -->
<template ngbModalContainer></template>

在这个示例中,点击按钮来触发了hanelOpenModal,但这并不是唯一的触发方式,有了 Redux,我们可以在任何地方调用 action 来执行它,指令、service或者effect。这是没有限制的。

example1_modal

Sidebar(s) 侧边栏


在一个应用中,其侧边栏最基本的视觉属性便是它的显示和隐藏。在全局的状态中由一个布尔值属性来决定侧边栏是opened状态还是closed状态。如果有两个侧边栏(或者多个,看你是怎么玩的>_<!!),那就为每一个侧边栏提供一个状态属性。

当用户与侧边栏交互的时候,需要有一些类似于开关作用的action
layout.action.ts

export const LayoutActionTypes = { 
//左侧边栏行为
OPEN_LEFT_SIDENAV: '[Layout] Open LeftSidenav', 
CLOSE_LEFT_SIDENAV: '[Layout] Close LeftSidenav', 
//右侧边栏行为
 OPEN_RIGHT_SIDENAV: '[Layout] Open RightSidenav', 
CLOSE_RIGHT_SIDENAV: '[Layout] Close RightSidenav', }; 

export class OpenLeftSidenavAction implements Action { type = LayoutActionTypes.OPEN_LEFT_SIDENAV; constructor() { } } 

export class CloseLeftSidenavAction implements Action { type = LayoutActionTypes.CLOSE_LEFT_SIDENAV; constructor() { } } 

export class OpenRightSidenavAction implements Action { type = LayoutActionTypes.OPEN_RIGHT_SIDENAV; constructor() { } } 

export class CloseRightSidenavAction implements Action { type = LayoutActionTypes.CLOSE_RIGHT_SIDENAV; constructor() { } } 

export type LayoutActions = CloseLeftSidenavAction | OpenLeftSidenavAction | CloseRightSidenavAction | OpenRightSidenavAction

根据之前提到的,侧边栏的状态属性值应该是布尔类型的变量。在这个示例中,左侧边栏默认开启,但是会有一个根据屏幕尺寸来决定是否调用CloseLeftSidenavAction来关闭它的逻辑。
layout.reducer.ts

import * as layout from './layout.actionis';

export interface State {
  leftSidebarOpened: boolean;
  rightSidebarOpened: boolean;
}

const initialState: State = {
  leftSidebarOpened: true,
  rightSidebarOpened: false
};

export function reducer(state = initialState, action: layout.LayoutActions): State {
  switch(action.type) {
    case layout.LayoutActionTypes.CLOSE_LEFT_SIDENAV: { return Object.assign({}, state, { leftSidebarOpened: false }); }
    case layout.LayoutActionTypes.OPEN_LEFT_SIDENAV: { return Object.assign({}, state, { leftSidebarOpened: true }); }
    case layout.LayoutActionTypes.CLOSE_RIGHT_SIDENAV: { return Object.assign({}, state, { rightSidebarOpened: false }); }
    case layout.LayoutActionTypes.OPEN_RIGHT_SIDENAV: { return Object.assign({}, state, { rightSidebarOpened: true }); } 
    default:
      return state;
  }
}

export const getLeftSidenavState = (state:State) => state.leftSidebarOpened;
export const getRightSidenavState = (state:State) => state.rightSidebarOpened;

index.ts中添加一些选择器来访问侧边栏的状态:

export const getLeftSidenavState = (state:State) => state.leftSidebarOpened;
export const getRightSidenavState = (state:State) => state.rightSidebarOpened;

使用


除了将逻辑绑定在组件自身外,还可以通过结合结构型指令来关闭或开启对应的侧边栏。

$ ng g directive sidebar-watch

sidebar-watch.directive.ts

import {
  Directive,
  ElementRef,
  Renderer,
  OnInit,
  AfterViewInit,
  AfterViewChecked
} from '@angular/core';
import {
  Store
} from "@ngrx/store";
import * as fromRoot from "../common/index";
let $ = require('jquery');
@Directive({
  selector: '[sidebarWatch]'
}) export class SidebarWatchDirective implements OnInit {
  constructor(private el: ElementRef, private _store: Store < fromRoot.AppState > ) {} /* Doing the checks on ngOnInit makes sure the DOM is fully loaded and the elements are available to be selected */
  ngOnInit() { /* 监听左侧边栏状态 */
    this._store.select(fromRoot.getLayoutLeftSidenavState).subscribe((state) => {
      if (this.el.nativeElement.className == 'left-sidebar') {
        if (state) {
          $("#main-content").css("margin-left", "300px");
          $(this.el.nativeElement).css('width', '300px');
        } else {
          $("#main-content").css("margin-left", "0");
          $(this.el.nativeElement).css('width', '0');
        }
      }
    }); /* 监听右侧边栏状态 */
    this._store.select(fromRoot.getLayoutRightSidenavState).subscribe((state) => { /* You can use classes (addClass/removeClass) instead of using jQuery css(), or you can go completely vanilla by using selectors such as windiw.getElementById(). . */
      if (this.el.nativeElement.className == 'right-sidebar') {
        console.log('test') if (state) {
          $('#fade').addClass('fade-in');
          $("#rightBar-body").css("opacity", "1");
          $("body").css("overflow", "hidden");
          $(this.el.nativeElement).css('width', '60%');
        } else {
          $('#fade').removeClass('fade-in');
          $("#rightBar-body").css("opacity", "0");
          $("body").css("overflow", "auto");
          $(this.el.nativeElement).css('width', '0');
        }
      }
    });
  }
}

该指令控制ElementRefnativeElement属性,这个属性用于访问组件模板中DOM。当指令知道了(也就是绑定成功)它控制哪个侧边栏以后,便确认其对应的状态是true还是false。然后通过 jQuery 去操作视图中对应的元素。jQuery 的使用能高效的选定元素和改变其属性,并使用原生的 JavaScript 去增删元素的 class。

类似的,我们可以创建一个用于控制侧边栏开关的指令

/** * Created by Centroida-2 on 1/22/2017. */
import {
  Directive,
  Input,
  ElementRef,
  Renderer,
  HostListener
} from '@angular/core';
import {
  Store
} from "@ngrx/store";
import * as fromRoot from "../common/index";
import * as layout from '../common/layout/layout.actions'
@Directive({
  selector: '[sidebarToggle]'
}) export class SidebarToggleDirective {
  public leftSidebarState: boolean;
  public rightSidebarState: boolean;
  @Input() sidebarToggle: string;
  @HostListener('click', ['$event']) onClick(e) { /* 左侧边栏开关 */
    if (this.sidebarToggle == "left" && this.leftSidebarState) {
      this._store.dispatch(new layout.CloseLeftSidenavAction());
    } else if (this.sidebarToggle == "left" && !this.leftSidebarState) {
      this._store.dispatch(new layout.OpenLeftSidenavAction())
    } /* 右侧边栏开关 */
    if (this.sidebarToggle == "right" && this.rightSidebarState) {
      this._store.dispatch(new layout.CloseRightSidenavAction());
    } else if (this.sidebarToggle == "right" && !this.rightSidebarState) {
      this._store.dispatch(new layout.OpenRightSidenavAction());
    }
  }
  constructor(private el: ElementRef, private renderer: Renderer, private _store: Store < fromRoot.AppState > ) {
    this._store.select(fromRoot.getLayoutLeftSidenavState).subscribe((state) => {
      this.leftSidebarState = state;
    });
    this._store.select(fromRoot.getLayoutRightSidenavState).subscribe((state) => {
      this.rightSidebarState = state;
    });
  }
}

该指令有一个输入型属性@Input sidebarToggle,其值可以为left或者right,由它控制的侧边栏位置所决定。每当用户点击元素触发侧边栏行为的时候,@HostListener('click')会捕获这个点击事件并且检查该侧边栏的全局状态,并调用对应的 action。

我们创建两个侧边栏来验证以上的实现:

$ ng g component left-sidebar

left-sidebar-component.ts

import {
  Component
} from '@angular/core';
@Component({
  selector: 'left-sidebar',
  templateUrl: 'left-sidebar.component.html',
  styleUrls: ['./sidebar.styles.css']
}) export class LeftSidebarComponent {
  constructor() {}
}

left-sidebar.component.html

<section sidebarWatch class="left-sidebar">
</section>

接着创建另一个侧边栏

$ ng g component right-sidebar

right-sidebar.component.ts

import {
  Component
} from '@angular/core';
@Component({
  selector: 'right-sidebar',
  templateUrl: 'right-sidebar.component.html',
  styleUrls: ['./sidebar.styles.css']
}) export class RightSidebarComponent {
  constructor() {}
}

right-sidebar.component.html

<section sidebarWatch class="right-sidebar"> 
  <button class="btn btn-primary" sidebarToggle="right">Close Right Sidebar</button>
 </section>

sidebarWatch的使用方式很直观。只需要将它放置在侧边栏组件的顶层元素中。
sidebarToggle需要放置在控制侧边栏开关的元素中(在这里以 button 为例),并且需要将left或者right赋值给这个指令,让它知道自己控制的是哪个侧边栏。
我们需要一些样式来让这些元素看起来更像侧边栏:

$ touch src/app/components/sidebar.styles.css

sidebar.styles.css

.left-sidebar,
.right-sidebar {
  transition: width 0.3s;
  height: 100%;
  position: fixed;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}

.left-sidebar {
  background: #909090;
}

.right-sidebar {
  overflow-y: auto !important;
  overflow-x: hidden !important;
  right: 0;
  z-index: 999 !important;
  background: #212121;
}

在应用的根组件中,将侧边栏放置在 class 为main-content的 div 元素的上方:
app.component.html

<div id="fade" class="fade-in"></div>
<left-sidebar></left-sidebar>
<right-sidebar></right-sidebar>
<div id="main-content"> <button class="btn btn-primary" sidebarToggle="left">Toggle Left Sidebar</button> <button class="btn btn-primary" sidebarToggle="right">Toggle Right Sidebar</button>
  <!-- ... -->
</div>
<!-- ... -->

这个 id 为fade的 div 元素用于在右侧边栏开启的时候实现 fade 过渡效果。为其添加一些样式:

.fade-in {
  postition: absolute;
  min-height: 100%!important;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  background: rgba(0,0,0,0.5);
  width: 100%;
  transition: top 0.3s, right 0.3s, bottom 0.3s, left 0.3s;
}

在这样的设定中,侧边栏完全是无关其容器的。任何元素都可以通过指令在视图中的任何位置来实现和侧边栏一样的效果。并且可以灵活的根据需求去添加底栏或者顶栏组件

example2_sidebar

Dismissable Alerts (可消除的呼出提示)


控制 alert 在应用中何时何地呼出。因为 alert 要么是服务端控制呼出,要么是通过用户行为呈现,因此它理所应当由应用状态进行控制。

与本文的其他示例不同,“reduxifying”的 alert 相比起来要容易一些——它们能根据状态中响应的 alert 项集合的增删进行直接的渲染。

通常情况下,一个 alert 应当由两个属性:messagetype。一下是一个 alert 的属性模型:

export class Alert {
  message: string;
  type: string;
}

首先,我们添加一些 action 来控制 alert 的增删:
layout.actions.ts

export const LayoutActionTypes = {
  ADD_ALERT: '[Layout] add alert',
  REMOVE_ALERT: '[Layout] remove alert'
};
export class AddAlertAction implements Action {
  type = LayoutActionTypes.ADD_ALERT;
  constructor(public payload: Object) {}
}
export class RemoveAlertAction implements Action {
  type = LayoutActionTypes.REMOVE_ALERT;
  constructor(public payload: Object) {}
} 

export type LayoutActions = AddAlertAction | RemoveAlertAction

接着,我们在视图状态中创建 alert 的片段:
layout.reducer.ts

import * as layout from './layout.actions';
export interface State {
  alerts: Array < Object > ;
}
const initialState: State = {
  alerts: [],
};
export function reducer(state = initialState, action: layout.LayoutActions): State {
  switch (action.type) {
    case layout.LayoutActionTypes.ADD_ALERT:
      {
        return Object.assign({}, state, {
          alerts: [...state.alerts, action.payload]
        });
      }
    case layout.LayoutActionTypes.REMOVE_ALERT:
      {
        return Object.assign({}, state, { 
          /* Alerts are filtered by message content, but for real-world usage, an 'id' field would be more suitable. */
          alerts: state.alerts.filter(alert => alert['message'] !== action.payload['message'])
        });
      }
    default:
      return state;
  }
} 
/* If you add more attributes to the alerts such as 'position' or 'modelType', there can be more selectors added that can filter the collection and allow only certain to be displayed in designated places in the application. */
export const getAlerts = (state: State) => state.alerts;

最后,我们在index.ts中为 alert 添加一个选择器:

//..
export const getLayoutAlertsState = createSelector(getLayoutState, fromLayout.getAlerts);

这就是全部了。现在 alert 是应用状态的一部分了。但如和使用呢?我们继续前进。

使用


通过一些工具的使用,构建 alert 只需要非常小段的代码,因为 ng-bootstrap 已经提供了的实现。因此,我们只需要在任何有相应需求的地方复用这个组件:

$ touch src/app/alerts-list.component.ts

alerts.component.ts

import {
  Component,
  Input,
  EventEmitter,
  Output
} from '@angular/core';
@Component({
  selector: 'alerts-list',
  templateUrl: 'alerts-list.component.html',
}) export class AlertsListComponent {
  @Input() alerts: any;
  @Output() closeAlert = new EventEmitter();
  constructor() {}
}

这个组件接收一个包含一些 alert 对象的数组,然后想要关闭的 alert 对应的事件响应。

$ touch src/app/alerts-list.component.html

alerts.component.html

<p *ngFor="let alert of alerts"> 
  <ngb-alert [type]="alert.type" (close)="closeAlert.emit(alert)">{{ alert.message }}</ngb-alert> 
</p>

不要忘记将组件导入到根模块:
app.module.ts

import {
  AlertsListComponent
} from "./components/alerts-list.component";
@NgModule({
  declarations: [
    AlertsListComponent, 
    //...
  ],
}) export class AppModule {}

接下来,容器组件需要实现一个选定 alert 和调起事件的逻辑。
app.component.ts

import {
  Component,
  OnInit
} from '@angular/core';
import {
  Store
} from "@ngrx/store";
import {
  Observable
} from "rxjs";
import * as fromRoot from './common/index';
import * as layout from './common/layout/layout.actions';
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
}) export class AppComponent implements OnInit {
  public alerts$: Observable < any > ;
  constructor(private store: Store < fromRoot.AppState > ) {
    this.alerts$ = store.select(fromRoot.getLayoutAlertsState);
  }
  addAlert(alert) {
    this.store.dispatch(new layout.AddAlertAction(alert))
  }
  onCloseAlert(alert: Object) {
    this.store.dispatch(new layout.RemoveAlertAction(alert))
  }
}

我们用两个呼出不同类型 alert 的按钮来查看 alert 的效果:
app.component.html

<div id="fade" class="fade-in"></div>
<left-sidebar></left-sidebar>
<right-sidebar></right-sidebar>
<div id="main-content">
  <!-- List of alerts goes here -->
  <alerts-list [alerts]="alerts$ | async (closeAlert)=" onCloseAlert($event)
    "></alerts-list> <!-- Buttons for creating alerts --> <button class="btn btn-danger " (click)="addAlert({type: 'danger', message: 'This is a danger alert'})
    ">Add a danger alert</button> <button class="btn btn-success " (click)="addAlert({type: 'success', message: 'This is a success alert'}) ">Add a success alert</button> </div>
example3_alert

在实际的开发方案中,alert 可以在服务端返回一个确切的结果的时候进行呼出。比如,在如下的片段中,当应用处理来自服务端的请求会调用AddAlertAction来呼出 alert。

@Effect() deleteStudent = this._actions.ofType(student.ActionTypes.DELETE_STUDENT).switchMap((action) => this._service.delete(action.payload)).mergeMap(() => {
      return Observable.from([new DeleteStudentSuccessAction(), /* Chain actions - once the server successfully deletes some model, create an alert from it. */ new layout.AddAlertAction({
          type: 'success',
          message: 'Student successfully deleted!')]).catch(() => {
          new layout.AddAlertAction({
            type: 'danger',
            message: 'An error ocurred.'
          }) return Observable.of(new DeleteStudentFailureAction()
          }));
      });

Window size (窗口尺寸)


在应用状态中存储一个监听窗口尺寸的属性能让 Redux 在许多方面相当有用,尤其是在实现响应式的视图、设备相关的行为或者样式(通过 NgClass 或者 NgStyle 等方式)的动态更换的时候。

我们要在窗口尺寸发生改变的时候同时改变应用状态中对应的属性值来让它发挥作用。我们为它添加一个 action:
layout.actions.ts

import {
  Action
} from '@ngrx/store';
export const LayoutActionTypes = {
  // 添加对应窗口拉伸行为的 action
  RESIZE_WINDOW: '[Layout] Resize window'
};
export class ResizeWndowAction implements Action {
  type = LayoutActionTypes.RESIZE_WINDOW;
  constructor(public payload: Object) {}
}
export type LayoutActions = ResizeWndowAction

我们需要windowWidthwindowHeight两个属性来实现窗口尺寸的存储:
layout.reducer.ts

import * as layout from './layout.actions';
export interface State {
  windowHeight: number;
  windowWidth: number;
}
const initialState: State = {
  windowHeight: window.screen.height,
  windowWidth: window.screen.width
};
export function reducer(state = initialState, action: layout.LayoutActions): State {
  switch (action.type) { /* Window resize case */
    case layout.LayoutActionTypes.RESIZE_WINDOW:
      {
        const height: number = action.payload['height'];
        const width: number = action.payload['width'];
        return Object.assign({}, state, {
          windowHeight: height,
          windowWidth: width
        });
      }
    default:
      return state;
  }
}
export const getWindowWidth = (state: State) => state.windowWidth;
export const getWindowHeight = (state: State) => state.windowHeight;

我们直接用window.screen.heightwindow.screen.width的值来作为初始化的状态值。WindowResizeAction附带一个包含窗口高宽值的对象:{width: number, height: number
有许多种方式可以监听窗口的拉伸,但或许最方便也最常用的方式是给根组件修饰器的host属性添加一个监听。如此一来,无论用户在应用的何处,只要发生了窗口拉伸,ResizeWindowAction都会被调用。
app.component.ts

import {
  Component,
  OnInit
} from '@angular/core';
import {
  Store
} from "@ngrx/store";
import {
  Observable
} from "rxjs";
import * as fromRoot from './common/index';
import * as layout from './common/layout/layout.actions';
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
  /* Add this to your AppComponent to listen for window resize events */
  host: {
    '(window:resize)': 'onWindowResize($event)'
  }
}) export class AppComponent implements OnInit {
  constructor(private store: Store < fromRoot.AppState > ) {}
  ngOnInit() {}
  onWindowResize(event) {
    this.store.dispatch(new layout.ResizeWndowAction({
      width: event.target.innerWidth,
      height: event.target.innerHeight
    }))
  }
}

host将会监听窗口拉伸并调用onWindowResize方法,并传入事件对象。这个方法通过event.target获取新的窗口尺寸,然后将其作为参数来调用ResizeWindowAction以更新状态中的值。

使用


最普遍的窗口尺寸需求就是响应式设计。比如,当屏幕像素宽小于 768px(iPad)的时候希望左侧边栏初始状态为收起。用 Redux 实现这个需求非常简单——只需要添加一个 if 语句进行对应操作:
layout.reducers.ts

export function reducer(state = initialState, action: layout.LayoutActions): State {
  switch (action.type) {
    case layout.LayoutActionTypes.RESIZE_WINDOW:
      {
        const height: number = action.payload['height'];
        const width: number = action.payload['width'];
        const leftSidebarState = width < 768 ? false : state.leftSidebarOpened;
        return Object.assign({}, state, {
          windowHeight: height,
          windowWidth: width,
          leftSidebarOpened: leftSidebarState
        });
      }
  }
}

如果是使用 jQuery,相同的实现过程是不那么尽如人意的。然而,有了 Redux,一个三目表达式就可以满足你的需要。

使用 Redux 的时候,所有的逻辑会在一个状态中心隔离开来,并且调试和测试你所有的实现是非常容易。

example4_window-size
example4_window-size

Server-side Pagination (服务端分页)


(译者注:其实 Server-side 在这里的含义并不是指由服务端完成分页,而是强调应用状态在客户端和服务端的联动性)
用 Redux 管理应用分页可以提高应用状态的利用率,并用尽可能少的代码来提高灵活性。

GiantBomb API


我们用 GiantBomb API 来作为数据源来演示 Redux pagination 是如何运作的。我们将请求到 GiantBomb 数据库中的游戏数据,然后对结果进行分页。分页由应用状态管理。

首先,创建games文件夹:

$ mkdir src/app/common/games
$ touch src/app/common/games.actions.ts

games.actions.ts

import {
  type
} from "../util";
import {
  Action
} from "@ngrx/store";
export const GameActionTypes = { /* Because the games collection is asynchronous, there need to be actions to handle each of the stages of the request. */
  LOAD: '[Games] load games',
  LOAD_SUCCESS: '[Games] successfully loaded games',
  LOAD_FAILURE: '[Games] failed to load games',
};
export class LoadGamesAction implements Action {
  type = GameActionTypes.LOAD;
  constructor(public payload: any) {}
}
export class LoadGamesFailedAction implements Action {
  type = GameActionTypes.LOAD_FAILURE;
  constructor() {}
}
export class LoadGamesSuccessAction implements Action {
  type = GameActionTypes.LOAD_SUCCESS;
  constructor(public payload: any) {}
}
export type GameActions = LoadGamesAction | LoadGamesFailedAction | LoadGamesSuccessAction

Redux 中有一个加载异步数据的规则,由LOADLOAD_SUCCESSLOAD_FAILURE三个 action 实现。后两个会在 middleware (Redux 中间件)处理服务端响应的时候调起。
我们梳理一下实现一个分页功能的所需的组成部分,以明确如何来构造这个games的分页状态:

  1. 当前页码
  2. 数据的总量
  3. 当前展示数据的集合
  4. (可选)每个分页展示的数据量

有了如上思路,那么分页状态的接口应当如下所示:

export interface State {
  loaded: boolean;
  loading: boolean;
  entities: Array<any>;
  count: number;
  page: number;
}

我们看看整个功能代码是什么样的:

$ touch src/app/common/games.reducer.ts

games.reducer.ts

import {
  createSelector
} from 'reselect';
import * as games from './games.actions';
export interface State {
  loaded: boolean;
  loading: boolean;
  entities: Array < any > ;
  count: number;
  page: number;
};
const initialState: State = {
  loaded: false,
  loading: false,
  entities: [],
  count: 0,
  page: 1
};
export function reducer(state = initialState, action: games.GameActions): State {
  switch (action.type) {
    case games.GameActionTypes.LOAD:
      {
        const page = action.payload;
        return Object.assign({}, state, {
          loading: true,
          /* If there is no page selected, use the page from the initial state */ page: page == null ? state.page : page
        });
      }
    case games.GameActionTypes.LOAD_SUCCESS:
      {
        const games = action.payload['results'];
        const gamesCount = action.payload['number_of_total_results'];
        return Object.assign({}, state, {
          loaded: true,
          loading: false,
          entities: games,
          count: gamesCount
        });
      }
    case games.GameActionTypes.LOAD_FAILURE:
      {
        return Object.assign({}, state, {
          loaded: true,
          loading: false,
          entities: [],
          count: 0
        });
      }
    default:
      return state;
  }
} /* Selectors for the state that will be later used in the games-list component */
export const getEntities = (state: State) => state.entities;
export const getPage = (state: State) => state.page;
export const getCount = (state: State) => state.count;
export const getLoadingState = (state: State) => state.loading;

每当GamesAction调起LOAD的时候,都会将页码传递到 reducer 并赋值给当前状态。剩下的工作就是用当前分页状态的page去请求服务器。我们需要将这个状态集成到全局中去来实现这个功能。
index.ts

import * as fromGames from "./games/games.reducer"
export interface AppState { 
  layout: fromLayout.State; 
  games: fromGames.State 
} 
export const reducers = { 
  layout: fromLayout.reducer, 
  games: fromGames.reducer 
};

export const getGamesState = (state: AppState) => state.games; 
export const getGamesEntities = createSelector(getGamesState, fromGames.getEntities); 
export const getGamesCount = createSelector(getGamesState, fromGames.getCount); 
export const getGamesPage = createSelector(getGamesState, fromGames.getPage); 
export const getGamesLoadingState = createSelector(getGamesState, fromGames.getLoadingState);

getGamesPage用于取得当前页码并将其作为参数来请求服务端数据。

$ touch src/app/common/games.service.ts

games.service.ts

import {
  Injectable,
  Inject
} from '@angular/core';
import {
  Response,
  Http,
  Headers,
  RequestOptions,
  Jsonp
} from "@angular/http";
import {
  Store
} from "@ngrx/store";
import * as fromRoot from "../index"
@Injectable() export class GamesService {
  public page: number;
  constructor(private jsonp: Jsonp, private store: Store < fromRoot.AppState > ) { /* Get the page from the games state */
    store.select(fromRoot.getGamesPage).subscribe((page) => {
      this.page = page;
    });
  } /* Get the list of games. GiantBomb requires a jsnop request with a token. You can use this token as a present from me, the author, and use it in moderation! */
  query() {
    let pagination = this.paginate(this.page);
    let url = `http://www.giantbomb.com/api/games/?api_key=b89a6126dc90f68a87a6fe1394e64d7312b242da&?&offset=${pagination.offset}&limit=${pagination.limit}&format=jsonp&json_callback=JSONP_CALLBACK`;
    return this.jsonp.request(url, {
      method: 'Get'
    }).map((res) => {
      return res['_body']
    });
  } /** * This function converts a page to a pagination * query. * * @param page * * @returns {{offset: number, limit: number}} */
  paginate(page: number, ) {
    let beginItem: number;
    let endItem: number;
    let itemsPerPage: number = 10;
    if (page == 1) {
      beginItem = 0;
    } else {
      beginItem = (page - 1) * itemsPerPage;
    }
    return {
      offset: beginItem,
      limit: itemsPerPage
    }
  }
}

当前的页码信息是从状态中获取的,并且由paginate进行传递。paginate是一个辅助函数,用于将当前页码信息转换成符合 GiantBomb API 规则的offsetlimit参数。

接下来,我们实现一个用于调用 service 和调起SUCCESSFAILURE action 的中间件。

$ touch src/app/common/games.effects.ts

games.effects.ts

import 'rxjs/add/operator/map';
import 'rxjs/add/operator/catch';
import 'rxjs/add/operator/switchMap';
import {
  Observable
} from 'rxjs/Observable';
import {
  Injectable
} from "@angular/core";
import * as games from "./games.actions";
import {
  Actions,
  Effect
} from "@ngrx/effects";
import {
  GamesService
} from "./games.service";
import {
  LoadGamesSuccessAction
} from "./games.actions";
import {
  LoadGamesFailedAction
} from "./games.actions";
@Injectable() export class GameEffects {
  constructor(private _actions: Actions, private _service: GamesService) {}
  @Effect() loadGames$ = this._actions.ofType(games.GameActionTypes.LOAD).switchMap(() => this._service.query().map((games) => {
    return new LoadGamesSuccessAction(games)
  })).catch(() => Observable.of(new LoadGamesFailedAction()));
}

最后,从@ngrx/effects模块中导入EffectsModule并运行该 effect,再将GamesService以 provider 的形式导入:
app.module.ts

import {
  EffectsModule
} from "@ngrx/effects";
import {
  GameEffects
} from "./common/games/games.effects";
import {
  GamesService
} from "./common/games/games.service";
@NgModule({
  imports: [EffectsModule.run(GameEffects)],
  providers: [GamesService],
  bootstrap: [AppComponent]
}) export class AppModule {}

这个实现为分页功能提供了极大的便利——该应用的状态同时用于客户端的数据呈现和服务端的数据查询。

使用


我们构建一个games-list组件来确认这个分页实现在开发可重用分页功能时的可用性。

之前提到的,分页的实现需要状态中有4个“片段”:

  1. 数据实例的集合
  2. 数据总量
  3. 当前页码
  4. Loading/Loaded 状态

我们先创建一个game-list的模板:

$ ng g component games-list

games-list.component.ts


  Component,
  OnInit,
  Input,
  EventEmitter,
  Output
} from '@angular/core';
@Component({
  selector: 'games-list',
  templateUrl: 'games-list.component.html',
}) export class GamesListComponent { /* The minimim required inputs of a list component using redux */
  @Input() games: any;
  @Input() count: number;
  @Input() page: number;
  @Input() loading: boolean; /* Emit and event when the user clicks on another page */
  @Output() onPageChanged = new EventEmitter < number > ();
  constructor() {}
}

games-list.component.html

<div class="container" *ngIf="games">
  <table class="table table-hover">
    <thead class="thead-inverse">
      <tr>
        <th>Name</th>
      </tr>
    </thead>
    <tbody>
      <tr *ngFor="let game of games">
        <td>{{game?.name}}</td>
      </tr>
    </tbody>
  </table>
  <ngb-pagination [collectionSize]="count" [(page)]="page" (pageChange)="onPageChanged.emit($event)" [maxSize]="10" [disabled]="loading"></ngb-pagination>
</div>

在根模块中声明这个组件:

import {
  GamesListComponent
} from "./components/games-list.component";
@NgModule({
  declarations: [GamesListComponent, ]
})

GamesListComponent使用 ng-bootstrap 中的 ngbPagination 组件来辅助构建。这个组件需要通过输入(@Input)属性来渲染一个分页,而pageChange事件会触发输出(@Output)函数onPageChanged来将动作传递到容器组件。

接下来,我们对容器组件进行完善(在这个示例中,即AppComponent)。

容器组件需要做一些工作来使分页功能运行起来:

  1. 提供相应的状态信息作为GamesListComponent组件的输入
  2. 有一个方法来处理onPageChanged事件

app.component.ts

import * as games from './common/games/games.actions';
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
}) export class AppComponent implements OnInit {
  public games$: Observable < any > ;
  public gamesCount$: Observable < number > ;
  public gamesPage$: Observable < number > ;
  public gamesLoading$: Observable < boolean > ;
  constructor(private store: Store < fromRoot.AppState > ) { /* Select all the parts of the state needed for the GamesListComponent */
    this.games$ = store.select(fromRoot.getGamesEntities);
    this.gamesCount$ = store.select(fromRoot.getGamesCount);
    this.gamesPage$ = store.select(fromRoot.getGamesPage);
    this.gamesLoading$ = store.select(fromRoot.getGamesLoadingState);
  } /* When the component initializes, render the first page ofresults */
  ngOnInit() {
    this.store.dispatch(new games.LoadGamesAction(1));
  }
  onGamesPageChanged(page: number) {
    this.store.dispatch(new games.LoadGamesAction(page))
  }
}

最后,将GamesListComponent的选择器添加到AppComponent的模板。
app.component.html

<div id="main-content"> <!-- ... --> 
  <games-list [games]="games$ | async" [count]="gamesCount$ | async" [page]="gamesPage$ | async" [loading]="gamesLoading$ | async" (onPageChanged)="onGamesPageChanged($event)"></games-list> 
</div>

async管道在这里会提取对应 observable 变量最近一次的值,同时监听状态的更新,然后作为输入属性传递到组件中去。

以下是分页功能在触发 action 时的运行图示:


example5_Pagination
example5_Pagination

结论


这些示例代表了许多你在使用 Angular 2/4 + Redux 构建应用时可能遇到的需求。它们在很大程度上提供了一个样板示例供更明确的需求实现,在此也希望提供更多的 idea 来实现其他需求。

Redux 在控制视图状态上表现得优秀吗?在我看来,这是毫无疑问的。在开发的时候它可能需要比较多一点的代码量,但是当应用的代码基础不断增强以及逻辑的可复用性越来越强的时候,Redux 的光辉,谁也掩盖不了。

推荐阅读更多精彩内容