Created
March 25, 2020 17:12
-
-
Save okunokentaro/0757899ce9e0ab577c7794e2217d0971 to your computer and use it in GitHub Desktop.
Walts - Angular 2向けFluxライブラリを作った
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
2016/09/28 にQiitaに投稿した記事のアーカイブです | |
--- | |
@armorik83です。Fluxライブラリ[Walts](https://github.com/crescware/walts)を開発したので発表します。 | |
<img width="200px" alt="walts.png" src="https://qiita-image-store.s3.amazonaws.com/0/17959/745f55b1-ace7-c8b5-d4b1-c56a0a8cea02.png"> | |
--- | |
# Walts | |
この度、Waltsというライブラリを開発した。ウォルツとも読めるが、ここはワルツと呼んでもらいたい。`View -> Action -> Store`、この三角の動きを三拍子に見立てて名付けたものだ。 | |
- [crescware/walts](https://github.com/crescware/walts) | |
数々の検証や他のライブラリの知見を経て開発に着手したのが、Angular 2用を意識して設計したFluxライブラリ"Walts"である。他のライブラリの知見や昨今のFlux事情については[前日の記事](2c7933d3376c51c01461)にて綴ってある。 | |
これは2016年4月に開発を始めており、それまでに私が経験してきたフロントエンドの難点や当時の案件の問題点、反省点などを数多く活かしたものとなっている。[Almin.js](https://github.com/almin/almin)とも開発時期が近いようだが、全くあずかり知らぬところで開発しており、結果的にはAlmin.js側が先出しになったのでそちらも参考にしている。 | |
[DDD](https://ja.wikipedia.org/wiki/%E3%83%89%E3%83%A1%E3%82%A4%E3%83%B3%E9%A7%86%E5%8B%95%E8%A8%AD%E8%A8%88), [CQRS](Responsibility-Segregation), [Redux](http://redux.js.org/), [RxJS](https://github.com/ReactiveX/rxjs), [Savkin's Flux](https://vsavkin.com/managing-state-in-angular-2-applications-caf78d123d02#.agza2exhl), [redux-observable](https://github.com/redux-observable/redux-observable)…、昨今のアーキテクチャ、デザインパターン、ライブラリを調べては実践し、信頼している技術者にレビューを依頼しては開発を進めてきた。 | |
## Waltsの特徴 | |
Waltsは「Angular 2上にFluxアーキテクチャを導入する」ことを最大のモチベーションとして設計している。また、他のFluxライブラリとは異なり、Angular 2であること、TypeScriptであることを常に念頭に置いている。 | |
推奨実装例として、今後チュートリアル記事を準備する予定にしている。 | |
## Waltsの記述例 | |
TodoMVCを用意しているので、ご覧いただきたい。なお[ng2-redux版](https://github.com/armorik83/comparing-ng2-redux-and-walts/tree/master/examples/ng2-redux)との比較を目的に実装している。 | |
- https://github.com/armorik83/comparing-ng2-redux-and-walts/tree/master/examples/walts | |
### Stateの記述例 | |
```ts:app.state.ts | |
import { State } from 'walts' | |
import { TodosRepository, FilterType } from './todos.repository' | |
export interface AppState extends State { | |
todos?: TodosRepository | |
filter?: FilterType | |
} | |
``` | |
Waltsを採り入れたAngularアプリケーションを開発する上で、最初に定義するのがこのStateである。 | |
Stateは`AppState extends State`としてinterfaceとして宣言している。この名前はなんでもよい。 | |
TypeScriptを前提としているため、AppStateには型のみを宣言する。 | |
### Viewの記述例 | |
つぎにViewからのユーザ操作の入力だ。そのためには、Fluxでいう`Action Creators`と`Dispatcher`を用いる。Waltsでは基本的に語を変えずにそのままにしているが、`Action Creators`のみ`Actions`と呼ぶことにした。 | |
```ts:todo-item.component.ts | |
import { Component, Input } from '@angular/core' | |
import { Todo } from './todo' | |
import { AppDispatcher } from './app.dispatcher' | |
import { AppActions } from './app.actions' | |
@Component({ | |
selector: 'ex-todo-item', | |
template: `...` | |
}) | |
export class TodoItemComponent { | |
@Input() todo: Todo | |
private editing: boolean | |
constructor(private dispatcher: AppDispatcher, | |
private actions: AppActions) {} | |
ngOnInit() { | |
this.editing = false | |
} | |
onClickDestroy() { | |
const id = this.todo.id | |
this.dispatcher.emit(this.actions.deleteTodo(id)) | |
} | |
} | |
``` | |
これはAngular 2のComponentである。削除ボタンを例として取り上げよう。 | |
```ts | |
onClickDestroy() { | |
const id = this.todo.id | |
this.dispatcher.emit(this.actions.deleteTodo(id)) | |
} | |
``` | |
`this.dispatcher`と`this.actions`はComponentの`constructor`に記述することで、それぞれDIして受け取っている。これらは`app.dispatcher.ts`と`app.actions.ts`として定義する。 | |
`this.actions.deleteTodo(id)`の戻り値はあくまでも「処理を行うための関数」でしかないので、これを`this.dispatcher.emit()`に渡すことで初めて実行される。 | |
### Dispatcherの記述例 | |
```ts:app.dispatcher.ts | |
import { Injectable } from '@angular/core' | |
import { Dispatcher } from 'walts' | |
import { AppState } from './app.state' | |
@Injectable() | |
export class AppDispatcher extends Dispatcher<AppState> {} | |
``` | |
`Dispatcher`はこれが全てである。名前はなんでもいいのだが、Waltsの提供する`Dispatcher`からのextendsが必須なので`AppDispatcher`という名前にしている。実装は何もないが、TypeScriptのGenericsを通す意味がある。 | |
### Actionsの記述例 | |
```ts:app.actions.ts | |
import { Injectable } from '@angular/core' | |
import { Actions, Action } from 'walts' | |
import { AppState } from './app.state' | |
import { MAP_FILTERS } from './todos.repository' | |
@Injectable() | |
export class AppActions extends Actions<AppState> { | |
addTodo(text: string): Action<AppState> { | |
return (state) => { | |
state.todos.addTodo(text) | |
return state | |
} | |
} | |
clearCompletedAction(): Action<AppState> { | |
return (state) => { | |
state.todos.clearCompleted() | |
return state | |
} | |
} | |
completeAll(): Action<AppState> { | |
return (state) => { | |
state.todos.completeAll() | |
return state | |
} | |
} | |
setFilter(filter: string): Action<AppState> { | |
return (state) => ({ | |
filter: MAP_FILTERS[filter] | |
}) | |
} | |
} | |
``` | |
[facebook/flux](https://github.com/facebook/flux)や[Redux](http://redux.js.org/)と大きく異るのは「Actionsに処理を書く」点である。他のFluxライブラリではActionsはイベント用トークンを`actionType`などで記述して値と共にイベントとして発火するだけで、実際の処理はStoreのswitch文内に書いていた。[Savkin's Flux](https://vsavkin.com/managing-state-in-angular-2-applications-caf78d123d02#.m4egrb7o2)ではこれをクラスのインスタンスにすることで、分岐を`instanceof`で行っていた。 | |
これに対してWaltsでは、意図的にActionsに処理を集約させるよう設計している。戻り値`Action<AppState>`は「`AppState`を受け取り`AppState`を返す関数」のことである。イベント駆動の考え方では、関数そのものをトークンとして投げ、その関数をそのまま実行するというのは、依存の結合上好まれないかもしれない、という点は把握している。トリガとハンドラを、イベント名文字列とディスパッチャによって疎にするという前提があるからだ。 | |
ただし実際に開発していると、この懸念点はAngular 2の場合、DI機構をベースにした依存の逆転ですでに解決できており、それよりもIDEなどでView側のトリガからすぐに処理を追える点を重視すべきだと判断した。(すぐに処理を追えるとは、Actionsのソースを開くことで処理を読める、イベント名をプロジェクト内全検索にかける必要がない、ということ) | |
Actionsの例の解説に戻ろう。この例では`state.todos`はRepositoryとして実装しているので、ここへの操作はRepository内の副作用に期待しているが、CQRSの考え方を適用し書き込みと読み込みは分けることを推奨する。この例ではActions内では書き込みの処理のみを記述している。一方で、後述のStore内では読み込みの処理を記述する。Waltsではこういったstateのプロパティに対する副作用の期待は、やむを得ないものであると認識しており、引数state、戻り値stateの関数が維持されるならば、その中では従来のような手続き的な処理を書くこともあり得ると想定している。 | |
このActionについてテストを実践する場合、Angular 2ではそもそもDIによるモックテストが前提となっているため、このRepositoryをモックに置き換え、直接Actionの関数を検証するだけで済む。 | |
Actionsの名称は、この例では`AppActions`としているが、規模に応じて分割し`FooActions extends Actions<AppState>`や`BarActions extends Actions<AppState>`など複数作ることは構わない。 | |
```ts | |
return (state) => ({ | |
filter: MAP_FILTERS[filter] | |
}) | |
``` | |
上記のように、stateの部分的なプロパティのみ返しても、すべてのStateは結合される(これはReactの`setState()`を参考にした) | |
### Storeの記述例 | |
最後にStoreの記述例である。 | |
```ts:app.store.ts | |
import { Injectable } from '@angular/core' | |
import { Observable } from 'rxjs' | |
import { Store } from 'walts' | |
import { AppState } from './app.state' | |
import { AppDispatcher } from './app.dispatcher' | |
import { TodosRepository, FilterType } from './todos.repository' | |
import { Todo } from './todo' | |
const INIT_STATE: AppState = { | |
todos: void 0, | |
filter: 'showAll' | |
} | |
@Injectable() | |
export class AppStore extends Store<AppState> { | |
constructor(protected dispatcher: AppDispatcher, | |
private todosRepository: TodosRepository) { | |
super((() => { | |
INIT_STATE.todos = todosRepository | |
return INIT_STATE; | |
})(), dispatcher) | |
} | |
getAllTodos(): Observable<Todo[]> { | |
return this.observable.map((state) => { | |
return state.todos.getAll() | |
}) | |
} | |
getFilteredTodos(): Observable<Todo[]> { | |
return this.observable.map((state) => { | |
const todos = state.todos.getAll() | |
if (state.filter === 'showAll') { | |
return todos | |
} | |
if (state.filter === 'showActive') { | |
return todos.filter((todo) => !todo.completed) | |
} | |
if (state.filter === 'showCompleted') { | |
return todos.filter((todo) => todo.completed) | |
} | |
console.error('The unknown filter type has given.') | |
}) | |
} | |
getCompletedCount(): Observable<number> { | |
return this.observable.map((state) => { | |
return state.todos.completedCount() | |
}) | |
} | |
getFilter(): Observable<FilterType> { | |
return this.observable.map((state) => { | |
return state.filter | |
}) | |
} | |
} | |
``` | |
Storeも同じように`walts/Store`をextendsし`AppStore`として用いる。 | |
```ts | |
@Injectable() | |
export class AppStore extends Store<AppState> { | |
// ... | |
} | |
``` | |
`const INIT_STATE`にはアプリケーション起動直後の初期値を定義している。型は`AppState`である。この初期値はあくまでもアプリケーションが起動した瞬間に使用されるものであり、即座に通信して値を取得し、更新することはまったく問題ない。 | |
Storeも希望に応じて分割してよいが、注意点として`FooStore extends AppStore`や`FooStore extends Store<AppState>`としてはいけない。なぜならStoreはシングルトンであり、複数のStoreを作成してしまうと、処理がStoreの数だけ走ってしまうからだ。(Storeを二つ生成すると、値が常に二倍になってしまう) | |
もしStoreをドメインごとに複数扱いたいときは、次のようにする。 | |
```ts | |
@Injectable() | |
class AppStore extends Store<AppState> { | |
... | |
} | |
@Injectable() | |
class FooStore { | |
constructor(store: AppStore) {} | |
} | |
@Injectable() | |
class BarStore { | |
constructor(store: AppStore) {} | |
} | |
``` | |
このように大本のStore自体は一つで、サブStoreは大本のStoreをDIして扱えばよい。これで作成されるStoreは一つとなる。Storeとそこに乗るStateは常に一つであるべきという考え方はReduxの影響を受けている。 | |
Storeに記述する処理についてだが、CQRSに則ると読み込み中心となる。 | |
```ts | |
getAllTodos(): Observable<Todo[]> { | |
return this.observable.map((state) => { | |
return state.todos.getAll() | |
}) | |
} | |
``` | |
Storeのメソッドの戻り型は規則では縛っていないが、`Observable`を返すことを推奨する。なぜなら、ViewからStoreに対して明示的に`get()`してしまうと、かつての神オブジェクトを取り合っていた時代に逆戻りしてしまうからだ。FluxとはObserverパターンなので、Viewは値の変更がやってくるまで、ただ黙って`subscribe`していればよい。 | |
## 非同期処理 | |
非同期処理はもちろん考慮に含めている。middlewareが必要なんてこともない。Waltsでは`Actions#delayed()`というメソッドを用意している。 | |
- https://github.com/armorik83/walts-flux-comparison/blob/master/app/app.actions.ts#L15-L25 | |
```ts | |
fetchAllProducts(): Action<AppState> { | |
return (state) => { | |
return this.delayed((apply) => { | |
this.api.getAllProducts().then((products) => { | |
apply((state) => ({ | |
products | |
})) | |
}) | |
}) | |
} | |
} | |
``` | |
別のアプリケーションからの引用だが、`this.delayed((apply) => {})`の箇所がWaltsでの非同期処理を扱う仕組みである。 | |
なぜこのように分かれているかというと、処理を呼んだときの`state`と非同期処理が終わった瞬間の`state`は異なる可能性があるからだ。APIをコールする際にstateの値が必要になり、そのAPIのレスポンスをstateに格納しなければならない状況などで、これは役に立つ。 | |
なお通常のPromiseはサポートしていないが、`this.delayed()`の実態はただのPromiseの型定義ラッパーに名前を付けたものなので、型定義さえ一致していればPromiseも使用できる。(あまり推奨はしない) | |
# Waltsの始め方 | |
Angular 2の始め方については[公式のドキュメント](https://angular.io/docs/ts/latest/quickstart.html)や[拙記事](http://qiita.com/armorik83/items/ae737ab584012a0f5876)を参照してもらいたい。 | |
``` | |
npm install --save walts | |
``` | |
必要となるファイルは次の通りだ。ファイル名は任意であるが下記を推奨しておく。ファイルの作成場所はアプリケーションに応じて決めればよい。 | |
``` | |
touch app.state.ts app.store.ts app.dispatcher.ts app.actions.ts | |
``` | |
各ファイルは次のような体裁を推奨している。 | |
```ts:app.state.ts | |
import {State} from 'walts'; | |
export interface AppState extends State { | |
// | |
} | |
``` | |
```ts:app.store.ts | |
import {Injectable} from '@angular/core'; | |
import {Observable} from 'rxjs'; | |
import {Store} from 'walts'; | |
import {AppState} from './app.state'; | |
import {AppDispatcher} from './app.dispatcher'; | |
const INIT_STATE: AppState = { | |
// | |
}; | |
@Injectable() | |
export class AppStore extends Store<AppState> { | |
constructor(protected dispatcher: AppDispatcher) { | |
super(INIT_STATE, dispatcher); | |
} | |
} | |
``` | |
```ts:app.dispatcher.ts | |
import {Injectable} from '@angular/core'; | |
import {Dispatcher} from 'walts'; | |
import {AppState} from './app.state'; | |
@Injectable() | |
export class AppDispatcher extends Dispatcher<AppState> { | |
} | |
``` | |
```ts:app.actions | |
import {Injectable} from '@angular/core'; | |
import {Actions, Action} from 'walts'; | |
import {AppState} from './app.state'; | |
@Injectable() | |
export class AppActions extends Actions<AppState> { | |
actionName(): Action<AppState> { | |
return (state) => state; | |
} | |
} | |
``` | |
## Waltsのサンプル集 | |
前節の基礎的な記述を元に、いくつかのサンプル・アプリケーションを用意している。 | |
- https://github.com/armorik83/walts-flux-comparison | |
- ショッピングカート | |
- https://github.com/armorik83/comparing-ng2-redux-and-walts/tree/master/examples/walts | |
- TodoMVC | |
- https://github.com/crescware/walts/tree/master/examples/flux-chat | |
- Facebook/flux例にあるチャットの移植(一部移植途中) | |
## サポート体制 | |
今回はロゴも作った、かなり本気だ。この内容は英訳し、英語のドキュメントと共に早期にサイトとして整備し、世界に向けてアプローチしていく予定で活動を続ける。少しでも賛同者を集め、開発やフィードバックに協力していただけると幸いである。 | |
# 結び | |
Fluxは登場から約2年となり、おおよそ広まった感はあるが、Reduxの界隈を見ているとまだまだ試行錯誤や成長が窺える。Angularと共にフロントエンドのスケーリング、様々な設計概念をこれからも追求していき、Waltsを育てていきたい。 | |
--- | |
よろしくおねがいします。 | |
[もう少し丁寧に解説した記事も書きました。](http://qiita.com/armorik83/items/bcef3e40ab48133e378e) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment