Angular 2 i Redux

Ten wpis jest 4 częścią z 5 w kursie Angular 2

Otóż okazuje się, że Redux świetnie współgra z aplikacjami w Angular 2 i biblioteka ta jest bardzo często wykorzystywana razem z tym frameworkiem. Poprzednio opisywałem sposoby na komunikację pomiędzy komponentami sugerowane przez twórców Angulara, wszystkie miały jednak pewną wadę: Dobrze działały tylko w przypadku prostych scenariuszy. Użycie Reduksa znacznie upraszcza zarządzanie stanem nawet najbardziej złożonych aplikacji oraz umożliwia łatwą komunikację pomiędzy komponentami. Na czym dokładnie polega filozofia Reduksa opisywałem we wpisie Flux i Redux, więc osoby nieznające tego konceptu zapraszam do tamtego wpisu. W tym artykule chciałbym skupić się na bardzo konkretnym przykładzie użycia biblioteki Redux razem z Angular 2.

Sposobów połączenia Reduksa z Angularem 2 jest wiele, między innymi biblioteka ng2-redux, czy bardziej skomplikowany koncept łączący FRP i Reduksa: ngrx/store. Tutaj prezentuję najprostszą metodę, dzięki czemu wiedza ta jest najbardziej uniwersalna.

Koncepcja

Napiszmy teraz znowu listę zadań w Angular 2, tym razem wykorzystując Reduksa do zarządzania stanem aplikacji. Zacznijmy od zaprojektowania kodu i podziału na komponenty. Potrzebujemy komponent do dodawania zadań, komponent reprezentujący ich listę oraz komponent będący pojedynczym zadaniem na liście. Koncepcyjnie HTML będzie wyglądać tak:

<my-app>  
    <my-add-todo></my-add-todo>
    <my-todo-list>
        <my-todo-list-item></my-todo-list-item>
        <my-todo-list-item></my-todo-list-item>
        …
    </my-todo-list>
</my-app>  

Redux

Akcje

Rozpoczynamy od stworzenia projektu oraz definiujemy akcje Reduksa w pliku todoActions.ts. W tej prostej appce potrzebujemy tylko dwóch akcji:

  • utworzenie zadania
  • oznaczenie zadania jako ukończone/nieukończone

Oprócz tego, w tym samym pliku umieszczamy też klasę z metodami ułatwiającymi tworzenie akcji (tzw. action creators):

export const ADD_TODO = 'ADD_TODO';  
export const TOGGLE_TODO = 'TOGGLE_TODO';

@Injectable()
export class TodoActions {  
  private nextTodoID = 0;

  addTodo(title:string) {
    return {
      id: this.getNextID(),
      type: ADD_TODO,
      title,
      complete: false
    };
  }

  toggleTodo(id:number) {
    return {
      id,
      type: TOGGLE_TODO
    };
  }

  private getNextID() {
    return ++this.nextTodoID;
  }
}

Klasa oznaczona jest dekoratorem @Injectable, aby było możliwe jej wstrzyknięcie do klas komponentów poprzez Dependency Injection. Implementacja getNextID może być dowolna, tutaj dla prostoty id zadań to kolejne liczby naturalne. Jednocześnie w pliku todo.ts definiujemy sobie pomocniczą klasę oznaczającą nowe zadanie na liście:

export class Todo {  
  id:number;
  title:string;
  complete:boolean;
}

Reducer

Następnym krokiem pracy z reduksem jest stworzenie tzw. reducera. W tym przypadku reducer jest tylko jeden, bo aplikacja jest niezwykle prosta. Zaczynamy do zadeklarowania interfejsu dla stanu naszej aplikacji oraz zdefinowania stanu początkowego:

interface AppState {  
  todos: Array<Todo>;
}

const initialState:AppState = {  
  todos: []
};

Nasz reducer działa w ten sposób, że sprawdza którą z akcji ma obsłużyć i wywołuje odpowiednią funkcję:

export function rootReducer(state:AppState = initialState, action):AppState {  
  switch (action.type) {
    case ADD_TODO:
      return addTodo(state, action);
      break;
    case TOGGLE_TODO:
      return toggleTodo(state, action);
      break;
    default:
      return state;
  }
}

function addTodo(state:AppState, action):AppState {  
  return {
    todos: [
    ...state.todos,
    {id: action.id, title: action.title, complete: action.complete}
    ]
  };
}

function toggleTodo(state:AppState, action):AppState {  
  return {
    todos: state.todos.map(todo => {
      if (todo.id === action.id) {
        return {
          id: todo.id,
          complete: !todo.complete,
          title: todo.title
        };
      }
      return todo;
    })
  };
}

Reducer nie mutuje todos, zawsze zwraca nową tablicę.

Store

Na podstawie tak napisanego reducera tworzymy store, a następnie informujemy Angulara, że ten store jest dostępny jako zależność do wstrzyknięcia. Dodatkowo wywołujemy tutaj funkcję window.devToolsExtension() – jest to funkcja udostępniania przez wtyczkę Redux DevTools do przeglądarki Google Chrome. Wtyczka ta znacznie ułatwia pracę z Reduksem, pozwala na przykład przejrzeć wszystkie zdarzenia, które miały miejsce w aplikacji, a także dowolnie je cofać i powtarzać. Mała próbka możliwości:

