Angular 2 i Redux

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, TodoListComponent i TodoListItemComponent. 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 :)

Michał Miszczyszyn

Programista z doświadczeniem w JavaScripcie po stronie klienta i serwera. Wielki fan TypeScripta.

Subscribe to Type of Web

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!