WIZ-CODE.blog

JavaScriptやAjaxをテーマとしたブログです。

*

jQueryプラグインの高度な作成法 ― ES5によるカプセル化

      2016/04/05

jQueryプラグインはWeb上でいまや数千もの種類が公開されているらしく、DOM要素にエフェクトを与えるちょっとしたものから、豪華なスライドショーを実装する手の込んだものまで様々なプラグインが用意されています。そのほとんどが次のように、たったひとつのメソッドの実行でプラグイン機能を利用できます。

$('#gallery').slideshow();

[jQuery(あるいはドルマーク$)関数(=コンストラクタ)]にセレクタを指定して、返り値である[jQueryオブジェクト(=インスタンス)]にプラグインメソッドをつなげるスタイルが一般的です。

jQueryプラグインの多くはWebページにウィジェットを追加したり、DOM要素にエフェクトやアニメーションを加えるもので占められています。そのため、プラグイン自体はせいぜいひとつかふたつの機能を実行するのに特化していて、プラグインメソッドがひとつあれば十分なケースがほとんどです。

一方で、jQueryのプラグイン群を見渡すと、追加した機能について後々プラグイン使用者(ウェブマスター)が削除を意図したとき、プラグイン制作者が削除のための手段やAPIを提供していないものが多いように見えます。たとえば、スライドギャラリーを実装するプラグインがあったとして、ユーザーが閉じるボタンなどで任意にスライドギャラリーを隠せるようにしたいとウェブマスターが考えたとき、もしプラグイン側でそれを停止するか実行前の状態に戻す手段を用意していなければ、ウェブマスターはプラグインを適用した要素のイベントを削除するなどの処理コードを自分で書かなければなりません。

現在のWebサイトはSPAとまではいかなくとも、Ajaxやアニメーションを多用して、シングルページでコンテンツやアプリケーションを完結させる傾向が強くなっています。ですから、プラグインを適用した要素にいつまでもイベント監視が張り付いていたり、プラグイン機能がページ上でもはや不要になったのにそのまま放置するというのはメモリ管理上、または思わぬバグにつながりやすいなど様々な観点でよろしくないと思われます。今後プラグイン制作者はプラグインのライフサイクル(起動から終了、削除までの一連の流れ)を意識した制作を行っていく必要が出てくるのではないでしょうか。

そうなると、これまで多くの人々が気軽にjQueryプラグインを制作できた理由でもある、jQuery.fnにメソッドを登録するだけという簡易な方法がかえってアダになります。jQueryプラグインはただの[メソッド]であるために、それ自体[クラス]のような複雑な構造を持つことができないのです。

jQueryプラグインは基本的に、セレクタで指定した要素集合のjQueryオブジェクトを対象にするタイプ、つまりjQuery.fnにメソッドをアタッチしたプラグインになります。そのため、ひとつのプラグインにつき実行できるメソッドはひとつだけです。もし、複数のメソッドをひとつのプラグインに加えたいとしたら、プラグイン制作者はjQueryプラグインを作成するときに、いくつかの選択をする必要があるでしょう。

//ひとつのプラグインに複数のメソッドを登録するケース
;(function ($) {
    $.fn.addSlideShow = function () {
        ....
    };

    $.fn.removeSlideShow = function () {
        ....
    };
}(jQuery));

しかし、この方法だとどちらのメソッドもパブリックメソッドになるため、実質プラグインをふたつ使用するのと同等であり、メソッド名が他のプラグインとかぶる危険が出てきます。そして、メソッド数が増えれば増えるほどその可能性は高まります。その他、制作者がプラグインのメソッド名がかぶらないようわざと名前を冗長にするといった使用者に優しくない対応をするケースも想定されます。これはアンチパターンとされています。自分も過去に作ったプラグインでこのような感じのを作ったことがありますが。

このデメリットを解決する方法はいくつかあります。jQueryプラグインをうまいことカプセル化する設計方法 – jamadam weblogで、いくつか紹介されている解決策を参考にしてみます。そのなかで、本家jQueryで提供されているUIコンポーネント、jQuery UIで使用されている方法論は、妥当かつ推奨されているもののひとつです。

