Knockout.jsのコンポーネント化を劇的に簡単にする方法


Knockout.jsって便利だなーとめっちゃ思っているのですが、bindingする範囲の管理に困っていました。そこに対する解決策というか解決案です。

今まで

Knockout.jsでは、管理の範囲をid要素指定で限定することができますが、把握するのが大変です。

# #foo内でbindingする。
ko.applyBindings(view_model, docuemnt.getElementById('foo'))

これだと#foo内の要素とかに間違えてbindingしたりすると重複bindingでエラーになります。あとDOMが辛いのにDOMで範囲を指定するのがダサいですね…。

やはりコンポーネント化か。しかし…

@sukobutoさんにtwitterで教えてもらったのですが、コンポーネント化して管理するのがよさそうです。

たしかに、全体を管理するAppViewModelを作ると、withでコンポーネントを管理できるし、すでにbinding済みの領域との衝突が発生しなくてよさそうです。

しかし、コンポーネント化しようとすると、ko.applyBindingsを実行するタイミングが難しいのと、コンポーネントは全部一旦AppViewModelのプロパティとして登録するのか?ということになります。

app_view_model.js.coffee
class window.AppViewModel
  constructor: ->
    @fooViewModel = new FooViewModel()
    @barViewModel = new BarViewModel()
    @hogeViewModel = new HogeViewModel()
    # これをViewModelがあるだけ繰り返すとかめんどい

$ ->
  app_view_model = new AppViewModel()
  ko.applyBindings(app_view_model)

まぁ、やってられません。

コンポーネントを動的に追加する

そこで、全体を管理するAppViewModelを修正して、動的に追加できるようにしました。

app_view_model.js.coffee
class window.AppViewModel
  addViewModel: (view_model_name, view_model) ->
    Object.defineProperty(this, view_model_name, {
      value: view_model,
      writable: false,
      enumerable: true
    })

  addViewModels: (hash) ->
    self = this
    $.each hash, (key, value) ->
      self.addViewModel(key, value)

キモは、definePropertyを使っているところです。これを使って、動的にプロパティを定義し、valueにコンポーネント化するViewModelを渡しています。
また、複数のViewModelを一気に登録できるように、addViewModelsも定義してあります。
複数のViewModelを登録するため、と書いていますが、本当はHashで書きたいだけです。

class InquiryViewModel
  constructor: ->
    @email = ko.observable('')
    @body = ko.observable('')
    # など、適当に…

$ ->
  # ダブルクォートで囲むのがだるい…
  app_view_model.addViewModel("inquiryViewModel", new InquiryViewModel())
  # ダブルクォートがなくて綺麗(私的に)
  app_view_model.addViewModels(inquiryViewModel: new InquiryViewModel())

Before(改修前)

お問い合わせフォームをKnockout.jsで作っていたとします。

html(slim)

inquiry.html.slim
/ お問い合わせフォーム
form#new_inquiry data-bind="with: inquiryViewModel"
  input type="email" name="email" data-bind="value: email, valueUpdate:  afterkeydown"
  textarea name="body" data-bind="value: body, valueUpdate: afterkeydown"

  input type="submit" value="送信" data-bind="enable: checkParams"

お問い合わせフォーム用のViewModel

inquiry_view_model.js.coffee
class window.InquiryViewModel
  constructor: ->
    @email = ko.observable('')
    @body = ko.observable('')
    @checkParams = ko.computed ->
      # 簡易的なチェック
      @email().length > 0 && @body().length() > 0
    , this

全体を管理するためのViewModel

app_view_model.js.coffee
class window.AppViewModel
  constructor: ->
    @inquiryViewModel = new InquiryViewModel()

$ ->
  app_view_model = new AppViewModel()
  ko.applyBindings(app_view_model)

beforeのように作ると、使わないViewModelまで追加した状態になってしまいます。そのようにしない方法もあるでしょうが、constructorが膨れ上がっていくと思われるのであんまり見た目的にもよくありません。

After(改修後)

そして、思いついたのが、これです。

html(slim)

お問い合わせフォームは変わりません。

お問い合わせフォーム用のViewModel

inquiry_view_model.js.coffee
class window.InquiryViewModel
  constructor: ->
    @email = ko.observable('')
    @body = ko.observable('')
    @checkParams = ko.computed ->
      # 簡易的なチェック
      @email().length > 0 && @body().length() > 0
    , this

$ ->
  # お問い合わせフォームが存在するときのみbindingする
  if $('#new_inquiry').is '*'
    # app_view_modelにinquiryViewModelプロパティを追加
    app_view_model.addViewModels(inquiryViewModel: new InquiryViewModel())

全体を管理するためのViewModel

app_view_model.js.coffee
class window.AppViewModel
  addViewModel: (view_model_name, view_model) ->
    Object.defineProperty(this, view_model_name, {
      value: view_model,
      writable: false,
      enumerable: true
    })

  addViewModels: (hash) ->
    self = this
    $.each hash, (key, value) ->
      self.addViewModel(key, value)

$ ->
  window.app_view_model = new AppViewModel()

# 全てのJSの処理が終わってから、必要なコンポーネントのみ追加した状態でbindingする
$(window).load ->
  ko.applyBindings(app_view_model)

このようにすることで、必要なコンポーネントのみをAppViewModelに追加した状態でbindingすることができます。余計な処理が入り込むことがなくなりますし、コンポーネントもwithで範囲指定ができるので、わかりやすいかと思います。

注意

Object.definePropertyはIE9以上から利用可能なので、IE8以下の対応が必要な場合は上記の方法は使えません。

参考URL: http://kangax.github.io/compat-table/es5/