📐

Walts - Angular 2向けFluxライブラリを作った

2021/07/04に公開

@armorik83 です。Flux ライブラリ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、この三角の動きを三拍子に見立てて名付けたものだ。

数々の検証や他のライブラリの知見を経て開発に着手したのが、Angular 2 用を意識して設計した Flux ライブラリ"Walts"である。他のライブラリの知見や昨今の Flux 事情については前日の記事にて綴ってある。

これは 2016 年 4 月に開発を始めており、それまでに私が経験してきたフロントエンドの難点や当時の案件の問題点、反省点などを数多く活かしたものとなっている。Almin.jsとも開発時期が近いようだが、全くあずかり知らぬところで開発しており、結果的には Almin.js 側が先出しになったのでそちらも参考にしている。

DDD, CQRS, Redux, RxJS, Savkin's Flux, redux-observable…、昨今のアーキテクチャ、デザインパターン、ライブラリを調べては実践し、信頼している技術者にレビューを依頼しては開発を進めてきた。

Walts の特徴

Walts は「Angular 2 上に Flux アーキテクチャを導入する」ことを最大のモチベーションとして設計している。また、他の Flux ライブラリとは異なり、Angular 2 であること、TypeScript であることを常に念頭に置いている。

推奨実装例として、今後チュートリアル記事を準備する予定にしている。

Walts の記述例

TodoMVC を用意しているので、ご覧いただきたい。なおng2-redux 版との比較を目的に実装している。

State の記述例

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 CreatorsDispatcherを用いる。Walts では基本的に語を変えずにそのままにしているが、Action CreatorsのみActionsと呼ぶことにした。

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 である。削除ボタンを例として取り上げよう。

onClickDestroy() {
  const id = this.todo.id
  this.dispatcher.emit(this.actions.deleteTodo(id))
}

this.dispatcherthis.actionsは Component のconstructorに記述することで、それぞれ DI して受け取っている。これらはapp.dispatcher.tsapp.actions.tsとして定義する。

this.actions.deleteTodo(id)の戻り値はあくまでも「処理を行うための関数」でしかないので、これをthis.dispatcher.emit()に渡すことで初めて実行される。

Dispatcher の記述例

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 の記述例

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/fluxReduxと大きく異るのは「Actions に処理を書く」点である。他の Flux ライブラリでは Actions はイベント用トークンをactionTypeなどで記述して値と共にイベントとして発火するだけで、実際の処理は Store の switch 文内に書いていた。Savkin's Fluxではこれをクラスのインスタンスにすることで、分岐を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>など複数作ることは構わない。

return (state) => ({
  filter: MAP_FILTERS[filter],
});

上記のように、state の部分的なプロパティのみ返しても、すべての State は結合される(これは React のsetState()を参考にした)

Store の記述例

最後に Store の記述例である。

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として用いる。

@Injectable()
export class AppStore extends Store<AppState> {
  // ...
}

const INIT_STATEにはアプリケーション起動直後の初期値を定義している。型はAppStateである。この初期値はあくまでもアプリケーションが起動した瞬間に使用されるものであり、即座に通信して値を取得し、更新することはまったく問題ない。

Store も希望に応じて分割してよいが、注意点としてFooStore extends AppStoreFooStore extends Store<AppState>としてはいけない。なぜなら Store はシングルトンであり、複数の Store を作成してしまうと、処理が Store の数だけ走ってしまうからだ。(Store を二つ生成すると、値が常に二倍になってしまう)

もし Store をドメインごとに複数扱いたいときは、次のようにする。

@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 に則ると読み込み中心となる。

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()というメソッドを用意している。

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 の始め方については公式のドキュメント拙記事を参照してもらいたい。

npm install --save walts

必要となるファイルは次の通りだ。ファイル名は任意であるが下記を推奨しておく。ファイルの作成場所はアプリケーションに応じて決めればよい。

touch app.state.ts app.store.ts app.dispatcher.ts app.actions.ts

各ファイルは次のような体裁を推奨している。

app.state.ts
import {State} from 'walts';

export interface AppState extends State {
  //
}
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);
  }
}
app.dispatcher.ts
import {Injectable} from '@angular/core';
import {Dispatcher} from 'walts';

import {AppState} from './app.state';

@Injectable()
export class AppDispatcher extends Dispatcher<AppState> {
}
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 のサンプル集

前節の基礎的な記述を元に、いくつかのサンプル・アプリケーションを用意している。

サポート体制

今回はロゴも作った、かなり本気だ。この内容は英訳し、英語のドキュメントと共に早期にサイトとして整備し、世界に向けてアプローチしていく予定で活動を続ける。少しでも賛同者を集め、開発やフィードバックに協力していただけると幸いである。

結び

Flux は登場から約 2 年となり、おおよそ広まった感はあるが、Redux の界隈を見ているとまだまだ試行錯誤や成長が窺える。Angular と共にフロントエンドのスケーリング、様々な設計概念をこれからも追求していき、Walts を育てていきたい。


よろしくおねがいします。

もう少し丁寧に解説した記事も書きました。

Discussion