Cały kod umieszczamy w pliku main.ts, w którym zwyczajowo znajduje się wywołanie funkcji bootstrap. Drugim, do tej pory pomijanym argumentem funkcji bootstrap jest tablica providers. Jej działanie jest analogiczne do własności o tej samej nazwie w komponentach.

const appStoreFactory = () => {  
  const appStore = createStore(rootReducer, undefined, window.devToolsExtension && window.devToolsExtension());
  return appStore;
};

bootstrap(AppComponent, [  
  provide('AppStore', { useFactory: appStoreFactory }),
  TodoActions 
]);

Warto zwrócić uwagę na nietypowy sposób w jaki użyty jest tutaj appStore – na potrzeby tego wpisu nie będę się zagłębiał w ten temat (ale na pewno samemu Dependency Injection w Angular 2 poświęce cały osobny artykuł!). Należy jedynie pamiętać, że w Angularze wstrzykiwać możemy albo klasy, albo dowolne wartości identyfikowane po nazwie. Tutaj appStore nie jest klasą, więc będzie identyfikowany pod nazwą AppStore.

Komponenty

Kolejnym krokiem jest stworzenie komponentów AddTodoComponent, TodoListComponentTodoListItemComponent. Możemy do tego wykorzystać Angular CLI, jak już to omawiałem w jednym z poprzednich wpisów. Modyfikujemy AppComponent dodając do niego tablicę providers z wymienionymi komponentami oraz używamy ich w szablonie:

@Component({
  selector: 'my-app',
  directives: [AddTodoComponent, TodoListComponent],
  template: `
  <h1>To do list with Redux</h1>
  <my-add-todo></my-add-todo>
  <my-todo-list></my-todo-list>
  `
})
export class AppComponent {  
}

AddTodoComponent

Pierwszy z komponentów jest odpowiedzialny za dodawanie elementów do listy. Nic prostszego, zwykły input, ngModel oraz metoda, która tworzy akcję ADD_TODO:

@Component({
    selector: 'my-add-todo',
    template: `
    <form>
      <label>
        Nowe zadanie:
        <input type="text" [(ngModel)]="newTodoTitle" (keyup.enter)="addTodo()">
      </label>
    </form>
    `
})
export class AddTodoComponent {  
    newTodoTitle:string;

    addTodo() {
      if (!this.newTodoTitle) {
          return;
      }
      const action = this.todoActions.addTodo(this.newTodoTitle);
      this.appStore.dispatch(action);
      this.newTodoTitle = '';
    }

    constructor(@Inject('AppStore') private appStore:Store,
                private todoActions:TodoActions) {
    }
}

Zwróćmy uwagę w jaki sposób wstrzykiwany jest appStore – identyfikowany jest po nazwie i używamy do tego dekoratora @Inject('AppStore').

TodoListComponent

Drugi komponent naszej aplikacji to lista. Jej zadaniem będzie pobranie tablicy zadań oraz reagowanie na zmiany. Obie te rzeczy realizujemy poprzez wywołanie funkcji appStore.subscribe() w momencie inicjalizacji komponentu. Ważne jest jednak, aby tę subskrypcję usunąć gdy komponent jest niszczony. Posłużą do tego zdarzenia cyklu życia. Gdy już mamy tablicę zadań, wyświetlimy je w szablonie przy pomocy *ngFor i komponentu TodoListItemComponent, do którego przekażemy obiekty z zadaniami:

@Component({
  selector: 'my-todo-list',
  directives: [TodoListItemComponent],
  template: `
  <my-todo-list-item *ngFor="let todo of todos" [todo]="todo"></my-todo-list-item>
  `
})
export class TodoListComponent implements OnInit, OnDestroy {  
  todos:Array<Todo>;

  private unsubscribe:Function;

  ngOnInit() {
    this.unsubscribe = this.appStore.subscribe(() => {
      const state = this.appStore.getState();
      this.todos = state.todos;
    });
  }

  ngOnDestroy() {
    this.unsubscribe();
  }

  constructor(@Inject('AppStore') private appStore:Store) {
  }
}

TodoListItemComponent

To już chyba tylko formalność. Ten komponent reprezentuje element na liście zadań i ma jeden atrybut wejściowy @Input() todo, a po kliknięciu na checkbox wysyłana jest odpowiednia akcja:

@Component({
  selector: 'my-todo-list-item',
  template: `
  <label>
    <input type="checkbox" (change)="onTodoClick()" [checked]="todo.complete">
    {{ todo.title }}
  </label>
  `
})
export class TodoListItemComponent {  
  @Input() todo:Todo;

  onTodoClick() {
    const action = this.todoActions.toggleTodo(this.todo.id);
    this.appStore.dispatch(action);
  }

  constructor(@Inject('AppStore') private appStore:Store,
              private todoActions:TodoActions) {
  }
}

Demo

Aplikacja jest gotowa. Zaimplementowaliśmy bardzo prostą listę zadań z użyciem frameworka Angular 2 oraz biblioteki Redux. Efekt jest widoczny poniżej. Tak prosty przykład być może nie oddaje jeszcze pełni zalet wynikających z wykorzystania Reduksa, jednak gdy aplikacja będzie rosła na pewno docenimy możliwości, które daje Redux oraz łatwość z jaką możemy rozwijać nasze komponenty bez konieczności czynienia drastycznych zmian w innych częściach aplikacji. Zachęcam do komentowania 🙂