Fluxの枠にURLルーティングを収める試行

JSer.info 200回記念祭の懇親会でざっくりアイディアだけ話していた記憶(酔っ払っていたので正確には覚えていない)なんだけど、実際に必要になったので試しに作ってみたという話。

モチベーション

Fluxパターンを用いた設計を行なっている場合というのは往々にしてSingle Page Applicationであるので、URLに基づくルーティングを要しない、純然たるアプリケーションなケースが多い。だが、アプリケーションの性質によっては、パーマネントリンク的な機能の再現をしたいことがあり、ルーティング機構が欲しかったりする。

で、そういう場合については語られてる事例をあんまり見かけなかったので、作ってみた。

デザイン

  • Storeに基本ロジックを閉じ込めるのは変わらない
  • URLに基づく履歴情報は、ユーザーインターフェースの一種と捉える。ので、Viewと考える

使ったライブラリ

基本要件として

  • URL path文字列のパースとそれに応じたルーティングの実施を行うルーティング処理
  • URL pathを書き換えるための、History API操作

のそれぞれが独立して動くようにする必要がある。 JSのルーティングライブラリは、どういうわけかBackboneとBackboneベースの派生カスタムライブラリに乗っていることが多いので、探すのに苦労した。が、幸運なことに見つかった(見つからなかったら自作せざるを得なかった)

サンプルコード

だいたいこんな感じでいいのはないでしょうか:

var crossroads = require("crossroads");
var Dispatcher = require("flux").Dispatcher;
var EventEmitter = require("events").EventEmitter;
var hasher = require("hasher");

var RoutingActionCreator = {

    moveTo: function (path) {
        RoutingDispather.dispatch({
            actionType: "Routing::moveTo",
            path: path,
        });
    },
};

var RoutingDispather = {
    _dispatcher: new Dispatcher(),

    dispatch: function (val) {
        this._dispatcher.dispatch(val);
    },

    register: function (callback) {
        this._dispatcher.register(callback);
    },
};

var RoutingStore = {
    _emitter: new EventEmitter(),

    EVENT_CHANGE: "RoutingStore::change",

    dispatchToken: null,

    setup: function () {
        crossroads.addRoute("/bar", () => {
            // route毎に必要な処理
        });

        crossroads.addRoute("/foo", () => {
            // route毎に必要な処理
        });

        // 各route毎の処理が終わったら, storeのchangeイベントを起こす
        crossroads.routed.add((path) => {
            this.emitChange(path);
        });
    },

    addChangeListener: function (callback) {
        this._emitter.addListener(RoutingStore.EVENT_CHANGE, callback);
    },

    emitChange: function (path) {
        this._emitter.emit(this.EVENT_CHANGE, path);
    },
};

RoutingStore.dispatchToken = RoutingDispatcher.register(function(payload){
    switch (payload.actionType) {
        case "Routing::moveTo":
            var path = payload.path;
            crossroads.parse(path);
            break;
    }
});


var URIHistoryView = function () {
    this.init();
};
URIHistoryView.prototype = {

    init: function () {
        RoutingStore.addChangeListener(this.onChange.bind(this));

        // URLの変更に応じてActionを起こす
        var parseHash = function parseHash(newHash){
            RoutingActionCreator.moveTo(newHash);
        };
        hasher.initialized.add(parseHash);
        hasher.changed.add(parseHash);

        hasher.init();
    },

    onChange: function (path) {
        // Storeのchangeイベントのコールバック変更のため, URLの変更だけする.
        hasher.changed.active = false;
        hasher.setHash(path);
        hasher.changed.active = true;
    },
};

function main() {
    RoutingStore.setup();
    new URIHistoryView();
}

上のコードに書いてない点

URLに紐づく具体的なViewの生成箇所について

上のサンプルコードだと明示していない。Store内の各ルーティングのコールバック内で、適当にControllerって名前をつけた関数を呼んでもいいかもしれない。Fluxの原則に外れるけど、まあどうせ生成するViewの粒度なんて大きなものだし、枠外に外れるのはここだけだし、XXX とかつけておいてドキュメントも書いておけば良いだろう。良くないか。普通にStoreから発行されるchangeイベントを監視するのもいいかもしれない

ルーティングを回避して、URLだけ更新したい場合

専用のメッセージ作ってルーター呼ばずにそのままStoreのchangeイベントを発行してViewに伝えりゃ良いじゃろ

まとめ

本サンプルコードが動かなくても気にせず、雰囲気で行きましょう。