過去にJavaScript開発をやったことがある人であれば、Reduxのことは聞いたことがあるでしょう。Reactとともに一般に普及し、開発者の中には「当時のJavaScript関係で一番興奮した出来事だった」、「アプリケーションの構築に大変革をもたらした」、はては「Reduxのおかげで地球温暖化が完全に止まった」と言う人もいるくらいです。
失礼、ちょっと我を忘れてしまいました。しかし、真面目な話、Reduxはアプリケーションの構築方法に、変化をもたらしたのは本当です。この投稿では、Reduxを別のライブラリのImmutable.jsと一緒に、Angular 2と統合するやり方をご説明します。
概要
この投稿では、FluxアーキテクチャとReduxの基本的な概念を考えていきます。それから、簡単な連絡先リストのアプリケーションを段階的に作っていきます。初めは基本的なセットアップを構築し、その後、不変性を追加し、最後にはReduxの状態のコンテナを作成します。
進めていく上で、なぜそれをしなければいけないかを1つずつ丁寧に説明していくよう努めます。最後までいってもアプリケーションは完成しませんが、おおまかなやり方をつかんでいただけると思います。
ReduxとFluxアーキテキチャ
Fluxはユーザインターフェースを構築するための、簡単なアーキテクチャパターンです。フレームワークやライブラリではありません。クライアントサイドのアプリケーションを構築するためのデザインパターンなのです。
ソース:Flux Documentation『Structure and Data Flow』
ReduxはDan Abramov氏が開発したFluxの実装です。Fluxはそれ自身がライブラリではないので、FacebookはFlux中心のアプリケーションが利用できるDispatcherライブラリを作成しました。Reduxも同じアーキテクチャですが、これは特定の抽象化を簡単にするために作られたものになります。
ReduxはFluxのすべての利点を備え(アクションの記録と再生、単一方向のデータフロー、変更の依存)、さらに新たな利点も追加されている(簡単なundo-redoとホットリロード)。それもDispatcherや登録の保存は必要ない。
―Dan Abramov
基本的な性質を見てみましょう。
1) アプリケーション内の変更はすべて、1つのJavaScriptオブジェクト、つまりストアに格納される。
2) オブジェクトストアはアプリケーションの状態のコンテナとして機能します。
3) 状態は読み取り専用で、変更できない(つまり、不変である)。
4) 状態に変更を加える(ユーザイベントやAPIインタラクションなど)には、変更を説明するJavaScriptオブジェクトを送る必要がある。つまり、アクションをディスパッチする。
6) データはストアからビューに転送され、その逆方向への転送はない。よって、ビューは状態を変更することができない。
前述したように、状態変更は現在の状態を変更することはできません。Reduxでは、変更が発生するたびに、新しいJSONオブジェクトが返されなければいけません。これを実現するために、状態の変更は純粋関数を使ってトリガーさせる必要があります。純粋関数は同じ入力に対しては常に同じ値が帰ってくるという関数です。つまり、外のものは何も変更ができないということです。外部変数を変更したり、データベースを呼び出すこともできません。
function pureFunction(object){ return object.a * 100; } var x = { a: 1 }; pureFunction(x); console.log(x); // x = { a: 1 }
function impureFunction(object){ object.a = object.a * 100; return object.a; } var x = { a: 1 }; impureFunction(x); console.log(x); // x = { a: 100 }
Reduxでは、これらはReducerとして知られ、アプリケーションの状態がアクションに対応してどのような変更するかの決定を下します。
ここまでできて、まだはっきりと理解できていなくても大丈夫です。先に読み進め、Reducerやアクション、ストアが具体例の中でどう使われているのか見ていけば、理解はどんどん深まるはずです。
連絡先リストの例
では、連絡先リストを実際に構築していきましょう。まだの人がいれば、Angular 2 QuickStartを使ってアプリケーションをセットアップすることもできます。今回は、埋め込みコードでのクラスの定義やスタイルの要素などの必要のないものは省略し、必要なものだけを説明しますが、それぞれのステップの最後には、完全なソースコードのリンクを貼っておきますので、参考にしてください。
まずは、純粋にAngularの要素で簡単なものを構築してみましょう。最初にContact Storeでアプリケーションのロジックを処理します。
// Contact Store export class Contact { name: String; star: boolean; } export class ContactStore { contacts: Contact[]; constructor() { this.contacts = []; } addContact(newContact: String) { this.contacts.push({ name: newContact, star: false }); } removeContact(contact: Contact) { const index = this.contacts.indexOf(contact); this.contacts.splice(index, 1); } starContact(contact: Contact) { const index = this.contacts.indexOf(contact); this.contacts[index].star = !this.contacts[index].star; } }
このやり方はAngular 1.xでサービスをセットアップする方法にとても似ています。これで、ストアが連絡先を追加、削除、お気に入りに登録できるアプリケーションの状態を制御するようになりました。
では、コンポーネントをセットアップします。
// Contact List Component import { Component } from '@angular/core'; import { ContactStore } from './contact-store'; @Component({ selector: 'contact-list', templateUrl: 'app/contact-list.html', styleUrls: ['app/contact-list.css'], }) export class ContactList { constructor(private store: ContactStore) { } addContact(contact) { this.store.addContact(contact); } removeContact(contact) { this.store.removeContact(contact); } starContact(contact) { this.store.starContact(contact); } }
<!-- Contact List HTML --> <input #newContact placeholder="Add Contact" (keyup.enter)="addContact(newContact.value); newContact.value='' "> <ul> <li *ngFor="let contact of store.contacts"> {{ contact.name }} <button (click)="starContact(contact)"> <i class="fa fa-2x" [class.fa-star]="contact.star" [class.fa-star-o]="!contact.star"></i> </button> <button (click)="removeContact(contact)"> <i class="fa fa-trash fa-2x"></i> </button> </li> </ul>
ここでは、プライベートなstore
プロパティを定義し、それをContactStore
インジェクションとして特定するコンストラクタがあります。addContact
、removeContact
、starContact
などの入力のメソッドはすべて、ContactStore
のそれぞれのメソッドにリンクしています。
今のところ、使用可能なシンプルなものを構築することに成功しました。いい滑り出しです。ソースコードはこちらからどうぞ。
複数のコンポーネント
Angular 2はコンポーネントベースなので、連絡先ごとにコンポーネントがあることは理にかなっています。実際のリスト上で親コンポーネントを設定しましょう。
// Contact List Component import { Component } from '@angular/core'; import { ContactStore } from './contact-store'; import Contact from './contact'; @Component({ selector: 'contact-list', templateUrl: 'app/contact-list.html', styleUrls: ['app/contact-list.css'], directives: [Contact] }) export class ContactList { constructor(private store: ContactStore) { } addContact(contact) { this.store.addContact(contact); } }
<!-- Contact List HTML --> <input #newContact placeholder="Add Contact" (keyup.enter)="addContact(newContact.value); newContact.value='' "> <ul> <li *ngFor="let contact of store.contacts"> <contact [contact]="contact"></contact> </li> </ul>
そして、子コンポーネントはこの連絡先のコンポーネントとなります。
// Contact Component import { Component, Input } from '@angular/core'; import { ContactStore, Contact as ContactModel} from './contact-store'; @Component({ selector: 'contact', templateUrl: 'app/contact.html', styleUrls: ['app/contact.css'], }) export default class Contact { @Input() contact: ContactModel; constructor(private store: ContactStore) { } removeContact(contact) { this.store.removeContact(contact); } starContact(contact) { this.store.starContact(contact); } }
<!-- Contact HTML --> <div class="contact-container"> {{ contact.name }} <button (click)="starContact(contact)"> <i class="fa fa-2x" [class.fa-star]="contact.star" [class.fa-star-o]="!contact.star"></i> </button> <button (click)="removeContact(contact)"> <i class="fa fa-trash fa-2x"></i> </button> </div>
ご覧のとおり、ストアインスタンスは親コンポーネントにも子コンポーネントにも挿入されました。少し理解が深まったのではないでしょうか。ソースコードはこちらからどうぞ。
Change Detection Strategy
Angular 2では、すべてのコンポーネントがそれぞれでChange Detectorを持っており、それぞれのテンプレートでバインドするようになっています。例えば、私たちはContact
コンポーネントにバインドする{{ contact.name }}
を持っています。つまり、Contact
コンポーネントの裏にあるChange Detectionは、contact.name
とその変更のデータを提示するのです。
では、イベントが引き起こされると何が起きるのでしょう。Angular 1.xでは、digestサイクルが起動すると、すべてのバインディングがアプリケーション全体で引き起こされます。Angular 2でも似ていて、すべてのコンポーネントがチェックされます。Angularは、イベントが起きるたびではなく、入力プロパティの中の1つが変更されたときだけ、コンポーネント上でChange Detectionを始めるんです。いいでしょう? 私たちのコンポーネントレベルではAngularのChangeDetectionStrategy
を使ってこれができます。
// Contact Component import {..., ChangeDetectionStrategy} from '@angular/core'; @Component({ selector: 'contact', templateUrl: 'app/contact.html', styleUrls: ['app/contact.css'], changeDetection: ChangeDetectionStrategy.OnPush })
こんなに簡単なんです! このコンポーネントのChange Detectionは、コンポーネントのバインディングに変更が起きたときのみ、起動するようになりました。
ちなみに、今回のようなシンプルなアプリケーションでこれは本当に重要なことでしょうか? そもそもアプリケーションに多くのバインディングがなければ意味はありません。しかし、巨大なアプリケーションでは、イベントが引き起こされるたびにバインディングの数をかなり減らすができ、とても有効です。
不変性のケース
Change Detection Strategyを有効活用するには、状態を完全に不変にしておく必要があります。しかし、JavaScriptオブジェクトの性質として、初期設定で可変になっています。そこで、Facebookのチームが開発したライブラリであるImmutable.jsを使用します。
不変コレクションはオブジェクトとしてではなく値として扱うべきである。オブジェクトは時間とともに変化しうるもので、値はある特定の時点での状態を表していると考えられるからだ。
―Immutable.js – The case for Immutability
簡単に言えば、不変オブジェクトは本質的に変更されないオブジェクトなのです。それらを変更するには、変更したい内容の参照オブジェクトを新たに作成し、元のオブジェクトは残しておく必要があります。Immutable.jsは、アプリケーションに含めることのできる不変データストラクチャを数多く提供してくれます。
不変性は、npm install immutable
でインストールできます。インストールができたら、SystemJSの設定をアップデートしなければなりません。もし、Angular 2 QuickStartに従ってアプリケーションをセットアップしたなら、systemjs.config.js
ファイルの中にあります。あとは、割り当てられたらフィールドをもう1つ追加し、システムローダが、immutable
が参照されたときに正しいファイルを探せるようにするだけです。
/** * System configuration for Angular 2 samples * Adjust as necessary for your application needs. */ (function(global) { // map tells the System loader where to look for things var map = { 'app': 'app', // 'dist', '@angular': 'node_modules/@angular', 'angular2-in-memory-web-api': 'node_modules/angular2-in-memory-web-api', 'rxjs': 'node_modules/rxjs', 'immutable': 'node_modules/immutable/dist/immutable.js' }; // ...
これだけです。では、ContactStore
を更新しましょう。
// Contact Store import Immutable = require('immutable'); export class Contact { name: String; star: boolean; } export class ContactStore { contacts = Immutable.List<Contact>(); addContact(newContact: String) { this.contacts = this.contacts.push({ name: newContact, star: false }); } removeContact(contact: Contact) { const index = this.contacts.indexOf(contact); this.contacts = this.contacts.delete(index); } starContact(contact: Contact) { const index = this.contacts.indexOf(contact); this.contacts = (<any>this.contacts).update(index, (contact) => { return { name: contact.name, star: !contact.star }; }); } }
ご覧の通り、contacts
のインスタンス化を変更し、配列の代わりにList
を使っています。List
はJavaScriptの配列と類似していますが、不変であり、完全に持続性があります。
リストへの変更を持続させるために、push
とdelete
とupdate
のメソッドを使っています。配列と同じように、indexOf
もリストの中の選択された連絡先を探すのに使われます。重要なのは、これらのすべてのメソッドでは、現在のコレクションは変更されませんが、新しく不変コレクションが生成されるということです。
ソースコードのリンクはこちらです。
注釈:もしかしたら、なぜstarContact
メソッドで、<any>
を使うのか疑問に思っている人もいるかもしれません。これはただのTypeScriptの型のアサーションで、アップデートされた連絡先のオブジェクトを返す時のコンパイラエラーを防いでいます。これは、リストに対してメソッドを実行したときの型定義にともなう問題があるため、単なる回避策なのです(こちらで詳しく解説しています)。
Reduxで刺激を加えましょう
Reduxは、npm install --save redux
でインストールできます。また、ここでシステム設定のアップデートを再度してください。
var map = { '...': '...', 'redux': 'node_modules/redux/dist/redux.js' }; // ...
ここで、アクションファイル追加します。
// Actions import { Contact as ContactModel} from './contact-store'; export interface IContactAction { type: string; id: number; name?: string; } export function addContact(name: string, id: number): IContactAction { return { type: 'ADD', id, name }; } export function removeContact(id: number): IContactAction { return { type: 'REMOVE', id }; } export function starContact(id: number): IContactAction { return { type: 'STAR', id }; }
ご覧のとおり、すべてのアクションは、シンプルなJavaScriptオブジェクトなのです。例えば以下のようになります。
{ type: 'ADD', id, name }
Reduxでは、アクションは実行されるアクションの型を説明するtype
プロパティを必ず必要とします。また、文字列定数として定義することをおすすめします。これ以外にも、アクションのストラクチャをあなたの好きなようにセットアップできます。私はそれぞれのアクションで必要なもののみを含むようにセットアップし、id
とname
で連絡先を追加し、id
で削除とお気に入りに登録することができるようにしました。
それぞれのアクションが、それを作った関数の中に含まれているのが分かるはずです。
export function addContact(name: string, id: number): IContactAction { return { type: 'ADD', id, name }; }
これらはaction creatorとして知られ、それぞれが、生成したIContactAction
インターフェースに定義されます。
では、次にreducerのセットアップの仕方を見ていきましょう。
// Reducer import Immutable = require('immutable'); import { IContactAction } from './actions'; import { Contact as ContactModel} from './contact-store'; export function reducer(state: Immutable.List<ContactModel> = Immutable.List<ContactModel>(), action: IContactAction) { switch (action.type) { case 'ADD': return state.push({ id: action.id, name: action.name, star: false }); case 'REMOVE': return state.delete(findIndexById()); case 'STAR': return (<any>state).update(findIndexById(), (contact) => { return { id: contact.id, name: contact.name, star: !contact.star }; }); default: return state; } function findIndexById() { return state.findIndex((contact) => contact.id === action.id); } }
純粋関数に関して先ほど言ったことを覚えているでしょうか? reducerは状態を変更しませんし、副作用もありません。それぞれのアクションに対して、新しく生成された状態をただ返すだけなのです。
では、必要なストアのアップデートを実行しましょう。
// Contact Store import Immutable = require('immutable'); import { createStore } from 'redux'; import { IContactAction } from './actions'; import { reducer } from './reducer'; export class Contact { id: number; name: String; star: boolean; } export class ContactStore { store = createStore(reducer, Immutable.List<Contact>()); get contacts(): Immutable.List<Contact> { return this.store.getState(); } dispatch(action: IContactAction) { this.store.dispatch(action); } }
シンプルでいいですね。createStore
メソッドは、アプリケーションの完全な状態を持っているReduxストアを生成します。第二引数では、アプリケーションのプリロードされた状態に不変の連絡先リストを挿入します。現在の状態はgetState
でアクセスされ、アクションはdispatch
メソッドでディスパッチされます。
そして、Contact
クラスにid
フィールドを追加します。では、コンポーネントコンタクトメソッドをアップデートしましょう。
// Contact List Component // ... import { addContact } from './actions'; @Component({ // ... }) export class ContactList { contactID: number; constructor(private store: ContactStore) { this.contactID = 0; } addContact(contact) { this.store.dispatch(addContact(contact, this.contactID++)); } }
インクリメントしたIDの値は追加されたそれぞれの連絡先にセットされます。actions
から適切なアクションをインポートし、dispatch
でアクションを実行します。では、子コンポーネントを見てみましょう。
// Contact Component // ... import { removeContact, starContact } from './actions'; @Component({ // ... }) export default class Contact { // ... removeContact(contact) { this.store.dispatch(removeContact(contact.id)); } starContact(contact) { this.store.dispatch(starContact(contact.id)); } }
これで終わりです。アプリケーションでのReduxの基本的な使い方はすべて説明しました。最後のソースコードはこちらからどうぞ。
まとめ
お気付きだとは思いますが、Reduxの状態コンテナと一緒に不変コレクションを実装することは、アプリケーションに複雑性が増すことにつながります。今回の方法はAngularアプリケーションを構築する唯一の選択肢ではないですし、最善のやり方でもありません。複数ある中の1つのやり方であり、特定の状況下では力を発揮するものになっているはずです。
なぜ不変性を重要視するのか
初期設定で、Angularのchange detectionはアプリケーションで変更が生じたすべてのコンポーネントをチェックすることになっています。不変性を強制することで、いくつかのコンポーネント上で、ChangeDetectionStrategy.OnPush
を使えるようになり、必要なときだけ、チェックするようになりました(つまり、入力プロパティが変更されたときです)。このシンプルで使いやすい方法を実行するにはImmutable.jsのようなライブラリを使いましょう。
いつReduxを使えばいいか
これよりもいい質問はこうでしょう。「アプリケーションでFlux型アーキテクチャはいつ実行すべきか?」。これに対する答えは、この回答がすべてを語ってくれています。
参考文献
Angular 2 のChange Detection
Angular 2でChange Detectionがどのように機能するのかを詳しく説明している資料です。
Change Detection in Angular 2―Victor Savkin
Angular 2 Change Detection Explained―Thoughtram
Reduxについて
Dan Abramov氏がegghead.ioにすばらしいレッスンを載せています。―Getting Started with Redux
ReduxとAngular 2
Colin Eberhardt氏が状態の永続性とタイムトラベルをRuduxと統合する方法を分かりやすく説明しています。―Angular 2 Time Travel with Redux