[+]文章目录

Angular2和Rx的相关知识可以看我的Angular 2.0 从0到1系列

 

标题写错了吧,是React吧?没错,你没看错,就是Angular2。如果说RxJS是Angular2开发中的倚天剑,那么Redux就是屠龙刀了。而且这两种神兵利器都是不依赖于平台的,左手倚天右手屠龙......算了,先不YY了,回到正题。

Redux目前越来越火,已经成了React开发中的事实标准。火到什么程度,Github上超过26000星。

Redux的Github项目页面,超过26000星

那么什么到底Redux做了什么?这件事又和Angular2有几毛钱关系?别着急,我们下面就来讲一下。

什么是Redux?

Redux是为了解决应用状态(State)管理而提出的一种解决方案。那么什么是状态呢?简单来说对于应用开发来讲,UI上显示的数据、控件状态、登陆状态等等全部可以看作状态。

我们在开发中经常会碰到,这个界面的按钮需要在某种情况下变灰;那个界面上需要根据不同情况显示不同数量的Tab;这个界面的某个值的设定会影响另一个界面的某种展现等等。应该说应用开发中最复杂的部分就在于这些状态的管理。很多项目随着需求的迭代,代码规模逐渐扩大、团队人员水平参差不齐就会遇到各种状态管理极其混乱,导致代码的可维护性和扩展性降低。

那么Redux怎么解决这个问题呢?它提出了几个概念:Reducer、Action、Store。

Store

可以把Store想象成一个数据库,就像我们在移动应用开发中使用的SQLite一样,Store是一个你应用内的数据(状态)中心。Store在Redux中有一个基本原则:它是一个唯一的、状态不可修改的树,状态的更新只能通过显性定义的Action发送后触发。

Store中一般负责:保存应用状态、提供访问状态的方法、派发Action的方法以及对于状态订阅者的注册和取消等。

遵守这个约定的话,任何时间点的Store的快照都可以提供一个完整当时的应用状态。这在调试应用时会变得非常方便,有没有想过在调试时可以任意的返回前面的某一时间点?Redux的TimeMachine调试器会带我们进行这种时光旅行,后面我们会一起体验!

Reducer

我在有一段时间一直觉得Reducer这个东西不好理解,主要原因有两个:

其一是这个英语单词有多个含义,在词典上给出的最靠前的意思是渐缩管和减压阀。我之前一直望文生义的觉得这个Reducer应该有减速作用,感觉是不是和Rx的zip有点像(这个理解是错的,只是当时看到这个词的感觉)。

其二是我看了Redux的作者的一段视频,里面他用数组的reduce方法来做类比,而我之前对reduce的理解是reduce就是对数组元素进行累加计算成为一个值。

数组的reduce方法定义

其实作者也没有说错,因为数组的reduce操作就是给出不断的用序列中的值经过累加器计算得到新的值,这和旧状态进入reducer经处理返回新状态是一样的。只不过打的这个比方我比较无感。

这两个因素导致我当时没理解正确reducer的含义,现在我比较喜欢把reducer的英文解释成是“异形接头”(见下图)。Reducer的作用是接收一个状态和对应的处理(Action),进行处理后返回一个新状态。

很多网上的文章说可以把Reducer想象成数据库中的表,也就是Store是数据库,而一个reducer就是其中一张表。我其实觉得Reducer不太像表,还是觉得这个“异形接头”的概念比较适合我。

异形接头

Reducer是一个纯javascript函数,接收2个参数:第一个是处理之前的状态,第二个是一个可能携带数据的动作(Action)。就是类似下面给出的接口定义,这个是TypeScript的定义,由于JavaScript中没有强类型,所以用TypeScript来理解一下。

export interface Reducer {
  (state: T, action: Action): T;
}

那么纯函数是意味着什么呢?意味着我们理论上可以把reducer移植到所有支持Redux的框架上,不用做改动。下面我们来看一段简单的代码:

export const counter: Reducer = (state = 0, action) => {
    switch(action.type){
        case 'INCREMENT':
            return state + action.payload;
        case 'DECREMENT':
            return state - action.payload;
        default:
            return state;
    }
};

上面的代码定义了一个计数器的Reducer,一开始的状态初始值为0((state = 0, action) 中的 state=0 给state赋了一个初始状态值)根据Action类型的不同返回不同的状态。这段代码就是非常简单的javascript,不依赖任何框架,可以在React中使用,也可以在接下来的我们要学习的Angular2中使用。

Action

