Rails 4 で Knockout.js の ViewModel を Controller毎に別のファイルで管理する


少しハマったので、問題と解決方法をメモします。
これがベストなのか良く分からないので、もし別案があったらコメントお願いします。

## やりたいこと

各 View に対する Knockout.js の ロジック(ViewModel)を、Controller毎に別の JavaScirpt(CoffeeScript)ファイルに書きたい。

.. いたって普通のことなのですが、Rails だと下記のような問題がありました。

## 問題

環境は Rails `4.1.1`、Knockout.js `3.1.0` です。

- Controller毎のJavaScript(CoffeeScript)ファイルは、`app/assets/javascipts`配下に置けるものの、Railsの仕様上、全ページでロードされしまう。
- `ko.applyBindings` は引数の２つ目で、バインド対象の DOM を指定する機能がある。しかし、その DOM が存在しない場合は、全 document がバインド対象となる、という挙動である。
- よって、本来目的とする View と別の View で `ko.applyBindings` が呼び出された場合は、一つの DOM に複数の ViewModel がバインドされることになるが、次のような重複エラーとなる。

	![スクリーンショット 2014-08-28 21.49.28.png](https://qiita-image-store.s3.amazonaws.com/0/35733/c4a006bf-634d-83a0-3774-0ab7a0d41ea7.png "スクリーンショット 2014-08-28 21.49.28.png")

## 検討

- View ファイルに JavaScipt(CoffeeScirpt) を書けばなんとかなる？
	- ⇒ 管理上、やっぱり View とは分けたい。
- アセットパイプラインあたりの設定を変えたり、各Viewそれぞれで必要な JavaScript を読み込むようにする？
	- ⇒ 面倒だし検証が必要。Sprockets の動きや、プレコンパイルの動きも良く分からない..
	- ⇒ なにより、View 毎に読み込む JavaScript(CoffeeScript) ファイルが異なるとなると、production 時の JavaScript ファイル群の concat(結合) の恩恵が受けられない。

## どうしたか

Railsの設定はいじらず、本来目的とする View以外で Knockout.js のロジックが呼ばれても問題ないようにします。（全ページでロードされても問題ないようにする。）

ちなみに、私の環境(Mac)は次のとおりです。
> OS： `Mac OS X 10.9.4 (Mavericks)`
> Rails： `4.1.1`
> Ruby： `2.1.2`
> Knockout.js： `3.1.0`

### View

- Knockout.js のバインド範囲は、明示的に div タグ id 指定をするようにします。
	- 下記は Haml 記法ですが、`<div id="ItemView">` と `</div>` で囲うという意味です。

	```haml:app/views/items/index.html.haml
	〜
	#ItemView
	  %p
	    ほげほげ：
	    %input{"data-bind" => "value: hoge, valueUpdate: 'afterkeydown'"}
	〜
	```

### Viewに対応するJavaScript(CoffeeScript)ファイル 

- `ko.applyBindings` の際、２つ目の引数で明示的に、対応する div タグ id を指定します。

	```coffeescript:app/assets/javascripts/items.js.coffee
	$ ->
	
	  〜
	
	  viewScope = "#ItemView"
	  if $(viewScope)[0]
	    ko.applyBindings new ItemViewModel(), $(viewScope)[0]
	
	  return
	```

- `if` の説明
	- このJavaScript(CoffeeScript)ファイルは、Railsの全ページで読み込まれてしまいます。その際、別ページの View ではもちろん、`<div id="ItemView">` は存在しません。そのままだと、 **`ko.applyBindings` 引数２つ目の DOM が存在しない場合は、全 document がバインド対象となる** という挙動により、別ページで本来やりたかったバインドとこのバインドが重複し、例のエラーとなってしまいます。
	- そこで、上記のように `if` で、もしその DOMが存在したらバインドする、としてやります。

これで安全に、本来目的とする View に ViewModel のバインドができ、Controller 毎のロジックをそれぞれ独立した JavaScript(CoffeeScript)ファイルで管理し、また production 時には JavaScriptファイル郡の minijy や concat(結合)の恩恵を受けることができる構成になるかと思います。

## [2014.08.29追記]どうしたか その②

同僚に相談したところ、こっちの方がすっきり書けそうなので紹介します。

### View

- View側で次のようにバインディングします。ViewModel名の前に `HOGEAPP_ITEM.` をつけておきます。
	- Viewから `(function(){` .. `}).call(this);` で囲われた JavaScriptファイルの中身にアクセスするため。

	```haml:app/views/items/index.html.haml
	〜
	#ItemView
	  %p
	    ほげほげ：
	    %input{"data-bind" => "value: hoge, valueUpdate: 'afterkeydown'"}

      :coffee
        $ ->
          ko.applyBindings new HOGEAPP_ITEM.ItemViewModel(), $("#ItemView")[0]
	〜
	```

### Viewに対応するJavaScript(CoffeeScript)ファイル 

- 次のようにして、ViewModelを外部へ公開します。
	- 間違えて他のView用のViewModel呼ぶのを防ぐため、`_ITEM` のように Controller名をつけておきます。

	```coffeescript:app/assets/javascripts/items.js.coffee
    $ ->
      HOGEAPP_ITEM = window.CPADM_ITEM = window.CPADM_ITEM ? {}
    
      HOGEAPP_ITEM.ItemViewModel = () ->
        self =
          hoge: ko.observable()
          moge: ->
            return
        self
    
	  return
	```