flux
riot

Riotでfluxを学ぶ ToDo編③

More than 1 year has passed since last update.

riotとfluxで作るToDoリストの最終章となります。

①と②を読んでない人は是非最初から読んでいただければと(てかそれが前提になる)
ToDoリストと仕様は、②と変更ありません。

前回までで、ストアとビューに分離しましたので、
今回は、アクションとディスパッチャを追加してみたいと思います。

今回作るToDoリストのデモ

カスタムタグ部分

todo.tag
<script type="riot/tag">
<todo>
  <h1>TODO - fluxを使ってみる</h1>
  <form onsubmit="{addTask}">
    <input value="{stores.input.text}" onkeyup="{inputting}" placeholder="新規タスクを入力">
    <select onchange="{changePriority}">
      <option value="low" selected="{stores.input.priority==='low' }">優先度:低</option>
      <option value="mid" selected="{stores.input.priority==='mid' }">優先度:中</option>
      <option value="high" selected="{stores.input.priority==='high'}">優先度:高</option>
    </select>
    <button disabled="{stores.input.text===''}">追加</button>
  </form>
  <ul>
    <li class="{is-done: task.done}" each="{task, i in stores.todo.tasks}" onclick="{toggleTask.bind(this, i)}">
      <input type="checkbox" checked="{task.done}"/><span class="icon is-high" if="{task.priority==='high'}">高</span><span class="icon is-mid" if="{task.priority==='mid'}">中</span><span class="icon is-low" if="{task.priority==='low'}">低</span>{task.title}
    </li>
  </ul>
  <div>
    <button disabled="{!stores.todo.hasCompletedTasks}" onclick="{clearCompletedTasks}">完了したタスクを削除</button>
  </div>
  <style type="sass">
    .is-done {
      text-decoration: line-through;
      color: #ccc;
      .icon {
        &.is-high, &.is-mid, &.is-low {
          background: #ccc;
        }
      }
    }
    .icon {
      color:white;
      display: inline-block;
      margin: 0 5px;
      padding: 0 3px;
      &.is-high{ background: #F66; }
      &.is-mid { background: #CC6; }
      &.is-low { background: #66F; }
    }
  </style>// 宣言と初期化
  const ACTION    = opts.action;
  const dispatcher= opts.dispatcher;
  this.stores     = dispatcher.stores

  this.on("mount", function(){
    dispatcher.trigger(ACTION.TODO_ADD,
      {title:"riotで何か作ってみる", priority:"mid", done:true},
      {title:"fluxを実践してみる",   priority:"low", done:false}
    );
  })

  // ビュー
  this.stores.input.on( "changed", () => { this.update() });
  this.stores.todo.on( "changed", () => { this.update() });

  // アクション
  inputting(e) {
    e.preventUpdate = true;
    dispatcher.trigger(ACTION.INPUT_WRITING, e.target.value)
  }
  changePriority(e) {
    e.preventUpdate = true;
    dispatcher.trigger(ACTION.INPUT_CHANGE, e.target.value)
  }
  addTask(e) {
    e.preventDefault()
    e.preventUpdate = true;
    dispatcher.trigger(ACTION.TODO_ADD, {title:this.stores.input.text, priority:this.stores.input.priority, done:false});
  }
  toggleTask(i, e) {
    e.preventUpdate = true;
    dispatcher.trigger(ACTION.TODO_TOGGLE, i)
  }
  clearCompletedTasks(e) {
    e.preventUpdate = true;
    dispatcher.trigger(ACTION.TODO_CLEAR)
  }
</todo>
</script>

コード解説

  • 引数で渡されたactionと、dispatchar、変数ACTION、dispatcharにそれぞれ代入し、storesをthis.stores とこのriotタグのプロパティ値にしてます。
  • on("moount")で、初期のToDoを2件追加してます。
    • この時、②のToDoリストの時のように、ストアが持つaddTasks()メソッドで、ストアを直接操作するのではなく、ディスパッチャに対してアクションを伝えています。
  • DOMイベントのコールバック関数内(inputting()、changePriority()、addTask()、toggleTask()、clearCompletedTasks())でも、ストアではなく、ディスパッチャにアクションを伝えています。
    • e.preventUpdate = true;は、riotが自動で呼ぶupdate()を停止する処理です。
  • このコードでは見えませんが、ディスパッチャは、ストアへとそのアクションを伝えています。
  • このコードでは見えませんが、アクションに対応した処理によって、ストアはプロパティ値を操作され、プロパティ値が変わったのでchangedを発火します。
  • ビューはchangedに反応し、最新の値で、ビューを更新します。

ストアの値を直接操作するようなコードは、ここにはありません。
DOMイベント時には、ビューはディスパッチャを叩き、アクションと引数を伝えているだけです。

fluxまわりの定義

今回は、fluxを取り入れてToDoリストを設計してますので、
前準備として、fluxにのっとったデータなどを定義しておきます。

ビュー(riot)

前回ビューは、ビュー自身がストアを監視し、更新を取得し、ビューに展開する、能動的な存在になった、みたいな事を書きましたが、
②のToDoリストの段階では、実は未だそうなってなかったので書き直してます。

修正前

②のToDoリストでは、下記のようにriotにプロパティを用意して、

this.task     = ""
this.priority = ""
this.tasks  = []
this.hasCompletedTasks = false;

changedのタイミングで、その値を下のようにビューに配置し表示を更新していました。

// 監視&ビュー操作
this.input.on("changed", (text, priority) => {
  this.update({
    task    : text,
    priority: priority,
  });
});
this.todo.on("changed", (tasks, hasCompletedTasks) => {
  this.tasks  = tasks
  this.hasCompletedTasks = hasCompletedTasks
  this.input.init()
  this.update()
});

修正後

今回作るToDoリストでは、riotでプロパティを用意するのではなく
ビュー自体に、ストアである、inputとtasksを、直接埋め込んでいます。

<input value="{stores.input.text}" onkeyup="{inputting}" placeholder="新規タスクを入力">
の、stores.input.text とか
<li class="{is-done: task.done}" each="{task, i in stores.todo.tasks}" onclick="{toggleTask.bind(this, i)}">
の、stores.todo.tasks とか

ストアが持つプロパティを、ビューで表示している事になりますので、
ストアが更新されたら、表示も変化することになります。
(具体的には、下のように、changedでストアの更新を検知したタイミングでthis.update()して、ビューを更新してあげます。)

// ビュー
this.stores.input.on( "changed", () => { this.update() });
this.stores.todo.on( "changed", () => { this.update() });

ACTION

アクションは、内部APIを示すデータ構造ということなので、
このToDoリストというアプリは、こういうアクションを持ちますよってのを、明示的に定義してます。

Action.js
const ACTION = {
  TODO_ADD      : "TODO_ADD",
  TODO_TOGGLE   : "TODO_TOGGLE",
  TODO_CLEAR    : "TODO_CLEAR",
  INPUT_WRITING : "INPUT_WRITING",
  INPUT_CHANGE  : "INPUT_CHANGE",
};

アクションは、ビューとストアの間を繋ぐ架け橋、共通インターフェイスとでも言いますか、
そんな役割を持つことになります。

Storeクラス

Store.js
class Store {
  constructor() {
    riot.observable(this)
  }
  changed(...args) {  //console.log(args)
    //console.log(new Error().stack)
    this.trigger("changed", ...args);
  }
}

ストアは、observable且つchangerd()を持ちます。

モデルになるような対象を見つけたら、まずは普通にクラス設計し、
その後、このStoreクラスを継承し、ストアが必要な機能を備えればよいです。

Dispatcherクラス

Dispatcher.js
class Dispatcher {
  get stores() { return this._stores; }
  addStore(key, store) { this._stores[key] = store }
  constructor(...stores) {
    this._stores  = {}
    this._actions = {}
  }

  trigger(action, ...data) {
    let callback = this._actions[action]
    if ( typeof callback === "function" )
      callback(this.stores, ...data)
    //else
    //  console.warn("コールバック関数が登録されていません。")
  }
  on(action, callback) {
    this._actions[action] = callback
  }
}
const dispatcher = new Dispatcher();

ディスパッチャは、Actionを受け取って、Storeに伝え(Dispatch)るものです。

よって、
* ストアを追加するメソッドaddStore()と、追加したストアを参照する、storesを持ちます。
* ストアにアクションを伝えるためのインターフェイス、trigger()とon()を持ちます。(off|oneなどは簡単のため省略)

あるアクションに対する処理を下のようon()で定義しておけば、

ディスパッチャ.on(あるアクション, (stores, 引数)=>{
    // 処理
})

trigger()であるアクションを発火させることで、その処理を実行することができます。

ディスパッチャ.trigger(あるアクション, 引数)

補足(ここは読み飛ばして構わない)

いくつかDispatcharというものを見ましたが、
ACTIONを受け取ると、全ストアをループし、store.trigger(ACTION...)でACTIONを発火させていき、
ストアのほうに、on(ACTION, CALLBACK)で、そのACTIONにCALLBACKが定義されていた場合にだけ、処理が動く
みたいなのが多く、

storeが100個あったら、ループも100回まわるなー、とか。
storeにon(ACTION...)がなかったら、その100回全部無意味になるなー、とか。

思ったりします。

いえ、概念的には、ディスパッチャは、ストアにアクションを伝えてまわる。
ストアがそのアクションに対応する処理を持っていようが、いまいが、伝えてまわる、

ってのは分かるんですが、本当にそれで実装すると無駄な気が。。。

まぁDispatcherクラスは非常にシンプルになりますね。
ただ自分が受け取ったACTIONを、そのままstore.trigger(ACTION)をしてるだけですので、

// ディスパッチャにアクションを伝える
dispatcher.trigger(あるアクション);

// ディスパッチャ内
for ( store in ストアリスト ) {
    store.trigger(あるアクション);
}

これ必要なの?と思ったりします。

あともう1つ、
②のToDoリストの段階で、自分はそうしてるのですが、InputとTasksクラスの設計段階でメソッドを用意して、
そのメソッドを通してストアを操作することにしています。

ストアが自らのプロパティ値を操作させるのに、メソッドを用意するのではなく、
on()とtrigger()を使わせる理由が全く分からないんです。

自分としては、
* Storeに、場当たり的(ACTIONごと)にon()、trigger()で処理が追加されるのは、許容したくない。
* Storeの設計の段階で、そもそも必要なインターフェイスは全て用意している。
* Storeの設計で想定してないような方法でプロパティを操作されたくない。

ACTIONごとに必要な処理、
複数のストアへの操作が必要だったり、順番のある処理をしたり、ストア同士が対話するなど、
具体的な処理は、Dispatcherへの登録で良いのではないか。と。

マウント部分

todo.html
<script src="https://cdn.jsdelivr.net/npm/riot@3.6.0/riot+compiler.min.js"></script>
<script src="https://cdn.rawgit.com/medialize/sass.js/v0.6.3/dist/sass.js"></script>
<script src="/js/riot-flux.js"></script>
<script>
  // riotタグ内でSASSが使えるようにするための処理
  riot.parsers.css.sass = function(tagName, css) {
    var result = Sass.compile(css);
    return result;
  };
  // InputStoreクラスを定義
  class InputStore extends Store {
    DEFAULT_TEXT() { return "" }
    DEFAULT_PRIO() { return "mid" }

    get text() { return this._text }
    set text(text) { this._text = text; this.changed() }
    get priority() { return this._priority }
    set priority(priority) { this._priority = priority; this.changed() }

    constructor() {
      super();
      this._text      = this.DEFAULT_TEXT();
      this._priority  = this.DEFAULT_PRIO();
    }
    init() {
      this._text      = this.DEFAULT_TEXT();
      this._priority  = this.DEFAULT_PRIO();
      this.changed();
    }
  }

  // TodoStoreクラスを定義
  class TodoStore extends Store {
    get tasks() { return this._tasks; }
    get hasCompletedTasks() {
      let completed = this.tasks.some(function(task, i){
        return task.done
      });
      return completed;
    }
    constructor() {
      super();
      this._tasks = [];
    }

    addTask(...tasks) { //console.log(tasks)
      for ( const task of tasks  ) {
        this._tasks.push(task);
      }
      this.changed();
    }
    toggle(i) {
      let task = this._tasks[i];
      if ( task ) {
        task.done = !task.done;
        this.changed();
      }
    }
    clearCompletedTasks() {
      let tasks = this.tasks.filter(function(task, i){
        return !task.done
      });
      this._tasks = tasks;
      this.changed();
    }
  }

  // ディスパッチャの設定
  dispatcher.addStore("todo",   new TodoStore());
  dispatcher.addStore("input",  new InputStore());
  dispatcher.on(ACTION.TODO_ADD, function(stores, ...tasks){ //console.log(tasks)
    stores.todo.addTask(...tasks);
    stores.input.init()
  });
  dispatcher.on(ACTION.TODO_TOGGLE, function(stores, i){
    stores.todo.toggle(i);
  });
  dispatcher.on(ACTION.TODO_CLEAR, function(stores){
    stores.todo.clearCompletedTasks();
  });
  dispatcher.on(ACTION.INPUT_WRITING, function(stores, text){
    stores.input.text = text;
  });
  dispatcher.on(ACTION.INPUT_CHANGE, function(stores, priority){
    stores.input.priority = priority
  });

  // riotタグをマウント
  riot.mount("todo", {dispatcher:dispatcher, action:ACTION});
</script>

何をしてるかザッと説明しますと、

↓で、Dispatcherクラス、Storeクラス、ACTIONの定義を読み込んでます。

<script src="/js/riot-flux.js"></script>

Storeクラスを継承し、InputクラスとTasksクラスを作ってます。
プロパティやメソッドなどは、②のToDoリストを同じです。

dispatcherには、addStore()メソッドで、InputとTasksというストアを持たせて、
dispatcher.on(ACTION...)でアクションが伝えられた時のStoreへの伝令処理を、設定しています。

最後に、
riot.mount()でtodoタグをマウントして、引数としてディスパッチャとアクションをriotタグに渡しています。

fluxな部分を解説

処理のフローについて

ACTION.TODO_ADD のフローを例に解説してみます。

onclickによって、TODO_ADDというアクションが起こったことがディスパッチャ知らされます。

ディスパッチャは、TODO_ADDを受け取り、
TODO_ADDというアクションに登録された一連の処理を、ストアを通して実行していきます。

TODO_ADDというアクションは、
入力されたタスクを、TasksストアにaddTask()で追加し、
タスクが追加されたので、タスクの入力欄を空にしたいので、Inputストアをinit()で初期化します。

Inputストア、Tasksストアは、それぞれchangedを発行します。

ビューは、それぞれのストアのchangedを検知し、this.update()をかけ、
ビューに展開されてているストアは、更新された値で再描写されます。

アクション → ディスパッチャ → ストア → ビュー

という流れになっています。

役割について

ストアとビューについては、前回の記事で説明済みということで省略します。

今回追加した、アクションとディスパッチャについて、
しれっと実装したコードを紹介してしまいましたので、何故使うのかってところを簡単に説明します。

アクション

上のほうでも書いてますが、
アクションは、APIの明示と、ストアとビューの橋渡し的な役割を持ちます。

ストアとビューは互いにどんなものかを知らないですが、アクションというキーを基に同じ方向を向いた処理を実装できます。

ビューからACTIONがコールされ、ディスパッチャを介し、コールされたACTIONに対応するストア操作が処理される、といった具合です。

ディスパッチャ

ディスパッチャの役割は、アクションに紐づくストア操作の一括管理と言ったところでしょうか。

ディスパッチャを通してのみ、ストアを操作する、というルールを設けることで、
ストアを操作する箇所が散らばるのを防ぐことができます。

また、どんなアクションでどんな処理が行われているか?
もディスパッチャ見れば一目瞭然となります。

設計について

ACTIONの見極めが重要になるかなぁと。

まず、このアプリが出来ることを考える、
考えたらACTIONとして定義する。

っで、処理を実装する。
そのアクションを、叩けるユーザインターフェイスを用意する。

こんな感じですかね。

まとめ

本来のfluxとは違うものになってしまった気がする。

理解してない、理解できない、理解しかねるってのもあるけど、
何より、ここはこっちのが良いだろう、という自分流を取り入れてしまう所に原因があるのだと思う←

あるオブジェクトを操作するクラスを定義し、
そのオブジェクトを継承し、Store機能を追加するのが良いかもしれない。

すれば、元々のオブジェクトを汚すことなく、ストアに具体的な処理をガンガン追加できる。
そして、ディスパッチャは肥大することなく、ストアへアクションを丸投げできる。

fluxの道を誤った。。。

まぁfluxもどきにはなっていると思いますので、真のfluxの理解の助けぐらいにはなると思います。

次回は、このfluxもどきを使ってサクッとカウンターとカレンダーを作ってみるとします。