サンプルに惑わされるな!KnockoutでUIエフェクトを使う際のベター・プラクティス

  • 97
    いいね
  • 5
    コメント
この記事は最終更新日から1年以上が経過しています。

demo
なにはともあれJSFiddleで動きを見てみて下さい

はじめに

Knockout はリッチな Web UI を作るシンプルなフレームワークです。
Web UI ではフェードやスライドアニメーションなどのエフェクトを使用したいケースは少なくありません。Knockout 公式サイトの Live examples にも、アニメーションを適用するサンプル日本語訳はこちら)があります。

しかし当サンプルのエフェクトのかけかたは Effective じゃないので、この記事を書きました。
下図の「Knockout における Model-View-ViewModel パターン」を前提に説明いたします。

スライド1

サンプルの問題点 - エフェクトロジックの実装場所

エフェクト付きのバインディングハンドラを再定義

ズバリ、サンプル で登場する fadeVisible のことです。

View
<p data-bind="fadeVisible: displayMessage">
    尚、このメッセージは displayMessage プロパティが false になると自動的に消滅する。
</p>
ViewModel
function ViewModel() {
    var self = this;
    self.displayMessage = ko.observable(true);
}
$(function() {
    var vm = new ViewModel();
    ko.applyBindings(vm);
    vm.displayMessage(false); // これでメッセージがフェードアウト
};
BindingHandlers
// jQuery の fadeIn() / fadeout() メソッドを使ってエレメントの 可視/不可視 を切り替える
ko.bindingHandlers.fadeVisible = {
    init: function(element, valueAccessor) {
        var value = valueAccessor();
        $(element).toggle(ko.unwrap(value));
    },
    update: function(element, valueAccessor) {
        var value = valueAccessor();
        ko.unwrap(value) ? $(element).fadeIn() : $(element).fadeOut();
    }
};

これの何が問題か、というと...

機能が同じバインディングハンドラが量産されてしまう

fadeVisible バインディングの本来の機能は「要素を表示するか否か」を ViewModel の状態に基づいて決定するという意味において visible バインディングと同じですよね。
単に jQuery の fadeIn/fadeOut を使うためだけにバインディングハンドラが追加されました。要件があれば slideDown/slideUp animate ...と際限なく追加されて行きます。

スライド2

やめましょう!

カスタムバインディングを作るメリットは他にたくさんあります。しかし、エフェクトという「目的と結果を一つに絞り切れないもの」のために有効な手段とはいえません。

ViewModel でエフェクトロジックを実装

ViewModel でエフェクトロジックを実装、というのはつまり次のようなコードの事を言います。

View
<ul data-bind="foreach: { data: items, afterAdd: showItem, beforeRemove: hideItem }">
    <li data-bind="text: $data"></li>
</ul>
ViewModel
function ViewModel() {
    var self = this;
    self.items = ko.observableArray([]);

    self.showItem = function(elem) {
        $(elem).hide().fadeIn(300);
    };

    self.hideItem = function(elem) {
        $(elem).fadeOut(300, function(){ $(elem).remove() });
    };
}
$(function() {
    var vm = new ViewModel();
    ko.applyBindings(vm);
    vm.items.push("追加アイテム"); // アイテムがフワッと追加される
};

showItem/hideItem がエフェクトロジックです。
items にアイテムが追加されれば、フェードインとともに UI 上に表示され、
逆に削除されれば、フェードアウトして UI から消えます。

それって View のお仕事じゃない?

要素が追加・削除されたときにどういうエフェクトが発生するか、という一つの関心ごとは View で解決すべきではないでしょうか。

ViewModel の役割は View のために状態を保持し、View と Model の間で情報を伝えることです。
エフェクトは ViewModel に何の影響も与えませんし、ViewModel は View でどういうエフェクトが発生するかを知らなくても自分の役割を全うできます。

スライド3

MVVM の 決まり事だから ViewModel で実装してはいけない、という話ではなく、
ViewModel の役割じゃないことを ViewModel に押し付けることにメリットはない、ということです。
「複雑さの方向性が全く異なる」「View の世界の出来事」を ViewModel で表現する必要はなく、またそれらを表現するのに適している場所はやはり View です。

アニメーションは CSS3 に任せるのがベスト

Web における View のグループには CSS があります。しかも CSS3 はトランジションやアニメーションが使えます。これを使わない手はありません!

スライド4

エフェクトの表現には CSS3、エフェクトの発動は CSS バインディング

まずは fadeVisible の例です。これらは CSS バインディングで簡単に実現できます。

View(HTML)
<p class="message" data-bind="css: { shown: displayMessage }">
    尚、このメッセージは displayMessage プロパティが false になると自動的に消滅する。
</p>
View(CSS)
.message { /* 通常は透明 & 非表示 */
    opacity: 0;
    transition: opacity .3s
}
.message.shown { /* shown クラスがつくとフェードイン */
    opacity: 1;
}
/* 簡略化のためにベンダープレフィックスは記載していません */
ViewModel
function ViewModel() {
    var self = this;
    self.displayMessage = ko.observable(true);
};
$(function() {
    var vm = new ViewModel();
    ko.applyBingings(vm);
    vm.displayMessage(false); // これでメッセージがフェードアウト
});

たったこれだけですし、フェードからスライドに変えたいなーと思ったら CSS を修正するだけで済みます。
何よりも、「どうレンダリングするか、どういうエフェクトをつけるか」という内容を表現するのにはバインディングよりも CSS のほうが向いていると思いませんか?

CSS でできないことなら JS (jQuery) でやりましょう

もう一つの例は、要素の追加・削除にエフェクトを付けています。
これを CSS で表現しようとすると、要素を DOM から削除するタイミングが難しくなります。

それなら、単純にエフェクトロジックを ViewModel ではなく View のグループとして実装してしまいましょう!

スライド5

View(HTML)
<ul data-bind="foreach: { data: items, afterAdd: showItem, beforeRemove: hideItem }">
    <li data-bind="text: $data"></li>
</ul>
View(JS-visual-effect)
function showItem(elem) {
    $(elem).hide().fadeIn();
}
function hideItem(elem) {
    $(elem).fadeOut(300, function(){ $(elem).remove() });
}
ViewModel
function ViewModel() {
    var self = this;
    self.items = ko.observableArray([]);
}
$(function() {
    var vm = new ViewModel();
    ko.applyBindings(vm);
    vm.items.push("追加アイテム"); // アイテムがフワッと追加される
};

バインドできるのは ViewModel のプロパティだけじゃない

すこし異様に思った方もいらっしゃるかと思います。
実は Knockout のバインド構文は単なる「式」なので、定義済みのグローバル関数をバインドすることもできます。こうすることで、ViewModel ごとにメソッドが作成されメモリを消費していたものが一回の定義で実装できます。また、ViewModel とはべつのくくり、つまり View の一部として表現することができました。

グローバル関数だとトレーサビリティ悪くなるのでちょっと、という場合はエフェクトロジックモジュールとして切り出してしまえば問題ありません。バインドには式が書けます。バインディングコンテキストの内側と外側で参照できる変数・関数ならばバインドすることができます。

VisualEffectModule
window.effects = (function($) {
    var root = {}
      , list = root.list = {}
      ;
    // View に定義することで
    // ・エフェクトの内容にちなんだ名前をつけることができる
    // ・別の UI 部品でも使いまわせる
    // というメリットもあります
    list.fadeIn = function(elm) {
        $(elm).hide().fadeIn();
    };
    list.fadeOut = function(elm) {
        $(elm).fadeOut(300, function() { $(elm).remove() });
    };
    return root;
})(jQuery);
View
<ul data-bind="foreach: {
                   data: items,
                   afterAdd: effects.list.fadeIn,
                   beforeRemove: effects.list.fadeOut }">
    <li data-bind="text: $data"></li>
</ul>

追記:これをさらに掘り下げた、高階関数を使ってパラメータ設定可能なエフェクタをつくる という記事を書きました。

CSS3 に対応していないあいつらの面倒をみるには

CSS class の切り替えだけでキレイにエフェクトをキメられた!と思っても、そう簡単に許してくれないのが古い IE の生き残りたちですよね!

でもやはり CSS class の切り替えでエフェクトが発動する、というスマートなパターンは有効に活用したい!ということで、次のようなカスタムバインディングを作ってみました。

class-binding
ko.bindingHandlers['class'] = {
    update: function(element, valueAccessor) {
        ko.bindingHandlers.css.update(element, valueAccessor);
        if (jQuery) { // jQuery がロードされてたら、classChanged イベントを発火
            jQuery(element).trigger('classChanged', [ valueAccessor() ]);
        }
    }
}

CSS バインディングの代わりに使うことで、CSS class が切り替わった時に jQuery イベントを発行してくれます。古い IE でそのイベントをハンドリングすれば、パターンを崩さずエフェクトロジックを実装できます。
※ CSS バインディングの機能はそのまま引き継いでいます。CSS3 に対応していれば、CSS3 の機能でエフェクトが実装できます。

スライド6

View(HTML)...css→class
<p class="message" data-bind="class: { shown: displayMessage }">
    尚、このメッセージは displayMessage プロパティが false になると自動的に消滅する。
</p>
View(JS-visual-effect)
// 古い IE へのポリフィルコード。
// <!--[if lt IE 9]> <![endif]--> 等を使って処理するとよい
$(function() {
    $('.message').on('classChanged', function(e, data) {
        if (ko.unwrap(data.shown)) {
            $(this).fadeIn(300);
        } else {
            $(this).fadeOut(300);
        }
    };
});
ViewModel...同じ
function ViewModel() {
    var self = this;
    self.displayMessage = ko.observable(true);
}
$(function() {
    var vm = new ViewModel();
    ko.applyBindings(vm);
    vm.displayMessage(false); // これでメッセージがフェードアウト
};

追記:class binding は設計向きな名前じゃない

CSS class binding というのは目的に則した名前ではない、ボトムアップな名前です。
トップダウンで捉えるべく WPF の VisualStateManager の概念を参考に、名前を変えて整理してみました。
JSFiddle:VisualStateBinding

VisualStateManager は、UI 部品の外観を 状態ごとに 定義するための概念で、バインドされた状態に応じて、自らのビジュアルを定義に従って自動的に最適化する機能をもちます。

こう書くと難しいですが、ポイントは「 状態 」ごとに「 見た目 」が定義できる、という考え方です。
見た目には当然アニメーションが含まれていて、状態遷移の際にどうアニメーションするかを定義することができます。

ここまでエフェクトを View に分離できれば、「 View:魅せ方 」を考える担当者と「 ViewModel以降:仕組み 」を考える担当者の分業も考えやすくなってくるのではないでしょうか。
魅せ方を ViewModel や BindingHandler で解決しようとしていたのがだんだんバカバカしくなってきました。

※ Twitter でレスを下さった方との会話でふと思いついたので、追記させていただきました。ありがとうございます。ご意見等ありましたらぜひぜひ、コメント下さい><