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 :)