最近のjs界隈ではビューのフレームワークといえばReact, Vue.js, Angularの三強ですが、みなさんも業務ではちょっと昔のBackbone時代に書かれたコードをまだ目にすることがあるのではないでしょうか。
そういった大人の事情でBackbone時代のMarionette.jsにいまどきのFluxの概念を組み込んだらどうなるのかやってみました。
はじめに
この記事はMarionette.jsやFluxをある程度理解している方向けです。
この記事やサンプルコードを読む上で困ったときは以下をオススメします。
Marionette.js
https://marionettejs.com/
Marionette.js公式ドキュメントです。Viewの項目から使い方を見ていくのがよいと思います。
https://marionettejs.com/docs/master/marionette.view.html
Flux
Fluxの解説記事はググればたくさん見つかるはずですが、個人的にはazuさんの10分で実践するFluxという記事が図入りで理解しやすく、コードもシンプルで分かりやすかったのでオススメです。
ちなみに今回の記事でのFluxはライブラリは使わずにオレオレで実装しているので、ReduxなどのFluxを実装した特定のライブラリの知識はなくても大丈夫です。
目指すゴール
- jQueryは使わない
- Fluxのフローで状態を更新する
- 状態が更新されるとViewも自動的に更新される
おおまかな流れ
このようなサイクルで状態を更新し、Viewを更新します。
- グローバルな1つのStateから各Viewは自身のViewModelを作成
- ViewはViewModelを元に描画する
- 状態の更新はActionCreator経由でStateを書き換える
- StoreはStateが更新されたら更新イベントを発行
- 各ViewはStoreの更新を監視して、更新を検知したら自身のViewModelを更新する
- ViewModelが更新されるとViewが再描画される
ではそれぞれについて詳しく見ていきましょう。
StateからViewを描画
ReactであればStateから必要なpropsを取り出してJSXでDOMを構築するところです。
MarionetteのViewはthis.modelという自身が管理するBackbone.Model(以降、ViewModelと呼びます)と、underscore.jsのテンプレートを使って描画するのが基本スタイルです。
そのため、まずはStateからViewModelを作ります。
const state = {user_id: 1}
const model = new Backbone.Model(state)
const view = new HogeView({model: model})
次にViewModelが更新されたら自動的にrender()が再度実行されるようにviewのModelEventsに定義します。
const HogeView = Mn.View.extend({
ModelEvents: {
change: function() { this.render() }
}
})
これでViewの描画処理をViewModelに依存させることができました。
ViewModelの中身によって描画されるDOMが決定され、ViewModelが更新されたタイミングでDOMが再描画されます。
ちなみにViewModelの更新は単にset()でOKです。
Reduxなどでよく見るように現代ではStateを更新するためにObject.assign()を使うのが一般的ですが、Backbone.Model.set()は同じような挙動をしてくれます。
const newState = {user_id: 2}
model.set(newState)
// model.get('user_id') => 2 となる
Actionの発行
例えば何かの要素をクリックして状態を更新する場合、FluxではActionを発行してStoreがStateを更新します。
このフローに関してはMarionetteならではの工夫は特にせず、普通のクラスとして作ったActionCreator経由でActionを発行します。
ActionCreatorは世の中のFluxのフレームワークが行っているように何かイイ感じにViewから呼び出せるようにしてもいいですが、今回はコードを追いやすくするためにViewを作るときに外からActionCreatorを引数として渡すようにしました。
StoreとActionを結びつけるためのdispatcherも一緒に渡していますが、これらについては後述します。
import dispatcher from './Dispatcher'
const state = {count: 1}
const store = new Store(dispatcher, state)
const action = new ActionCreator(dispatcher)
const view = new CounterParentView({action: action, dispatcher: dispatcher})
子ViewからActionを発行したい場合
親子関係にあるViewの場合、子Viewは一般的に親から必要な情報だけもらって描画するだけのViewになるはずです(いわゆるComponent)。
その場合、子ViewはActionCreatorを持たないので子ViewからActionを発行したい場合にはActionCreatorを持つ親Viewまでイベントを伝搬させて、親ViewがActionを発行することになります。
MarionetteにはchildViewEventsという子Viewのイベントを親Viewがキャッチする仕組みが存在するので、これを使うのがいいでしょう。
以下は色々端折っているので実際には動きませんが、こんな感じのコードになります。
実際に動いているサンプルコードはこのあたりです。
https://github.com/Kesin11/MarionettePlayground/blob/master/src/modules/counter.js
// 子View
const CounterChildView = Marionette.View.extend({
// クリックしたときにcount:upというイベントが親に伝わる
triggers: {
"click": "count:up",
},
)}
// 親View
const CounterParentView = Marionette.View.extend({
// showChildViewによって親子関係を作る
regions: {
childRegion: "#child-region",
},
onRender() {
this.showChildView("childRegion", new CounterChildView())
},
// 子Viewのイベントをキャッチしたときにactionを発行する
childViewEvents: {
"count:up": function() { this.action.countUp(1, this.model.toJSON() },
},
// Marionette.ViewではchildViewEventsに書かなくてもonChildview**()というメソッドを実装するだけで子Viewのイベントをハンドリングすることも可能
// "count:up"の場合はonChildviewCountUp()というメソッド名になる
)}
Stateの更新ロジックをどこで行うか
クリックする度にカウントアップしていくような単純なカウンターを例にすると、元のStateに+1して新しいStateを生成する部分です。
昨今のFluxフレームワークではReducerがこの役目を担っているのですが、今回は本題ではないためActionCreatorにやらせてしまっています。
それなりの規模のものを作るのであれば、ActionCreatorが肥大化する前にReducerなどを用意してStateの更新ロジックはそこに移していくのがよいでしょう。
class ActionCreator {
constructor(dispatcher) {
this.dispatcher = dispatcher
}
dispatch(eventName, payload) {
this.dispatcher.trigger(eventName, payload)
}
countUp(value, currentState) {
const newState = { count: currentState.count + value }
this.dispatch("change:counter", newState)
}
}
非同期の更新ロジックはどこで行うか
代表的な例としてはajax通信です。
レスポンスが返ってくる間ActionCreatorでブロックし続けてしまうのは問題なので、どこかしらで非同期処理によってレスポンスが返ってきたらActionを発行してやる必要があります。
この処理をどこに書くべきなのかはこれだけで議論ができてしまうテーマですが、今回はこれも本題ではないので特に触れません。
個人的には、それほど大きな規模でないうちはActionCreatorの中でcallbackなりPromiseなりを使ってレスポンスが返ってきたらActionを発行するだけで十分ではないかと思います。
// fetchCount()は中でajax通信をして新しいカウントを取ってくるメソッドとする
// fetchCount()はPromiseを返すメソッドなのでthenで受ける
fetchCount().then((newState) => {
this.dispatch("change:counter", newState)
})
Store
StoreもMarionetteならではの工夫は特にない普通のクラスです。
StoreはActionとViewとのやりとりするためにdispatcherを持ってます。
発行されたActionと渡された新しいStateを元にStoreが管理しているStateの更新を行います。
Stateを更新したときにchange:store
のイベントを発行し、Storeのイベントを監視しているViewにStateが更新されたことを伝えます。
class store {
constructor(dispatcher, state) {
this.dispatcher = dispatcher
this.state = state
this.dispatcher.on("change:counter", this.updateCounterState, this)
}
// dispatchとonは単に書きやすくするためのメソッド
dispatch(eventName, payload) {
this.dispatcher.trigger(eventName, payload)
}
on(eventName, handler) {
this.dispatcher.on(eventName, handler, this)
}
// counterのStateを更新する
updateCounterState(newCounterState) {
// ES2015でない場合はundersore.jsの_.assign()で代用可能
this.state.counter = Object.assign(this.state.counter, newCounterState)
this.dispatch("change:store", this)
}
}
Dispatcher
DispatcherはView、Action、Storeをイベントで紐付ける役割です。
DispatcherはいわゆるEventEmitterであれば何でもいいのですが、Marionette.Objectが丁度この機能を持っているようだったのでDispatcherとしました。
View、Action、Storeで使われるDispatcherは共通である必要があるため、dispatcher.jsではインスタンスをexportし、importするモジュールで使いまわすことでシングルトン的な使い方をしています。
export default new Marionette.Object.({})
Viewの更新
ActionCreatorを持っているViewはStoreの監視も行います(いわゆるContainer)。
Storeのchange:store
イベントをキャッチしたらStoreから新しいStateを取り出し、ViewModelを更新します。
そしてViewModelが更新されたら再度render()も実行されるようにすることで、Storeが更新されたら自動的に更新されるViewのできあがりです。
export const CounterParentView = Mn.View.extend({
initialize(args) {
this.store = args.store
// Storeの変更を監視
this.store.on("change:store", (_store) => {
this.onChangeStore(this.getState())
})
},
// storeからこのViewに対応するStateを取り出す方法
getState: function() {
return this.store.state.counter
},
// 新しいStateでViewModelを更新する
onChangeStore: function(state) {
this.model.set(state)
},
// ViewModelに変更があったらrender()を呼び出して再描画する
modelEvents: {
change: function { this.render() },
},
})
これでやっとFluxのフローの完成です!
Actionを発行してStateを更新し、新しいStateに応じてViewが自動的に更新されるというサイクルが実現できました。
ちなみにgetState()やonChangeStore()など処理はStoreを監視するViewではどれもほぼ共通となるので、サンプルコードではContainerViewというクラスを作りました。
Containerの役割を持たせたいViewはこのクラスを継承するようにしています。
https://github.com/Kesin11/MarionettePlayground/blob/master/src/ContainerView.js
おまけ: CSSアニメーションが動かなくて困った話
MarionetteというかBackboneとテンプレートエンジンの話になりますが、普通にrender()で描画するとCSSアニメーションが動かないです。
原因はBackboneの時代ではテンプレートエンジン(Backboneだとデフォルトはunderscore.jsを使います)でDOMを毎回丸ごと作り直しているからです。
これだとクラスを付けたり外したりして制御するCSSアニメーションは動かないです。
ReactやVue.jsだと特に問題にはならないのですが、それらのフレームワークはVirtualDOMによってDOMを毎回作り直しているのではなくて、例えばclassだけ付けたり外したり状態の更新前と更新後を計算してDOMのうち変更が必要なところだけ変更されているからなのでした。
VirtualDOMが登場する前のMarionetteではCSSアニメーションが関係するところはテンプレートエンジンでの描画は諦めて、クラスのバインディングを手作業で書いていくしかなさそうです。
今回はBackbone.stickitを使いました。
VirtualDOMが何故便利なのか正直今までちゃんと理解していなかったのですが、古典に触れることで新しきの良さを理解することができました。
まとめ
一世代前のフレームワークであるMarionette.jsにFluxを組み込んだサンプルアプリを作ってみました。
有名なライブラリを使わずにオレオレFluxなので洗練はされていないですが、逆に複雑ではないのでコードを読んで理解するのもそれほど大変ではないはずです。
あとは何気にMarionetteとかBackbone.stickitの世代と、webpackやES2015を組み合わせたコードは珍しいと思います。
大人の事情で古めのライブラリを使う必要がある、けどwebpackやES2015は使いたいという方には参考になるかもしれません。