jQueryプラグインの書き方を考えてみる(1)

更新履歴

2010-01-21
プラグイン定義方法については、約1年後に再考した下記エントリの方をお勧めします。
プラグインを書いてみる

まずこのへんの記事を参考に、

jQueryは、プラグインで手軽に機能を実装できるのが特徴です。プラグインディレクトリ(Plugins | jQuery Plugins)に沢山のプラグインが公開されていますが、作り方を調べてみたら、かなり簡単に自分でも作成できるよう。以下の記事は分かりやすいチュートリアルになっています。
jQuery For Programmers: Part 1

jQuery のプラグインを作成する - GoodPic.Com

適当なプラグインを書いてみます。

//プラグイン定義
(function($j){
$j.fn.Sample=function(opt){
    var opt=$j.extend({max:200,min:100},opt);

    this.wrap('<div style="border:solid 10px #fafafa;margin-top:10px;"></div>')
        .css('border','solid 10px #eee').html('click!')
        .wrapInner('<div style="border:solid 10px #ddd;background:#ccc"></div>')
    ;
    var out$j = this.parent();
    var in$j = this.find('> div');

    var resize = function(size,idx){
        var wout$j=out$j;
        var win$j=in$j;
        if(idx!=undefined){
            wout$j=$j(out$j[idx]);
            win$j=$j(win$j[idx]);
        }
        wout$j.animate({width:size})
        win$j.animate({height:size-40})
    }

    var min = $j.min = function(idx){
        resize(opt.min,idx)
        resize(opt.max,idx)
        resize(opt.min,idx)
    }

    var max = $j.max = function(idx){
        resize(opt.max,idx)
        resize(opt.min,idx)
        resize(opt.max,idx)
    }

    $j('<button>click!</button>').prependTo('body').click(function(){
        out$j.width()==opt.min?max():min();
    });

    in$j.click(function(){
        var idx = in$j.index(this);
        $j(out$j[idx]).width()==opt.min?max(idx):min(idx);
    })

    out$j.each(function(idx){
        $j(this).css('margin-left',idx*opt.min);
    })

    return $j;   
}})(jQuery)   
//プラグイン実行
jQuery(function($j){
    $j('#sample1,#sample2').Sample().min();
})


サンプルページ

書いてみて気になったのが以下の点です。

ローカル関数・インスタンスメソッド(?)の定義コスト

resize、min、maxといったローカル関数が、プラグインメソッド内で定義されてるので、メソッド実行の都度発生するこれら関数生成の処理コストが気になります。ここでいうインスタンスメソッドとは、$j('#sample1,#sample2').Sample()によって生成されたjQueryインスタンス内でのみ有効なメソッドのことで this.$j.min や this.$j.max を指しています。(インスタンスメソッドという名前は適当につけた名前です。正しい呼び方があるかもしれません。)

可読性や保守性

ロジックが長くなると可読性や保守性が悪くなりそうです。もっとOOP的な書き方はできないか?

ローカル関数の定義コスト

サンプルプログラムなのでローカル関数の規模や数は微々たるものですが、これがもっと増えた場合や、大量のjQueryインスタンスからそのようなプラグインメソッドが実行された場合の処理コストが気になるところです。
まず、メソッドの定義パターンと、処理速度的なコストを確認してみます。

実行の都度、汎用ルーチンの定義を繰り返すパターン

上記のプラグインの例と同じケースです。

var loop=10000;
var a=function(){
	for(var i=0;i<10;i++)this['a'+i]=1;
	for(var i=0;i<20;i++)this['f'+i]=
	function(){/* 省略(テストではコストに影響するので長めに書いています) */};
}
var d=new Date();for(var i=0;i<loop;i++)new a()
alert((new Date())-d)

4969ms

汎用ルーチンの定義を事前に済ましておくパターン

実行時は関連付けのみを行います。

var loop=10000;
var af=function(){/* 省略(テストではコストに影響するので長めに書いています) */};
var a=function(){
	for(var i=0;i<10;i++)this['a'+i]=1;
	for(var i=0;i<20;i++)this['f'+i]=af
}
var d=new Date();for(var i=0;<loop;i++)new a()
alert((new Date())-d)