//アコーディオンウィジェットを作成
$('#menu').accordion();

//ウィジェットを破棄するdestroyメソッドを実行
$('#menu').accordion('destroy');

ところがこの、メソッドを文字列にして引数として渡す形式については、Web界隈では抵抗感があるとかダサいという意見が多少見受けられ、自分もまたこの方法があまり好きになれません。もし、jQueryプラグイン名を名前空間としてモジュール化する設計方法があったとしたら、それが最良ということになるでしょう。

;(function($) {
    $.fn.myPlugin = {};
    $.fn.myPlugin.init = function (params) {
        ....
    };
    $.fn.myPlugin.destroy = function (params) {
        ....
    };
})(jQuery);

ただし、このやり方だとメソッドに肝心のjQueryオブジェクトが渡らないし、プラグインがただのオブジェクト{}であるため、プラグインを初期化(実行)するとき必ず配下のメソッドで行わなければならなくなり冗長になります。しかし、ECMAScript5で追加された機能を使えば、この設計方法でプラグインを作成することが不可能ではありません。

アクセサディスクリプタでプラグインを定義する

ECMAScript5版にて、JavaScriptオブジェクトのプロパティをGetter/Setter関数によって定義できるObject.defineProperty()/Object.defineProperties()が追加されました。Internet Explorerはバージョン9以上、FirefoxChromeは最新バージョンであれば、これらの関数を使用できます。上記のモジュール化を実現するにはGetter関数が必要でした。なぜかというと、プラグインの名前空間オブジェクトがアクセスされた時点で、特定の処理をする関数が呼び出される必要があるからです。通常のJavaScriptで変数やプロパティにアクセスしただけでは何も起きません。一方で、Getter関数でプロパティを定義した場合、プロパティを関数として呼び出さずともアクセスした時点で内部で関数が実行されます。その仕組みを利用します。

;(function($) {
    //Object.defineProperty()でプラグインを定義する
    Object.defineProperty($.fn, 'myPlugin', {
        enumerable: false,
        get: function () {
            //ここに初期化コードを書く
            ....
            ....

            //プラグインメソッドを登録する
            this.method1 = method1;
            this.method2 = method2;

            //thisキーワードを返すことでメソッドチェーンを持続
            return this;
        }
    });

    function method1() {
        ....
        return this;
    }
    function method2() {
        ....
        return this;
    }
})(jQuery);

このようにプラグインを定義することで、プラグインのプロパティにアクセスしただけで初期化が完了し、

$(function () {
    /*
     プロパティにアクセスしただけだが
     プラグインの初期化が実行されている!
    */
    $('#my-plugin').myPlugin;
});

さらに登録したメソッドもその後のメソッドチェーンで使用できるようになります。

$(function () {
    //以降のメソッドチェーンで登録メソッドを使用可能に
    $('#my-plugin').myPlugin.method1(params);
});

しかし、プラグインのプロパティにアクセスしただけで初期化されてしまうというのは少し強引すぎるかもしれません。アクセスした時点で初期化は実行せず、代わりに初期化する関数をリターンする方法に変えてみます。そっちのやり方の方がよりスマートでしょう。

;(function($) {
    Object.defineProperty($.fn, 'myPlugin', {
        enumerable: false,
        get: function () {
            //thisキーワードをバインドした初期化関数を作成
            var initialize = $.proxy(init, this);
            //メソッド群はこの関数のプロパティに代入する
            initialize.method1 = method1;
            initialize.method2 = method2;
            
            return initialize;
        }
    });

    //初期化関数を定義する
    function init() {
        //忘れずにthisキーワードを返す
        return this;
    }

    function method1() {
        ....
        return this;
    }
    function method2() {
        ....
        return this;
    }
})(jQuery);

関数を返す方式に変えることで、プラグインのプロパティにアクセスしただけでは初期化されなくなります。

