Knockoutの小技集 〜左右二つのリストで移動可能なセレクトボックス(2)〜

第3回は予告通り前回の続きで、項目がダブルクリックされたら、他方のリストに項目を移動する機能を追加します。

ViewModel

まずはViewのことを気にせず、ViewModelでどのような関数が必要になるかを考えます。

なるべく前回の構造を維持するという前提で、ぱっと思いつくのは、ダブルクリックで選択された項目を他方のリストに移す関数があれば、うまくいきそうです。というわけで、愚直に書いてみました。

  ViewModel.prototype.moveItemToOther = function(item) {
    // ダブルクリックされた項目が左のリストにいるか、右のリストにいるか調べる
    var existsInLeft = this.leftItems.indexOf(item) >= 0;
    var existsInRight = this.rightItems.indexOf(item) >= 0;

    if (existsInLeft) {
      this.leftItems.remove(item);
      this.rightItems.push(item);
    } else if (existsInRight) {
      this.rightItems.remove(item);
      this.leftItems.push(item);
    }
  };

View 【NG】eventバインディングを使う

先ほど作ったmoveItemToOther関数をView側で、optionのダブルクリックイベントと関連付けたいわけですが、ダブルクリックのバインドは、標準ではdblclickバインディングというものは存在しないため、eventバインディングを使います。こんな感じです。

<select data-bind="options: leftItems, optionsText: 'name', selectedOptions: selectedLeft, 
event: {dblclick: moveItemToOther}" size="10" multiple="true"></select>
<!-- 略 -->
<select data-bind="options: rightItems, optionsText: 'name', selectedOptions: selectedRight, 
event: {dblclick: moveItemToOther}" size="10" multiple="true"></select>

しかし残念ながら、このやり方ではうまくいきません。moveItemToOther関数の引数に渡ってくるのは、ダブルクリックされたItemオブジェクトではなく、ViewModelだからです。

View 【NG】optionsAfterRenderバインディングを使う

よくよく考えると、生成されたoption要素に対して後付けでダブルクリックイベントをバインドさせなければなりません。

optionsバインディングのドキュメントを参照すると、optionsAfterRenderというのが正にそれです。早速、option要素が生成された後に、dblclickイベントをバインドするようにしてみます。

Viewは以下のようになりました。

<select data-bind="options: leftItems, optionsText: 'name', selectedOptions: selectedLeft, 
optionsAfterRender: bindMoveItemWithDblclick" size="10" multiple="true"></select>
<!-- 略 -->
<select data-bind="options: rightItems, optionsText: 'name', selectedOptions: selectedRight, 
optionsAfterRender: bindMoveItemWithDblclick" size="10" multiple="true"></select>

ViewModel

各optionにダブルクリックイベントをバインドするためのbindMoveItemWithDblclick関数を、ViewModelに追加します。

  ViewModel.prototype.bindMoveItemWithDblclick = function(option, item) {
    ko.applyBindingsToNode(option, {event: {dblclick: moveItemToOther}}, item);
  };

ここで呼び出しているko.applyBindingsToNodeはHTMLの要素に対してイベントをバインドするKnockoutの関数です。

しかしこれでもうまくいきません。ko.applyBindingsToNodeの第3引数はViewModelだからです。上記のコードを実行し、optionをダブルクリックすると、itemオブジェクトが持つmoveItemToOther関数を呼びだそうとしてエラーになります。

では、第3引数にViewModelを渡すために、thisとするとどうでしょう?

  ViewModel.prototype.bindMoveItemWithDblclick = function(option, item) {
    ko.applyBindingsToNode(option, {event: {dblclick: moveItemToOther}}, this);
  };

これもうまくいきません。実はこのコードには2つ問題が有ります。一つは、itemの情報が抜け落ちてしまっていること。そしてもう一つは、bindMoveItemWithDblclick関数の中では、thisはViewModelではなくWindow(グローバルオブジェクト)であることです。

前者の問題に対しては、itemの情報が渡せるように、以下のように書き換えてみます。

  ViewModel.prototype.bindMoveItemWithDblclick = function(option, item) {
    var fn = function() {
      this.moveItemToOther(item);
    };
    ko.applyBindingsToNode(option, {event: {dblclick: fn}}, this);
  };

これだとよさそうですね(回りくどいですが…)。

View【OK】optionsAfterRenderバインディングを使う(修正)

続いて、bindMoveItemWithDblclick関数におけるコンテキストが、WindowではなくViewModelになるように、optionsAfterRenderバインディングに明示します。

<select data-bind="options: leftItems, optionsText: 'name', selectedOptions: selectedLeft, 
optionsAfterRender: bindMoveItemWithDblclick.bind($data)" size="10" multiple="true"></select>
<!-- 略 -->
<select data-bind="options: rightItems, optionsText: 'name', selectedOptions: selectedRight, 
optionsAfterRender: bindMoveItemWithDblclick.bind($data)" size="10" multiple="true"></select>

bind()は、thisに引数($data)がセットされた新しい関数を作成します。Knockout.jsのeventバインディングにちょっとだけ言及されています。

ともあれ、これでようやくうまくいきました。

まとめ

今回のハマりポイントは以下の3点です。

  • optionsAfterRenderバインディングを使うと、関数内でthisがグローバルオブジェクトになっている。
  • 上記を回避するために、.bind($data)をつけて、明示的にthisがViewModel($data)になるようにする。
  • ko.applyBindingsToNodeでイベントをバインドする場合、関数をラップしなければならないケースもある。

感想としては、Knockoutを使う場合でも、JavaScriptでありがちなthisの問題は気をつけなければならないと感じました。特に、ViewModel,ViewのロジックからDOM要素(今回の例ではoption)を操作したいようなケースでは注意したいところです。

なお、今後登場するかどうかはわかりませんが、dom要素からViewModelを取り出すko.dataFor()やko.contextFor()などのかんすうもあり、必要になるケースが出てくるかもしれませんので一応言及しておきます。

追加したコード
  • ViewModel
  ViewModel.prototype.bindMoveItemWithDblclick = function(option, item) {
    var fn = function() {
      this.moveItemToOther(item);
    };
    ko.applyBindingsToNode(option, {event: {dblclick: fn}}, this);
  };

  ViewModel.prototype.moveItemToOther = function(item) {
    var existsInLeft = this.leftItems.indexOf(item) >= 0;
    var existsInRight = this.rightItems.indexOf(item) >= 0;

    if (existsInLeft) {
      this.leftItems.remove(item);
      this.rightItems.push(item);
    } else if (existsInRight) {
      this.rightItems.remove(item);
      this.leftItems.push(item);
    }
  };
  • View
<select data-bind="options: leftItems, optionsText: 'name', selectedOptions: selectedLeft, 
optionsAfterRender: bindMoveItemWithDblclick.bind($data)" size="10" multiple="true"></select>
<!-- 略 -->
<select data-bind="options: rightItems, optionsText: 'name', selectedOptions: selectedRight, 
optionsAfterRender: bindMoveItemWithDblclick.bind($data)" size="10" multiple="true"></select>