Store中存储了我们的应用状态,Reducer接收之前的状态并输出新状态,但是我们如何让Reducer和Store之间通信呢?这就是Action的职责所在。在Redux规范中,所有的会引发状态更新的交互行为都必须通过一个显性定义的Action来进行。

下面的示意图描述了如果使用上面代码的Reducer,显性定义一个Action {type: 'INCREMENT', payload: 2} 并且 dispatch 这个Action后的流程。

显性定义的Action触发Reducer产生新的状态

比如说之前的计数器状态是1,我们派送这个Action后,reducer接收到之前的状态1作为第一个参数,这个Action作为第二个参数。在Switch分支中走的是INCRMENT这个流程,也就是state+action.payload,输出的新状态为3.这个状态保存到Store中。

值得注意的一点是payload并不是一个必选项,看一下Action的TypeScript定义,注意到 payload?: any 那个 ? 没有,那个就是说这个值可以没有。

export interface Action {
  type: string;
  payload?: any;
}

为什么要在Angular2中使用?

首先,正如C#当初在主流强类型语言中率先引入Lamda之后,现在Java8也引入了这个特性一样,所有的好的模式、好的特性最终会在各个平台框架上有体现。Redux本身在React社区中的大量使用本身已经证明这种状态管理机制是非常健壮的。

再有我们可以来看一下在Angular中现有的状态管理机制是什么样子的。目前的管理机制就是...嗯...没有统一的状态管理机制。

遍地开花的Angular状态管理

这种没有统一管理机制的情况在一个大团队是很恐怖的事情,状态管理的代码质量完全看个人水平,这样会导致功能越来越多的应用中的状态几乎是无法测试的。

还是用代码来说话吧,下面我们看一下一个不用Redux管理的Angular应用是怎样的。我们就拿最常见的Todo应用来解析(题外话:这个应用已经变成web框架的标准对标项目了,就像上个10年的PetStore是第一代web框架的对标项目一样。)

第一种状态管理:我们在组件中管理。在组件中可以声明一个数组,这个数组作为todo的内存存储。每次操作比如新增(addTodo)或切换状态(toggleTodo)首先调用服务中的方法,然后手动操作数组来更新状态。

export class TodoComponent implements OnInit {
  desc: string = '';
  todos : Todo[] = [];//在组件中建立一个内存TodoList数组

  constructor(
    @Inject('todoService') private service,
    private route: ActivatedRoute,
    private router: Router) {}
  ngOnInit() {
    this.route.params.forEach((params: Params) => {
      let filter = params['filter'];
      this.filterTodos(filter);
    });
  }
  addTodo(){
    this.service
      .addTodo(this.desc) //通过服务新增数据到服务器数据库
      .then(todo => {//更新todos的状态
        this.todos.push(todo);//使用了可改变的数组操作方式
      });
  }
  toggleTodo(todo: Todo){
    const i = this.todos.indexOf(todo);
    this.service
      .toggleTodo(todo)//通过服务更新数据到服务器数据库
      .then(t => {//更新todos的状态
        const i = todos.indexOf(todo);
        todos[i].completed = todo.completed; //使用了可改变的数组操作方式
      });
  }
  ...

第二种方式呢,我们在服务中做类似的事情。在服务中定义一个内存存储(dataStore),然后同样是在更新服务器数据后手动更新内存存储。这个版本当中我们使用了RxJS,但大体逻辑是差不多的。当然使用Rx的好处比较明显,组件只需访问todos属性方法即可,组件内的逻辑会比较简单。

...
export class TodoService {

