読者です 読者をやめる 読者になる 読者になる

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

前書き

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

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

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の見た目は空白になる。)

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

公式で紹介されている方法(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も値が取得できます。

まとめ

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