$(function () {
    /*
     アクセスしただけでは何も起こらない
     関数が返されるだけだからである
    */
    $('#my-plugin').myPlugin;
});

プラグインを実行(初期化)するには、通常のjQueryプラグインの使用方法と同じくメソッドとして呼び出します。

$(function () {
    //メソッドとして呼び出して初めてプラグインが実行される
    $('#my-plugin').myPlugin();
});

また、登録したプラグインのメソッドを呼び出すには、プラグインのオブジェクトにメソッドをドットシンタックスでつなげます。

$(function () {
    //プラグインのメソッドを呼び出す
    $('#my-plugin').myPlugin.method1();
});

このやり方の利点は、プラグインのメソッドがjQueryオブジェクトに追加されない点です。プラグインのメソッド名を考えるとき、.add().remove()など、たまたまjQuery APIのメソッド名とかぶってしまうケースが考えられませんか。最初に示した定義方法だと、jQueryオブジェクトの固有プロパティとして.method1().method2()が追加されてしまうため、これらはメソッドチェーンが続くかぎり、その後も使用できてしまいます(固有プロパティはプロトタイプ=jQuery.prototypeのプロパティより優先されます)。もし、プラグインのメソッドに.add()というのを登録していると、後の方でjQuery APIの.add()が使いたくなったときに困りますね。二番目の定義方法ならば、プラグインのメソッドが使えるタイミングが、プラグインのオブジェクトの直後に限定されるため、以降のメソッドチェーンに影響が出る可能性がなくなります。

ところが、Getter関数でjQueryオブジェクトではなく初期化用の関数を返すようになったことで、別の問題が出てきます。もし、上記のコードのように初めからプラグインのメソッド.method1()を呼び出そうとすると、初期化が行われない状態で.method1()が呼び出されてしまいます。

$(function () {
    //プラグインは初期化されていない
    $('#my-plugin').myPlugin.method1();
});

この解決策として、初期化されたかどうかの真偽値をjQueryオブジェクトに保持させて、もし初期化されていなければメソッド内の条件分岐で初期化を先に実行するようにしてみます。また、thisキーワードでバインドした初期化関数(下記のコードの変数initializeに該当)が、プラグイン・オブジェクトがアクセスされるたびに毎回作成されるのは無駄なので、こちらも.data()メソッドを使用して関数をキャッシュしておきます。

;(function($) {
    //追加・変更点をハイライト
    Object.defineProperty($.fn, 'myPlugin', {
        enumerable: false,
        get: function () {
            var initialize;

            //初期化関数がキャッシュされていなければ新規に作成
            if (!this.data('initialize')) {
                initialize = $.proxy(init, this);

                initialize.method1 = method1;
                initialize.method2 = method2;

                this.data('initialize', initialize);
            } else {
                //すでにキャッシュ済みならそちらを使用する
                initialize = this.data('initialize');
            }
            
            return initialize;
        }
    });

    function init() {
        if (!this.data('initialized')) {
            this.data('initialized', true);
            //初期化処理
            ....
        }
        return this;
    }

    //初期化したかどうかで条件分岐させる
    function method1() {
        if (!this.data('initialized')) {
            init.apply(this);
        }
        ....
        return this;
    }
    function method2() {
        if (!this.data('initialized')) {
            init.apply(this);
        }
        ....
        return this;
    }
})(jQuery);

最後に、プラグインのメソッド名がjQuery APIのメソッド名とかぶったときの対処方法です。jQuery関数を親クラスに見立てて、$superというプロパティをjQuery.fnに追加して、このプロパティからjQuery APIにアクセスできるようにします。

$.fn.$super = function (method) {
    var args;
    args = Array.prototype.slice.call(arguments, 1);
    return $.fn[method].apply(this, args);
};

プラグインのメソッドに.html()というのがあるとして、jQueryオブジェクトにjQuery APIの.html()を適用する必要があるケースの使用例です。

function init() {
    this.$super('html', 'This is jQuery API!!');
}

以上、ES5を用いたjQueryプラグインの作成方法の紹介でした。

 - JavaScript/Ajax, Tips , , ,