【Javascript】Knockuout.js で option のカスケード処理を実装する

2018/12/01 追記

前書き

最近お仕事の方でKnockout.jsを使ってごにょごにょやっています。

で、あるフォーム画面を作るときにとあるselect optionのカスケードをやりたいな、と思って色々試行錯誤したんですが、思ったより大変だったのでメモしておきます。

optionsバインディングの使い方

先にoptionsバインディングについて簡単に説明しておきましょう。(日本語版

ドキュメントを読めばわかると思うんですが、data-bindingで指定できるものは以下の通りです。

パラメータ 概要
options select内のoptionとして使う配列。
optionsCaption selectの初期表示として表示する文言。optionの先頭に生成され、value""(空文字)になる。
optionsText optionsの中身がオブジェクトの場合にoptionのラベルとして使用するプロパティ名。
optionsValue optionsTextと同様に、optionsの中身がオブジェクトの場合にoptionvalueとして使用するプロパティ名。
optionsIncludeDestroyed 論理削除フラグ。boolean を指定する。
optionsAfterRender 生成したoptionに対し何らかの処理を加えるためのコールバック。
selectedOptions optionの初期選択値。selectmultiple属性を指定している場合は配列を指定すればよい。
valueAllowUnset valueバインディング日本語版)でoption内に存在しない値が指定されたときの挙動を制御する。false(デフォルト値)だと、valueバインディングしたものにoptionに指定されていない値が指定されると強制的にundifinedへ上書きするが、trueならばちゃんとその値がvalueとして保持される。(ただし、selectの見た目は空白になる。)*1