  private api_url = 'http://localhost:3000/todos';
  private headers = new Headers({'Content-Type': 'application/json'});
  private userId: string;
  private _todos: BehaviorSubject; 
  private dataStore: {  // 我们自己实现的内存数据存储
    todos: Todo[]
  };
  constructor(private http: Http, @Inject('auth') private authService) {
    this.authService.getAuth()
      .filter(auth => auth.user != null)
      .subscribe(auth => this.userId = auth.user.id);
    this.dataStore = { todos: [] };
    this._todos = new BehaviorSubject([]);
  }
  get todos(){
    return this._todos.asObservable();
  }
  // POST /todos
  addTodo(desc:string){
    let todoToAdd = {
      id: UUID.UUID(),
      desc: desc,
      completed: false,
      userId: this.userId
    };
    this.http
      .post(this.api_url, JSON.stringify(todoToAdd), {headers: this.headers})
      .map(res => res.json() as Todo) //通过服务新增数据到服务器数据库
      .subscribe(todo => {
        //更新内存存储todos的状态
        //使用了不可改变的数组操作方式
        this.dataStore.todos = [...this.dataStore.todos, todo];
        //推送给订阅者新的内存存储数据
        this._todos.next(Object.assign({}, this.dataStore).todos);
      });
  }
  toggleTodo(todo: Todo) {
    const url = `${this.api_url}/${todo.id}`;
    const i = this.dataStore.todos.indexOf(todo);
    let updatedTodo = Object.assign({}, todo, {completed: !todo.completed});
    this.http
      .patch(url, JSON.stringify({completed: !todo.completed}), {headers: this.headers})//通过服务更新数据到服务器数据库
      .subscribe(_ => {
        //更新内存存储todos的状态
        this.dataStore.todos = [
          ...this.dataStore.todos.slice(0,i),
          updatedTodo,
          ...this.dataStore.todos.slice(i+1)
        ];//使用了不可改变的数组操作方式
        //推送给订阅者新的内存存储数据
        this._todos.next(Object.assign({}, this.dataStore).todos);
      });
  }
...
}

当然还有很多方式,比如服务中维护一部分,组件中维护一部分;再比如说有的同学可能使用localStorage做存储,每次读来写去等等。

不是说这些方式不好(如果可以保持项目组内的规范统一,项目较小的情况下也还可以),而是说代码编写的方式太多了,而且状态分散在各个组件和服务中,没有统一管理。一个小项目可能还没有问题,但大项目就会发现内存状态很难统一维护。

更不用说在Angular2中我们写了很多组件里的EventEmitter只是为了把某个事件弹射到父组件中而已。而这些在Redux的模式下,都可以很方便的解决,我们同样可以很自由的在服务或组件中引用store。但不管怎样编写,我们遵守的同样的规则,维护的是应用唯一状态树。

Angular 1.x永久的改变了JQuery类型的web开发,使得我们可以像写手机客户端App一样来鞋前端代码。Redux也一样改变了状态管理的写法,Redux其实不仅仅是一个类库,更是一种设计模式。而且在Angular2 中由于有RxJS,你会发现我们甚至比在React中使用时更方便更强大。

在Angular 2中使用Redux

ngrx是一套利用RxJS的类库,其中的 @ngrx/store (https://github.com/ngrx/store) 就是基于Redux规范制定的Angular2框架。接下来我们一起看看如何使用这套框架做一个Todo应用。

打造一个有Http后台的Todo列表应用

对Angular2 不熟悉的童鞋可以去 https://github.com/wpcfan/awesome-tutorials/blob/master/angular2/ng2-tut/README.md 看我的Angular 2: 从0到1系列

简单内存版

当然第一步是安装 npm install @ngrx/core @ngrx/store --save。然后需要在你想要使用的Module里面引入store,我推荐在根模块 AppModule或CoreModule(把只在应用中加载一次的全局性东东单独放到一个Module中然后在AppModule引入) 引入这个包,因为Store是整个应用的状态树。

import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core';
import { AuthService } from './auth.service';
import { UserService } from './user.service';
import { AuthGuardService } from './auth-guard.service';

import { HttpModule, JsonpModule } from '@angular/http';
import { StoreModule } from '@ngrx/store';
import { todoReducer, todoFilterReducer } from '../reducers/todo.reducer';
import { authReducer } from '../reducers/auth.reducer';

@NgModule({
  imports:[
    HttpModule
    StoreModule.provideStore({ 
      todos: todoReducer, 
      todoFilter: todoFilterReducer
    })
  ],
  providers: [
    AuthService,
    UserService,
    AuthGuardService
    ]
})
export class CoreModule {
  constructor (@Optional() @SkipSelf() parentModule: CoreModule) {
    if (parentModule) {
      throw new Error(
        'CoreModule is already loaded. Import it in the AppModule only');
    }
  }
}

我们看到StoreModule提供了一个provideStore方法,在这个方法中我们声明了一个 { todos: todoReducer, todoFilter: todoFilterReducer }对象,这个就是Store。前面讲过Store可以想象成数据库,Reducer可以想象成表,那么这样一个对象形式告诉我们数据库是由那些表构成的(这个地方把Reducer想象成表还是有道理的).

那么可以看到我们定义了两个Reducer:todoReducer和todoFilterReducer。在看代码之前,我们来思考一下这个流程,所谓Reducer其实就是接收两个参数:之前的状态和要采取的动作,然后返回新的状态。可能动作更好想一些,先看看有什么动作吧:

  • 新增一个Todo
  • 删除一个Todo
  • 更改Todo的完成状态
  • 全部反转Todo的完成状态
  • 清除已完成的Todo
  • 筛选全部Todo
  • 筛选未完成的Todo
  • 筛选已完成的Todo

但是仔细分析一下发现后三个动作其实和前面的不太一样,因为后面的三个都属于筛选,并未改动数据本身。也不用提交后台服务,只需要对内存数据做简单筛选即可。前面几个都需要不光改变内存数据也需要改变服务器数据。

这里我们先尝试着写一下前面五个动作对应的Reducer,按前面定义的就叫todoReducer吧,一开始也不知道怎么写好,那就先写个骨架吧:

export const todoReducer = (state = [], {type, payload}) => {
  switch (type) {
    default:
      return state;
  }
}

即使是个骨架,也有很多有意思的点。

第一个参数是state,就像我们在组件或服务中自己维护了一个内存数组一样,我们的Todo状态其实也是一个数组,我们还赋了一个空数组的初始值(避免出现undefined错误)。

第二个参数是一个有type和payload两个属性的对象,其实就是Action。也就是说我们其实可以不用定义Action,直接给出构造的对象形式即可。内部的话其实reducer就是一个大的switch语句,根据不同的Action类型决定返回什么样的状态。默认状态下我们直接将之前状态返回即可。Reducer就是这么单纯的一个函数。

现在我们来考虑其中一个动作,增加一个Todo,我们需要发送一个Action,这个Action的type是 ’ADD_TODO’ ,payload就是新增加的这个Todo。

逻辑其实就是列表数组增加一个元素,用数组的push方法直接做是不是就行了呢?不行,因为Redux的约定是必须返回一个新状态,而不是更新原来的状态。而push方法其实是更新原来的数组,而我们需要返回一个新的数组。感谢ES7的Object Spread操作符,它可以让我们非常方便的返回一个新的数组。

export const todoReducer = (state = [], {type, payload}) => {
  switch (type) {
    case 'ADD_TODO':
      return [
          ...state,
          action.payload
          ];
    default:
      return state;
  }
}

现在我们已经有了一个可以处理 ADD_TODO 类型的Reducer。可能有的同学要问这只是改变了内存的数据,我们怎么处理服务器的数据更改呢?要不要在Reducer中处理?答案是服务器数据处理的逻辑是服务(Service)的职责,Reducer不负责那部分。后面我们会处理服务器的数据更新的。

接下来工作就很简单了,我们在TodoComponent中去引入Store并且在适当的时候dispatch ‘ADD_TODO’这个Action就OK了。

...
export class TodoComponent {
  ...
  todos : Observable;
  constructor(private store$: Store) {
  ...
    this.todos = this.store$.select('todos');
  }

  addTodo(desc: string) {
    let todoToAdd = {
      id: '1',
      desc: desc,
      completed: false
    }
    this.store$.dispatch({type: 'ADD_TODO', todoToAdd});
  }
  ...
}

利用Angular提供的依赖性注入(DI),我们可以非常方便的在构造函数中注入Store。由于Angular2对于RxJS的内建支持以及 @ngrx/store 本身也是基于RxJS来构造的,我们完全不用Redux的注册订阅者等行为,访问todos这个状态,只需要写成 this.store$.select('todos')就可以了。这个store后面有个 $ 符号是表示这是一个流(Stream,只是写法上的惯例),也就是Observable。然后在addTodo方法中把action发送出去就完事了,当然这个方法是在按Enter键时触发的。


 

似乎有点太简单了吧,但真的是这样,比在React中使用还要简便。Angular2中对于Observable类型的变量提供了一个Async Pipe,就是 todos | async ,我们连在OnDestroy中取消订阅都不用做了。

下面我们把reducer的其他部分补全吧。除了处理todoReducer中其他的swtich分支,我们为其添加了强类型,既然是在Angular2中使用TypeScript开发,我们还是希望享受强类型带来的各种便利之处。另外总是对于Action的Type定义了一系列常量。

import { Reducer, Action } from '@ngrx/store';
import { Todo } from '../domain/entities';
import { 
  ADD_TODO, 
  REMOVE_TODO, 
  TOGGLE_TODO,
  TOGGLE_ALL,
  CLEAR_COMPLETED,
  FETCH_FROM_API,
  VisibilityFilters
} from '../actions/todo.action';

export const todoReducer = (state: Todo[] =[], action: Action) => {
  switch (action.type) {
    case ADD_TODO:
      return [
          ...state,
          action.payload
          ];
    case REMOVE_TODO:
      return state.filter(todo => todo.id !== action.payload.id);
    case TOGGLE_TODO:
      return state.map(todo => {
        if(todo.id !== action.payload.id){
          return todo;
        }
        return Object.assign({}, todo, {completed: !todo.completed});
      });
    case TOGGLE_ALL:
      return state.map(todo => {
        return Object.assign({}, todo, {completed: !todo.completed});
      });
    case CLEAR_COMPLETED:
      return state.filter(todo => !todo.completed);
    case FETCH_FROM_API:
      return [
        ...action.payload
      ];
    default:
      return state;
  }
}

export const todoFilterReducer = (state = (todo: Todo) => todo, action: Action) => {
  switch (action.type) {
    case VisibilityFilters.SHOW_ALL:
      return todo => todo;
    case VisibilityFilters.SHOW_ACTIVE:
      return todo => !todo.completed;
    case VisibilityFilters.SHOW_COMPLETED:
      return todo => todo.completed;
    default:
      return state;
  }
}

上面的todoReducer看起来倒还是很正常,这个todoFilterReducer却形迹十分可疑,它的state看上去是个函数。是的,你的判断是对的,的确是函数。

为什么我们要这么设计呢?原因是这几个过滤器,其实只是对内存数组进行筛选操作,那么就可以通过 arr.filter(callback[, thisArg]) 来进行筛选。数组的filter方法的含义是对于数组中每一个元素通过callback的测试,然后返回值组成一个新数组。所以这个Reducer中我们的状态其实是不同条件的测试函数,就是那个callback。

好,我们一起把这个没有后台API的版本先完成了吧,要完成的其他部分都很简单,比如toggle、remove什么的,因为只是调用store的dispatch方法把Action发送出去即可。

...
export class TodoComponent {

  todos : Observable;

  constructor(
    private service: TodoService,
    private route: ActivatedRoute,
    private store$: Store) {
      const fetchData$ = this.store$.select('todos')
        .startWith([]);
      const filterData$ = this.store$.select('todoFilter');
      this.todos = Observable.combineLatest(
        fetchData$,
        filterData$,
        (todos: Todo[], filter: any) => todos.filter(filter)
      )
    }
  ngInit(){
    this.route.params.pluck('filter')
      .subscribe(value => {
        const filter = value as string;
        this.store$.dispatch({type: filter});
      })
  }
  addTodo(desc: string) {
    let todoToAdd = {
      id: UUID.UUID(),
      desc: desc,
      completed: false
    };
    this.store$.dispatch({
      type: ADD_TODO, 
      payload: todoToAdd
    });
  }
  toggleTodo(todo: Todo) {
    let updatedTodo = Object.assign({}, todo, {completed: !todo.completed});
    this.store$.dispatch({
      type: TOGGLE_TODO, 
      payload: updatedTodo
    });
  }
  removeTodo(todo: Todo) {
    this.store$.dispatch({
      type: REMOVE_TODO,
      payload: todo
    });
  } 
  toggleAll(){
    this.store$.dispatch({
      type: TOGGLE_ALL
    });
  }
  clearCompleted(){
    this.store$.dispatch({
      type: CLEAR_COMPLETED
    });
  }
}

我们一起看看过滤器部分怎么处理我们实现的,我们知道目前有两个和todo有关的Reducer:todoReducer和todoFilterReducer。这两个应该是配合来影响状态的,我们不可以在没有任何一方的情况下独立返回正常的状态。怎么理解呢?打个比方吧,我们添加了几个Todo之后,这些Todo肯定满足某个过滤器的条件测试,而不可能存在一个Todo在任何一个过滤器中都不满足其条件。

那么如何配合处理这两个状态流呢(在@ngrx/store中,它们都是流)?重新描述一下对这两个流的要求,为方便起见,我们叫todos流和filter流。我们想要这样的一个合并流,这个合并流的数据来自于todos流和filter流。而且合并流的每个数据都来自于一对最新的todos流数据和filter流数据,当然存在一种情况:一个流产生了新数据,但另一个没有。这种情况下,我们会使用新产生的这个数据和另一个流中之前最新的那个配对产生合并流的数据。

这在Rx世界太简单了,combineLatest操作符干的就是这样一件事。于是我们看到下面这段代码:我们合并了todos流和filter流,而且在以它们各自的最新数据为参数的一个函数产生了新的合并流的数据 todos.filter(filter)。稍微解释一下,todos流中的数据就是todo数组,我们在todoReducer中就是这样定义的,而filter流中的数据是一个函数,那么我们其实就是使用从todos流中的最新数组,调用todos.filter方法然后把filter流中的最新的函数当成todos.filter的参数。

const fetchData$ = this.store$.select('todos').startWith([]);
const filterData$ = this.store$.select('todoFilter');
this.todos = Observable.combineLatest(
  fetchData$,
  filterData$,
  (todos: Todo[], filter: any) => todos.filter(filter)
)

还有一处需要解释并且优化的代码位于ngInit中的那段,我们把它分拆出来列在下面。我们在Todo里面实现过滤器时使用的是Angular2的路由参数,也就是 todo/:filter 这种形式(我们定义在 todo-routing.module.ts 中了 ),比如如果过滤器是 ALL,那么这个表现形式就是 todo/ALL。下面代码中的 this.route.params.pluck('filter') 就是取得这个filter路由参数的值。然后我们dispatch了要进行过滤的action。

ngInit(){
  this.route.params.pluck('filter')
    .subscribe(value => {
      const filter = value as string;
      this.store$.dispatch({type: filter});
    })
  }

虽说现在的形式已经可以正常工作了,但总觉得这个路由参数的获取单独放在这里有点别扭,因为逻辑上这个路由参数流和filter流是有先后顺序的,而且后者依赖前者,但这种逻辑关系没有体现出来。

嗯,来优化一下,Rx的一个优点就是可以把一系列操作串(chain)起来。从时间序列上看这个路由参数的获取是先发生的,然后获取到这个参数filter流才会有作用,那么我们优化的点就在于怎么样把这个路由参数流和filter流串起来。

const filterData$ = this.route.params.pluck('filter')
  .do(value => {
    const filter = value as string;
    this.store$.dispatch({type: filter});
  })
  .flatMap(_ => this.store$.select('todoFilter'));

上面的代码把原来独立的两个流串了起来,逻辑关系有两层:

首先时间顺序要保证,也就是说路由参数的先有数据后 this.store$.select('todoFilter') 才可以工作。 do 相当于在语句中间临时subscribe一下,我们在此时发送了Action。

再有我们并不关心路由参数流的数据,我们只是关心它什么时候有数据,所以我们在 flatMap 语句中把参数写成了 _。

到这里,我们的内存版redux化的Angular2 Todo应用就搞定了。

时光旅行调试器 -- Redux TimeMachine Debugge

在介绍HTTP后台版本之前,我们要隆重推出大名鼎鼎的Redux时光旅行调试器。首先需要下载Redux DevTools for Chrome,在Chrome商店中搜索 Redux DevTools即可。

image_1b4oekl1o18829t616cv1jd7u3jm.png-232.7kB

安装好插件之后,我们需要在为 @ngrx/store 安装一个dev-tools的npm包: npm install @ngrx/store-devtools --save

然后在AppModule或CoreModule的Module元数据中加上 StoreDevtoolsModule.instrumentOnlyWithExtension()

...
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
@NgModule({
  imports:[
    ...
    StoreModule.provideStore({ 
      todos: todoReducer, 
      todoFilter: todoFilterReducer
    }),
    StoreDevtoolsModule.instrumentOnlyWithExtension()
  ],
  ...
})

这样就配置好了,让我们先看看它长什么样吧,打开浏览器进入todo应用。对了,别忘打开chrome的开发者工具,你应该可以看到Redux那个Tab,切换过去就好。

右侧的就是Redux DevTools

为什么叫它时光旅行调试器呢?因为传统的Debugger只能单向的往前走,不能回退。还记得我们有多少时间浪费在不断重新调试,一步步跟踪,不断添加watch的变量吗?这一切在Redux中都不存在,我们可以时光穿梭到任何一个已发生的步骤。而且我们可以选择看看如果没有某个步骤会是什么样子。

我们来试验一下,对于显示的某个todo做切换完成状态,然后我们会发现右侧的Inspector随即出现了TOGGLE_TODO的Action。你如果点一下这个Action,会发现出现了一个Skip按钮,点一下这个按钮吧,刚才那个Item的状态又恢复成之前的样子了。其实点任何一个步骤都没问题。

点击某个Action可以体验时光旅行

而且可以随时试验手动编辑一个Action,发射出去会是什么样子。还有很多其他功能,大家自己试验摸索吧。

在调试器中可以随时建立一个Action并发射出去

带HTTP后台版本

在前面铺垫的基础上,做这个版本很容易了。我们用json-server可以快速建立一套REST的Web API。json-server只需要我们提供一个json数据样本就可以完成Web API了,我们的样本json是这样的:

{
  "todos": [
    {
      "id": "6e628423-be05-204f-f075-527cc1bb10d8",
      "desc": "have lunch",
      "completed": false
    },
    {
      "id": "40ab7081-cab9-5900-4048-f4ea905afd2f",
      "desc": "take a break",
      "completed": false
    },
    {
      "id": "6ae06293-23d4-c0ca-ee5b-880365dbd48b",
      "desc": "having fun",
      "completed": false
    },
    {
      "id": "e54f5e86-a781-acd5-1d16-8b878c7cba5d",
      "desc": "have a test",
      "completed": true
    }
  ]
}

然后把这个数据文件起个名,比如叫 data.json 放在 src/app 下,使用 json-server ./src/app/data.json 启动api服务。

现在我们再来梳理一下如果使用后台版本的逻辑,我们的现在的数据源其实是来自于服务器API的,每次更改Todo后也都要提交到服务器。这个联动关系比较强,也就是说必须要服务器返回成功数据后才能进行内存状态的改变。这种情况下我们似乎应该把某些dispatch的动作放到service中。拿addTodo举个例子,我们post到服务器一个新增todo的请求后在发送了dispatch ADD_TODO的消息,这时内存状态就会根据这个进行状态的迁转。

import { Injectable, Inject } from '@angular/core';
import { Http, Headers } from '@angular/http';
import { UUID } from 'angular2-uuid';

import { Observable } from 'rxjs/Observable';
import { Store } from '@ngrx/store';
import { Todo } from '../domain/entities';

import {
  ADD_TODO,
  TOGGLE_TODO,
  REMOVE_TODO,
  TOGGLE_ALL,
  CLEAR_COMPLETED
} from '../actions/todo.action'

@Injectable()
export class TodoService {

  private api_url = 'http://localhost:3000/todos';
  private headers = new Headers({'Content-Type': 'application/json'});
  private userId: string;

  constructor(
    private http: Http, 
    @Inject('auth') private authService,
    private store$: Store
    ) {
    this.authService.getAuth()
      .filter(auth => auth.user != null)
      .subscribe(auth => this.userId = auth.user.id);
  }

  // POST /todos
  addTodo(desc:string): void{
    let todoToAdd = {
      id: UUID.UUID(),
      desc: desc,
      completed: false
    };
    this.http
      .post(this.api_url, JSON.stringify(todoToAdd), {headers: this.headers})
      .map(res => res.json() as Todo)
      .subscribe(todo => {
        this.store$.dispatch({type: ADD_TODO, payload: todo});
      });
  }
  // PATCH /todos/:id 
  toggleTodo(todo: Todo): void {
    const url = `${this.api_url}/${todo.id}`;
    let updatedTodo = Object.assign({}, todo, {completed: !todo.completed});
    this.http
      .patch(url, JSON.stringify({completed: !todo.completed}), {headers: this.headers})
      .mapTo(updatedTodo)
      .subscribe(todo => {
        this.store$.dispatch({
          type: TOGGLE_TODO, 
          payload: updatedTodo
        });
      });
  }
  // DELETE /todos/:id
  removeTodo(todo: Todo): void {
    const url = `${this.api_url}/${todo.id}`;
    this.http
      .delete(url, {headers: this.headers})
      .mapTo(Object.assign({}, todo))
      .subscribe(todo => {
        this.store$.dispatch({
          type: REMOVE_TODO,
          payload: todo
        });
      });
  }
  // GET /todos
  getTodos(): Observable {
    return this.http.get(`${this.api_url}?userId=${this.userId}`)
      .map(res => res.json() as Todo[]);
  }

  toggleAll(): void{
    this.getTodos()
      .flatMap(todos => Observable.from(todos))
      .flatMap(todo=> { 
        const url = `${this.api_url}/${todo.id}`;
        let updatedTodo = Object.assign({}, todo, {completed: !todo.completed});
        return this.http
          .patch(url, JSON.stringify({completed: !todo.completed}), {headers: this.headers})
      })
      .subscribe(()=>{
        this.store$.dispatch({
          type: TOGGLE_ALL
        });
      })
  }

  clearCompleted(): void {
    this.getTodos()
      .flatMap(todos => Observable.from(todos.filter(t => t.completed)))
      .flatMap(todo=> {
        const url = `${this.api_url}/${todo.id}`;
        return this.http
          .delete(url, {headers: this.headers})
      })
      .subscribe(()=>{
        this.store$.dispatch({
          type: CLEAR_COMPLETED
        });
      });
  }
}

增删改这些操作应该都没有问题了,但此时存在一个新问题:内存状态如何可以通过服务器得到初始值呢?原来的内存版本中,我们初始化就是一个空数组,但现在不一样了,你可能会有上次已经创建好的todo需要在一开始显示出来。

如何改变那个初始值呢?但如果换个角度想,现在引入了服务器之后,我们从服务器取数据完全可以定义一个新的Action,比如叫 FETCH_FROM_API 吧。我们现在只需要从服务器取得新数据后发送这个Action,应用状态就会根据取得的最新服务器数据刷新了。

import { Component, Inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { TodoService } from './todo.service';
import { Todo } from '../domain/entities';
import { UUID } from 'angular2-uuid';
import { Store } from '@ngrx/store';
import {
  FETCH_FROM_API
} from '../actions/todo.action'

import { Observable } from 'rxjs/Observable';

@Component({
  templateUrl: './todo.component.html',
  styleUrls: ['./todo.component.css']
})
export class TodoComponent {

  todos : Observable;

  constructor(
    private service: TodoService,
    private route: ActivatedRoute,
    private store$: Store) {
      const fetchData$ = this.service.getTodos()
        .flatMap(todos => {
          this.store$.dispatch({type: FETCH_FROM_API, payload: todos});
          return this.store$.select('todos')
        })
        .startWith([]);
      const filterData$ = this.route.params.pluck('filter')
        .do(value => {
          const filter = value as string;
          this.store$.dispatch({type: filter});
        })
        .flatMap(_ => this.store$.select('todoFilter'));
      this.todos = Observable.combineLatest(
        fetchData$,
        filterData$,
        (todos: Todo[], filter: any) => todos.filter(filter)
      )
    }

  addTodo(desc: string) {
    this.service.addTodo(desc);
  }
  toggleTodo(todo: Todo) {
    this.service.toggleTodo(todo);
  }
  removeTodo(todo: Todo) {
    this.service.removeTodo(todo);
  } 
  toggleAll(){
    this.service.toggleAll();
  }
  clearCompleted(){
    this.service.clearCompleted();
  }
}

现在服务器版本算是可以工作了,打开浏览器试一试吧。现在我们的代码非常清晰:组件中不处理事务逻辑,只负责调用服务的方法。服务中只负责提交数据到服务器和发送动作。所有的应用状态都是通过Redux处理的。

服务器版本可以正常工作了

一点小思考

虽然服务器版本可以work了,但为什么获取数据和fitler这段不可以放在服务中呢?为什么要遗留这部分代码在组件中?这个问题很好,我们一起来试验一下,实践是检验真理的唯一标准。

把组件构造函数中的代码移到Service的构造函数中,当然同样在Service中注入ActiveRoutes。

const fetchData$ = this.getTodos() 
  .do(todos => { 
    this.store$.dispatch({ 
     type: FETCH_FROM_API, 
     payload: todos 
    }) 
  }) 
  .flatMap(this.store$.select('todos')) 
  .startWith([]); 
const filterData$ = this.route.params.pluck('filter') 
  .do(value => { 
    const filter = value as string; 
    this.store$.dispatch({type: filter}); 
  }) 
  .flatMap(_ => this.store$.select('todoFilter')); 
this.todos = Observable.combineLatest( 
  fetchData$, 
  filterData$, 
  (todos: Todo[], filter: any) => todos.filter(filter) 
)

事实是残酷的,报错了

悲催的是,和我们想象的完全不一样,报错了。这是由于Service默认情况下是单件形式(Singleton),而ActivatedRoutes并不是,所以注入到service的routes并不是后来激活的那个。当然也有解决办法,但那个就不是本章的目标。

我们提出这个问题在于告诉大家@ngrx/store的灵活性,它既可以在Service中使用也可以在组件中使用,也可以混合使用,但都不会影响应用状态的独立性。在现实的编程环境中,我们经常会遇到自己不可改变的事实,比如已有的代码实现方式、或者第三方类库等无法更改的情况,这时候@ngrx/store的灵活性就可以帮助我们在项目中无需做大的更改的情况下进行更清晰的状态管理了。

Store即可以在Service中使用也可以在Component中使用

我实现的Todo其实是多用户版本,比这个例子里有多了一些东西。大家可以去
https://github.com/wpcfan/awesome-tutorials/tree/chapter09 查看代码

Angular2和Rx的相关知识可以看我的Angular 2.0 从0到1系列