2719ms

prototypeオブジェクトに、事前に定義しておくパターン
var loop=10000;
var a=function(){}
for(var i=0;i<10;i++)a.prototype['a'+i]=1;
for(var i=0;i<20;i++)a.prototype['f'+i]=
function(){/* 省略(テストではコストに影響するので長めに書いています) */};
var d=new Date();for(var i=0;i<loop;i++)new a()
alert((new Date())-d)

141ms

ロジックを見れば当然といえば当然かもしれませんが prototype による定義が処理速度的にはコストがかかってません。使用メモリの観点ではどうかというと、実は以前、日本野望の会さんの「Javascriptでカプセル化を実現する!」という記事で、

カプセル化した場合、コンストラクタ内でメソッドが定義されているので、new するたびfunctionが定義され、prototypeで定義するよりメモリを多くとられてしまうということはないのでしょうか? - cyokodog

Javascriptでカプセル化を実現する!- 日本野望の会

というコメントを書いたところ、以下のような詳細な調査結果を別エントリでご回答くださいました。

これを計算してみると1つあたりのメモリコストは
1,110 bytes
となっているようです。およそ1kのメモリが余分に使われてしまうわけです。
これは確かにコスト高な感じはします。
富豪プログラミングをしたいひとにはオススメということで
どうでしょうか(泣)

Javascriptでカプセル化のコスト - 日本野望の会

メモリ的にもそれなりのコストがかかるようですので、Javascriptの場合は極力prototypeベースでメソッド定義するのが良いかと思っています。

jQueryプラグイン定義の場合

jQueryプラグインを追加するということは「jQueryオブジェクトにprototypeベースのメソッドを追加する」ということと等価なわけで、問題視してたのはその追加メソッド内のローカル関数の再定義のコストと、プラグイン自体のソースの可読性や保守性についてです。
ローカル関数の定義ついては、前述の結論からすると prototypeベースでの定義なら処理コストはかかりませんが、ローカルでなくなってしまうのでこれは論外かと。既存メソッドとの名前競合も懸念されます。

ですので「汎用ルーチンの定義を事前に済ましておくパターン」で書くのが良いかと思われます。

//プラグイン定義
(function($j){
    var opt;
    var out$j;
    var in$j;
    var resize = function(size,idx){
        var wout$j=out$j;
        var win$j=in$j;
        if(idx!=undefined){
            wout$j=$j(out$j[idx]);
            win$j=$j(win$j[idx]);
        }
        wout$j.animate({width:size})
        win$j.animate({height:size-40})
    }
    var min = function(idx){
        resize(opt.min,idx)
        resize(opt.max,idx)
        resize(opt.min,idx)
    }
    var max = function(idx){
        resize(opt.max,idx)
        resize(opt.min,idx)
        resize(opt.max,idx)
    }
    jQuery.fn.Sample=function(pOpt){
        var $j=jQuery;
        opt=$j.extend({max:200,min:100},pOpt);
    
        this.wrap('<div style="border:solid 10px #fafafa;margin-top:10px;"></div>')
            .css('border','solid 10px #eee').html('click!')
            .wrapInner('<div style="border:solid 10px #ddd;background:#ccc"></div>')
        ;
    
        this.min = min;
        this.max = max;

        out$j = this.parent();
        in$j = this.find('> div');
    
        $j('<button>click!</button>').prependTo('body').click(function(){
            out$j.width()==opt.min?max():min();
        });

        in$j.click(function(){
            var idx = in$j.index(this);
            $j(out$j[idx]).width()==opt.min?max(idx):min(idx);
        })
    
        out$j.each(function(idx){
            $j(this).css('margin-left',idx*opt.min);
        })
        return this;
    }   
})(jQuery)   
//プラグイン実行
jQuery(function($j){
    $j('#sample1,#sample2').Sample().min();
})

サンプルページ

OOP的に書きかえてみる

次にこれをOOP的に定義し直し、プラグインメソッドはそれをコールすためだけのインターフェースという位置付けで書きかえてみます。