公式で紹介されている方法(withバインディング

公式ページのデモの一つにCart editorなるものがあります。(日本語版

ここで使われている方法を使えば比較的簡単かつ直感的にカスケード処理を実装することができます。と言うか、このデモでやってることがカスケードそのものです。

ただちょっと内容が豪華すぎて本当に知りたい部分がわかりにくいので、もうちょっと簡素な形で作ってみましょう。JSFiddleも用意してあるので動作確認はそちらでどうぞ。

まずはこんな ViewModel を用意します。

function Parent(label, value, children) {
  this.label = label;
  this.value = value;
  this.children = ko.observableArray(children);
}

function Child(label, value) {
  this.label = label;
  this.value = ko.observable(value);
}

function ViewModel() {
  var self = this;
  this.opts = ko.observableArray([
    new Parent("Parent1", 1, [new Child("Child1", 1), new Child("Child2", 2)]),
    new Parent("Parent2", 2, [new Child("Child3", 3), new Child("Child4", 4)])
  ]);
  this.parentSelected = ko.observable();
  this.childSelected = ko.observable();
  this.checkValue = function() {
    var vParent = self.parentSelected()
      ? self.parentSelected().value
      : "undifined";
    var vChild = self.childSelected() ? self.childSelected() : "undifined";
    alert("Parent: " + vParent);
    alert("Child: " + vChild);
  };
}

ko.applyBindings(new ViewModel());

で、HTML はこんな感じです。

<table>
  <tr>
    <td>Parent</td>
    <td>
      <select
        name="parent"
        data-bind="options: opts
                    , optionsText: 'label'
                    , value: parentSelected
                    , optionsCaption: '選択してください'"
      >
      </select>
    </td>
  </tr>
  <tr>
    <td>Children</td>
    <td data-bind="with: parentSelected">
      <select
        name="child"
        data-bind="options: children
                    , optionsText: 'label'
                    , optionsValue: 'value'
                    , optionsCaption: '選択してください'
                    , value: $parent.childSelected"
      >
      </select>
    </td>
  </tr>
</table>

<button data-bind="click: checkValue">値確認</button>

流れとしてはこんな感じですね。

ParentのoptionがViewModel.optsの配列でセットされる
(表示される文言はParent.label、値はParent.value)
↓
Parentで選択した値(Parentクラス)がViewModel.parentSelectedにセットされる
↓
select#childにparentSelectedが渡される
↓
optionsにParent.childrenがセットされ、表示される文言はChild.label、値はChild.valueの値になる。
**ここで選択された値をViewModel.childSelectedに反映させるため、valueは$parent.childSelectedにする。**

カスケードされるselectを最初から表示する

上記の方法ではselect#parentが選択されるまでselect#childは非表示の状態になっています。

何らかの理由でselect#childを先に表示させておきたい場合は、ifを使って初期表示用のselectを html に書いてしまいましょう。

visibleでも見た目上同じことができますが、select#childが DOM 上に二つ存在することになってしまいます。ifであれば DOM の再作成 / 削除を行ってくれるのでその心配もなくなります。form使わないならどっちでもいいですが。

JSFiddle

<table>
  <tr>
    <td>Parent</td>
    <td>
      <select
        name="parent"
        data-bind="options: opts
                    , optionsText: 'label'
                    , value: parentSelected
                    , optionsCaption: '選択してください'"
      >
      </select>
    </td>
  </tr>
  <tr>
    <td>Children</td>
    <!-- ko if: parentSelected() -->
    <td data-bind="with: parentSelected">
      <select
        name="child"
        data-bind="options: children
                    , optionsText: 'label'
                    , optionsValue: 'value'
                    , optionsCaption: '選択してください'
                    , value: $parent.childSelected"
      >
      </select>
    </td>
    <!-- /ko -->
    <!-- ko ifnot: parentSelected() -->
    <td>
      <select name="child" disabled>
        <option value="">先にParentを選択してください</option>
      </select>
    </td>
    <!-- /ko -->
  </tr>
</table>

<button data-bind="click: checkValue">値確認</button>

親要素が選択されていない場合は全子要素を選択できるようにする

これも初期表示用のselectとカスケード用のselectを作ってしまいましょう。

あわせて事前に全子要素を持った配列を作ってもいいですが、ko.computedを使った方がスマートですし変更にも強いです。

(JSFiddle)

// ViewModelに以下を追加
this.allChildren = ko.computed(function() {
  // flatten
  return [].concat.apply(
    [],
    self.opts().map(function(x) {
      return x.children();
    })
  );
});
<!-- ko if: parentSelected() -->
<td data-bind="with: parentSelected">
  <select
    name="child"
    data-bind="options: children
            , optionsText: 'label'
            , optionsValue: 'value'
            , optionsCaption: '選択してください'
            , value: $parent.childSelected"
  >
  </select>
</td>
<!-- /ko -->
<!-- ko ifnot: parentSelected() -->
<td data-bind="ifnot: parentSelected()">
  <select
    name="child"
    data-bind="options: allChildren
            , optionsText: 'label'
            , optionsValue: 'value'
            , optionsCaption: '選択してください'
            , value: childSelected"
  >
  </select>
</td>
<!-- /ko -->

formsubmitと併用する

カスケードとかは Knockout.js で全部設定するけどsubmitに関してはもうformの機能でやってしまいたい、と言うこともあるでしょう。

もちろん可能ですが、withバインディングを使う方法だとこれはできません…。

値の確認で使っているViewModel.checkValueをフォームのvalueで表示するようちょっと変更してみましょうか。(JSFiddle

this.checkValue = function() {
  /* var vParent = self.parentSelected() ? self.parentSelected().value : "undifined"; 
    var vChild = self.childSelected() ? self.childSelected() : "undifined"; */
  var vParent = window.test.parent.value;
  var vChild = window.test.child.value;
  alert("Parent: " + vParent);
  alert("Child: " + vChild);
};

で、実際に動かしてみると Parent の value は絶対にとれないと思います。

理由はこれ、非常に簡単で、selectoptionsバインディングJavascript のオブジェクトが指定されている場合、optionsValueバインディングを指定しないとoptionvalueはすべて空文字になるからです。(実際に FireBug とかで見てみるとわかる)

んじゃあoptionsValueを設定してやるかーとこんな感じにしてみます。(JSFiddle

<td>Parent</td>
<td>
  <select
    name="parent"
    data-bind="options: opts
            , optionsValue: 'value'
            , optionsText: 'label'
            , value: parentSelected
            , optionsCaption: '選択してください'"
  >
  </select>
</td>

動かしてみるとどんな Parent を選択しても Children が出てこなくなります。

これまた理由は非常に簡単で、optionsValueが設定されたことで、valueバインディングに設定したparentSelectedにはParent.valueが入ってくるからです。今回の例で言えば「1」とか「2」みたいなただの数字が渡されています。

となれば、withバインディングされているselect#childからしたら、optionsに指定されたchildrenなんて取れるわけがない(そもそもそんなプロパティがない)ので、空白のコンボボックスを表示するしかない、と言うわけです。

じゃあどうすればいいのかってとこですが、選択された値から Array を検索してしまいます。ダサいですね。

ViewModelにこんなコードを追加します。parentSelectedに値が入っていたらViewModel.opt.valueを検索して該当するParentを返すようにします。(JSFiddle

this.cascade = ko.computed(function() {
  var vParent = self.parentSelected();
  return vParent
    ? self.opts().filter(function(x) {
        return x.value == vParent;
      })[0]
    : null;
});

html はこんな感じ。

<form name="test">
  <table>
    <tr>
      <td>Parent</td>
      <td>
        <select
          name="parent"
          data-bind="options: opts
                        , optionsText: 'label'
                        , optionsValue: 'value'
                        , value: parentSelected
                        , optionsCaption: '選択してください'"
        >
        </select>
      </td>
    </tr>
    <tr>
      <td>Children</td>
      <td data-bind="if: cascade">
        <select
          name="child"
          data-bind="options: cascade().children
                        , optionsText: 'label'
                        , optionsValue: 'value'
                        , optionsCaption: '選択してください'
                        , value: childSelected"
        >
        </select>
      </td>
    </tr>
  </table>

  <button data-bind="click: checkValue">値確認</button>
</form>

これで値確認用のボタンをクリックすればちゃんと Parent も Child も値が取得できます。

まとめ

と言うわけで、ここ最近得た知見をいろいろまとめました。

*1:ちゃんと動かないことで有名。