JSのMV*は何がいいのか論争は最近収束気味な気はしますが、あんまり触ることができていなかったので、きよくらさんの話を聞いて興味を持っていたKnockout.jsを、仕事で作っているプロダクトに導入してみました。
AngularJSは途中から導入するには難しいですし、BackboneJSはなんか面倒臭かった記憶がありました。結局jQueryで頑張ろうと思えばなんとかなるレベルなので、とりあえずjQueryでDOMと戦っていましたが、面倒臭さが勝ち始めてきました。Knockout.jsは、MVVMとしてシンプルな機能を提供してくれているので、途中から導入できそうでした。
ちなみにKnockout.jsとjQueryを合わせて使っているので、プロジェクト内からjQueryを撲滅するとかそういうことはありません。相変わらずjQueryは便利に使っています。
jQueryのみで作ってDOMを掘っていく作業の辛いところは、変更に弱いことと、頭の中でAjaxで取得するであろうhtmlを想像しながら書いていくことです。まぁ普通だと思ってやっていたので、苦行か?と言われたら慣れの問題なのかもしれません。
Knockout.jsでデータバインディングを行うと、今までAjaxで取得して突っ込んでいたものをその場に書いておくことができるので、見通しがよくなりました。
適当なサンプルを書いてみます。
Before
サンプルコードとして想像で書いているので、文法的に間違っている部分があるかもしれません(検証してません)
<div id="result"> <!-- ここに何が入ってくるかは他のViewを見ないとわからない --> </div>
よくある、「この#resultなにが入るねん」問題。
<ul id="todo_list"> <% @todos.each do |todo| %> <li> <span class="<%= todo.finish ? 'finish' : 'active' %>"><%= todo.title %></span> <button type="button" class="btn_finish">完了</button> <button type="button" class="btn_delete">削除</button> </li> <% end%> </ul>
これがAjaxで取得されるhtmlです。
spanのclassをTodoモデルのfinishを見て出力しています。
また、buttonが2つありますが、ここではボタンが何をするのかわかりません。class名でだいたいの当たりはつけられますが…。
$ -> $("#result").load '/foo' $(document).on 'click', '.btn_delete', -> $(this).parent().remove() $(document).on 'click', '.btn_finish', -> title = $(this).prev() if $(title).hasClass("active") $(title).removeClass("active").addClass("finish") else $(title).removeClass("finish").addClass("active")
Beforeの場合、どういう要素がAjaxで追加されるのかわからないため、わりかし$(document).onが連発で定義されます。また、ToDoの完了時に見た目を調整するためにクラスをつけたりはずしたりするのに、状態チェックとしてクラスを持っているかどうかを確認しています。また、タイトルの見た目を変えたいので、押されたボタンの兄弟要素を取得するために$(this).prev()とかしていますが、要素の場所が変わったら動かなくなります。
削除する場合も、押されたボタンの親に当たるliタグを見つけてDOMを削除ということをしています。DOMを駆け上がらないといけません…。
そりゃあんたのコーディングの仕方が悪いだけだ、みたいな意見もあるかもしれませんがわりかしこういうのはよくあるパターンじゃないかと思って書いています。
After
次はKnockout.jsを使った場合です。
サーバサイドからは基本的にJSONのみ返します。
<div id="result"> <ul data-bind="foreach: list"> <li> <span data-bind="text: title, css: isFinish"></span> <button data-bind="click: toggleFinish">完了</button> <button data-bind="click: $parent.delete">削除</button> </li> </ul> </div>
data-bindと書くのがKnockout.jsのお作法です。
バインドされたViewModelのlistが展開されてリストが作成されます。#resultの中になにが入るのか一目瞭然です(本当はこの#resultもいらないけれど、比較のために残してます)。また、クリックイベントについてもこちらに書いておけますので、押されたら何が起きるのかがだいたいわかります。また、JSで操作するための余計なidやclassは一切なくなりました。
json.array! @todos do |todo| json.extract! todo, :title, :finish end
これはJBuilderでTodoクラスの配列をJSON化してます。
データだけなのでシンプルですね。
class TodoViewModel constructor: -> @list = ko.observableArray() delete: (todo) -> @list.remove(todo) class Todo constructor: (title, finish) -> @title = title @finish = ko.observable(finish) @isFinish = ko.computed -> if @finish() 'finish' else 'active' , this toggleFinish: -> @finish(!@finish()) $ -> todo_view_model = new TodoViewModel() ko.applyBindings(todo_view_model) $.getJSON '/foo.json', (data) -> data.forEach (datum)-> todo_view_model.list.push(new Todo(datum.title, datum,finish))
クライアントサイドに色々実装するので、こちらは長くなります。
まずは、TodoViewModelクラスを定義しています。これはTodoリスト自体を管理するためのViewModelです。@listにはTodoクラスのインスタンスが入れられていきます。
Todoの1つ1つの動きはTodoクラスに定義されています。
spanタグにどういうcssを当てるのかも、Todoインスタンスの@finishの状態を見て勝手に書き換えてくれます(@isFinishが担当)。Dom探査が必要ありません。
削除ボタンを押された場合は、$parent.deleteを実行しています。この場合、$parentはTodoViewModelになります。なので、TodoViewModelのdeleteメソッドが呼ばれますが、順番を意識せずに@list.remove(todo)でちゃんと削除したい要素が消されます。削除されたらもちろん画面上からも削除されます。
※なお、ko.mapping.fromJSを使っていないのはわざとです。わかりやすくするため。
感想
jQueryのみでやっていた場合は、View側でもclassを意識して書いて、JSでもクラスを意識して書いて…という感じで、画面をガチャガチャ弄っていたために、片方で定義し忘れていてときどきレイアウト崩れが起きるとか、そういうこともあったと思うのですが、Knockout.jsを使うと、かなり見通しがよくなります。オブジェクトの状態が画面に反映されるので、オブジェクトに注力してコーディングできます。
RubyとJS間でのデータの受け渡しがもうちょっと簡単だったらなぁ…とは思いますが、Knockout.jsならば局所的に導入することもできるので、プロジェクト全体を書き直さないといけない!とかそういうこともないですし。DOM探査が辛くなってきたところに対して導入してみたら、綺麗に処理できるようになって嬉しいんじゃないかなと思います。