//プラグイン定義
(function($j){
    var Sample= function(node$j,opt){
        var my=this;
        my.$j=node$j;
        my.opt=$j.extend({max:200,min:100},opt);
        my.$j.min=function(idx){return my.min(idx).$j};
        my.$j.max=function(idx){return my.max(idx).$j};
        my.build();
    }
    Sample.prototype={
        build : function(){
            var my=this;
            my.$j
                .wrap('<div style="border:solid 10px #fafafa;margin:10px;"></div>')
                .css('border','solid 10px #eee').html('click!')
                .wrapInner('<div style="border:solid 10px #ddd;background:#ccc"></div>')
            ;
            my.out$j = my.$j.parent();
            my.in$j = my.$j.find('> div');
            $j('<button>click!</button>').prependTo('body').click(function(){
                my.out$j.width()==my.opt.min?my.max():my.min();
            });
            my.in$j.click(function(){
                var idx = my.in$j.index(this);
                return $j(my.out$j[idx]).width()==my.opt.min?my.max(idx):my.min(idx);
            })
            my.out$j.each(function(idx){
                $j(this).css('margin-left',idx*my.opt.min);
            })
            return this;
        },
        resize : function(size,idx){
            var my=this;
            var out$j=my.out$j;
            var in$j=my.in$j;
            if(idx!=undefined){
                out$j=$j(my.out$j[idx]);
                in$j=$j(my.in$j[idx]);
            }
            out$j.animate({width:size})
            in$j.animate({height:size-40})
            return this;
        },
        min : function(idx){
            this.resize(this.opt.min,idx).resize(this.opt.max,idx).resize(this.opt.min,idx);
            return this;
        },
        max : function(idx){
            this.resize(this.opt.max,idx).resize(this.opt.min,idx).resize(this.opt.max,idx);
            return this;
        }
    }
    $j.fn.Sample=function(opt){
        return ((new Sample(this,opt)).$j);
    }   
})(jQuery)

//プラグイン実行
jQuery(function($j){
    $j('#sample1,#sample2').Sample().min();
})

サンプルページ

getterメソッドの追加

tableのヘッダを固定させる簡易scriptをサクっと作ってみた」でも書きましたが、プラグインの実行で動的にHTMLタグが追加生成され、それらのノードの取得メソッドを追加したい場合があるかと思います。今回の例では in$j や out$j がそれに該当します。
以下のように書くとメソッドチェーン可能なgetterメソッドの追加が容易に行えます。

//プラグイン定義
(function($j){
    var Sample= function(node$j,opt){

        - 省略 -

        return my.crrossBindGetter({
            self : my.$j,
            inFrame : my.in$j,
            outFrame : my.out$j
        });
    }
    Sample.prototype={

        - 省略 -

        crrossBindGetter : function(getter){
            for(var i in getter)for(var j in getter)getter[i][j]=this.getGetter(getter[j])
            return this;
        },
        getGetter : function(o){
            return function(){return o}
        }
    }

    - 省略 -

})(jQuery)

//プラグイン実行
jQuery(function($j){
    $j('#sample1,#sample2').Sample().min()
    .inFrame().css('background','red')
    .outFrame().css({'background':'blue','padding':'10px'});
})

サンプルページ

但し、上記の記述だけでは inFrame() や outFrame() の getterメソッド実行後、min() や max() といったメソッドをメソッドチェーンで実行することができません。

$j('#sample1,#sample2').Sample().inFrame().css('background','red').min() // error!

また、OOP的な記述にした影響で min,maxメソッドの記述が冗長化しています。

//変更前
this.min = min;
this.max = max;

//変更後
my.$j.min=function(idx){return my.min(idx).$j};
my.$j.max=function(idx){return my.max(idx).$j};

次回は「自分用class定義ライブラリmyclass.js」のjQueryプラグイン版をからめて、この辺の問題を解決し、もう少し容易にプラグイン定義ができる方法を考えてみます。

jQueryプラグインの書き方を考えてみる(2)」につづきます。