TypeScriptによるオブジェクト指向講座:ModelとView

2012/10/10 22:48 に Jun Shin が投稿   [ 2012/10/16 4:10 に更新しました ]

※記事タイトルを、「TypeScript+jQueryでオブジェクト指向で画像ビュアー作った」から変更しました。

■はじめに

TypeScriptを使って、オブジェクト指向でそれなりに意味のある規模のものを何か作ってみよう、ということで、画像ビュアーを作ってみます。小さいサンプル書いているだけでは見えないことも見えてくるかも。オブジェクト指向入門的な内容にもなっています。jQueryを結構使っているので、jQueryに興味がある方にも。

画像ビュアーといってもたいしたものではありません。上にサムネイル一覧があって、サムネイルをクリックすると、下に大きく表示される、という程度のものです。

完成したものはこのような感じになります。(サムネイルをクリックすると下に表示されます。何もしなくても、5秒置きに表示内容を切り替えます。最初に画像の事前ロードが入るので、しばしお待ちを。)

また、完成したソースコード一式はこちらからダウンロードできます。せっかちな方は記事末尾に完成済の全ソースコードを貼ってあるのでそちらもどうぞ。

上記のようなものを「やっつけ」のJavaScriptで作ればあっという間でしょう。しかし、TypeScriptは「やっつけ」で作るためのものではなく、強固に設計された再利用性の高いソースコードを記述する為のものだと思います。今回は、ViewとModelどちらも「再利用」しやすい形にすることを主な目的とします。

最初はModelからです。

■Modelを設計する

●「本質」を抜き出す

まず、このアプリケーションのModel、つまり「本質」とは何かを考えて見ます。ここでいう本質とは、表示方法に左右されない、アプリケーションのコアの部分のことです。表示方法がHTMLだろうがコンソール出力だろうが変わらない部分があるなら、それはModelです。

私はいつも、「このアプリケーションではどんな状態を管理し、その状態をどのように変更させたいのか」を考えます。そして「状態」をメンバ変数として持たせ、「どのように変更させたいのか」をメソッドとして定義します。この時、Viewについては極力考えないようにします。

状態としては、まず、「今表示している画像」がありそうです。そして、「サムネイル一覧の画像リスト」もありそうです。

ModelはViewに依存せず、独立させなくてはいけませんから、「表示」という言葉はなるべく使いたくありません。そこで、「今選択されている画像」という考え方に変更します。また、「サムネイル」というのも、今回は考えないでおきましょう。そこはViewに任せるものとします。なので、「サムネイル一覧の画像リスト」ではなく、「画像リスト」がこのModelの管理対象です。Viewに関係のない、本質的な部分にのみ注目しましょう。

つまり、このModelは「複数の画像を管理し、その中の1つが選択された状態になっているもの」を表せればいいことになります。画像ビュアーから「表示」の部分を取り除いたら、それぐらいシンプルになる訳です。クラス名は「ImageSelector」としましょう。

ImageSelector.ts
class ImageSelector {
    // list of images
    private imageList : string[] = [];
    
    // index number of the list of images
    private selectedIndex : number = -1; // not selected
}

画像の情報は、画像ファイルへのURL情報としてstring型で持つことにしました。

コンストラクタには、内部で保持する為の画像リストを渡せることにしましょう。インスタンスの生成後に1つずつ画像をaddしていくのもいいのですが、実際に使う場面を考えると、生成時にまとめて画像を渡す以外の使い方が思いつきません。必要になったらその時にaddメソッドを追加すればいいでしょう。

また、画像を1つ選択するためのselectメソッドと、選択されたindexを取得するgetSelectedIndex、あと、選択されている画像(のURL)を返すgetImageメソッドや、画像リストの数を返すgetCountメソッドも、あると便利そうなので作っておきましょう。

ImageSelector.ts
/// <reference path="jquery.d.ts" />
class ImageSelector {
    // list of images
    private imageList : string[] = [];
    
    // index number of the list of images
    private selectedIndex : number = -1; // not selected
    
    // imageList : array of 
    constructor( imageList : string[] ) {
        this.imageList = imageList;
    }
    
    select( index : number ) : void {
        if ( index < 0 or this.imageList.length  - 1 > index ){
            throw new Error( "out of index" );
        }
        this.selectedIndex = index;
    }
    
    getSelectedIndex() : number { 
        return this.selectedIndex; 
    }
    
    getImage() : string {
        return this.imageList[ this.selectedIndex ];
    }
    
    getCount() : number {
        return this.imageList.length;
    }
}

これで、このImageSelectorを、次のようにして使うことができるようになります。

var imagesel = new ImageSelector( ["image1.jpg", "image2.jpg", "image3.jpg"] );
imagesel.select( 2 );

選択対象の画像リストを保持し、その中からindex番号で1つを指定し、「選択されている」という状態を保持・変更することができるようになりました。この部分は、たとえアプリの出力がHTMLだろうがCanvasだろうがコンソール出力だろうが、変わらない部分です。

●イベント通知の仕組みを作る

ただ、このままだと、誰かがこのmodelのselectedIndexを変更した場合に、View側がそれを検知することができません。modelを操作した後いちいちそれをView側にも伝えなければならないのは面倒です。
ここは、ViewからModelの変更を「イベント」として受け取れるようにしましょう。こんな風に使えるようになるのが理想です。

-- View側のコード --
imagesel.onChangeSelectedIndex( ()=> { alert( "選択が変更された!" ); } );

modelに対してcallback用の関数オブジェクトを「イベントハンドラ」として渡しておき、selectedIndexが変更されたらその関数を呼び出してもらうことで「イベント通知」とする方法です。

イベント機構としては、callback関数オブジェクトのリストを内部で保持し、イベント発生時にまとめてそれらを呼び出すような仕組みがあればいいのですが、自作するのも面倒なのでjQueryのbind命令を使います。自分自身(this)をjQueryオブジェクト化してbind命令を使えるようにし、そのインスタンスをprivateメンバjqとして保存しておきます。そして、イベントハンドラ登録用のonChangeSelectedIndexメソッドを追加して、その中でbindを呼ぶようにしましょう。

もちろん、それだけでは意味がないので、selectメソッド内でこのイベントを実行(trigger)するようにします。これでイベント機構が搭載されました。

ImageSelector.ts
/// <reference path="jquery.d.ts" />
class ImageSelector {
    // list of images
    private imageList : string[] = [];
    
    // index number of the list of images
    private selectedIndex : number = -1; // not selected
    
    // jquery object for this
    private jq : JQuery;
    
    public static CHANGE_SELECTED_INDEX : string = "changeSelectedIndex";
    
    // imageList : array of 
    constructor( imageList : string[] ) {
        this.jq = $( this );
        this.imageList = imageList;
    }
    
    select( index : number ) : void {
        if ( index < 0 || index >= this.imageList.length ){
            throw new Error( "out of index" );
        }
        this.selectedIndex = index;
        this.jq.trigger( ImageSelector.CHANGE_SELECTED_INDEX );
    }
    
    getSelectedIndex() : number { 
        return this.selectedIndex; 
    }
    
    getImage() : string {
        return this.imageList[ this.selectedIndex ];
    }
    
    getCount() : number {
        return this.imageList.length;
    }
    
    onChangeSelectedIndex( handler : () => any ) : void{
        this.jq.bind( ImageSelector.CHANGE_SELECTED_INDEX, handler );
    }
}

1行目で、jquery.d.ts への参照を指定しています。このファイルはTypeScriptのサンプルから取得できます。

出来上がったImageSelectorクラスは、次のようにして使うことができます。

var imasel = new ImageSelector( ["image0.jpg", "image1.jpg", "image2.jpg" ] );
imasel.onChangeSelectedIndex( ()=> { console.log( imasel.getImage() ) } );
imasel.select( 0 ); // "image0.jpg"
imasel.select( 1 ); // "image1.jpg"
imasel.select( 2 ); // "image2.jpg"

selectメソッドでindexを設定する度に、onChangeSelectedIndexイベントが呼び出され、console.logに出力されるのが分かるでしょうか?

■単一の画像を表示する「ImageView」を作る

●HTMLとTypeScriptを関連付ける

なかなかいい感じになってきました。ぶっちゃけこのImageSelectorクラスだけでもいろいろ使えそうな気がしますね。
とはいえ、このままでは寂しいので、まずは選択された画像を表示する為のView、ImageViewを作成しましょう。

画像1枚を大きく表示するためのView…それはどのようなものでしょうか?

ブラウザで動かすことを想定していますから、Viewの本体はHTMLです。画像を1つ表示する為のViewを非常に単純化すれば、

<div id="imageView1">
    <img />
</div>

というHTMLになるでしょう。基本的にはこのDOMがImageViewの本体です。そしてこのHTMLの装飾は、何か事情があるのでない限り、CSS側で行うべきです。TypeScript(JavaScript)側でやるべきは、装飾以外の処理、つまり画像の表示や切り替えなどの処理になるでしょう。

まずは、指定したidのDOMをViewと関連付ける処理から始めます。コンストラクタでid(ここでは上記のHTMLのdiv要素を表す"#imageView1")を渡し、内部でそのidのDOMを保持するようにしましょう。div要素の取得には、jQueryを利用します。

ImageView.ts
/// <reference path="jquery.d.ts" />
class ImageView {
    private dom : JQuery;
    
    constructor( id : string ) {
        this.dom = $( id );
    }
}

このクラスは、こんな風にインスタンス化できます。

var imageView1 = new ImageView( "#imageView1" );

内部で行われていることは単純ですが、これでDOMを「ImageView」クラスとして扱えるようになりました。
あとは、このimageView1に対して、以下のように画像を指定して表示できればいいですね。

imageView1.setImage( "image1.jpg" );

具体的には次のようなコードを追加します。

ImageView.ts
/// <reference path="jquery.d.ts" />
/// <reference path="ImageSelector.ts" />

class ImageView {
    private dom : JQuery;
    
    constructor( id : string ) {
        this.dom = $( id );
    }
    
    setImage( image : string ) {
        this.dom.children( "img" ).attr( "src", image );
    }
}

setImageメソッドの中で、ImageViewの本体の子要素(children)のimgを取得し、そのsrc属性に新しい画像URLを指定しています。jQueryさまさまです。

●作成したImageViewをテストする

試しに以下のような main.ts をつくり、実行してみましょう。ImageSelectorとImageViewを生成し、ImageSelectorのonChangeSelectedIndexイベントハンドラの中で、選択された画像をImageViewに設定しています。そして、5秒置きにindexを1つずつ増やしながら選択画像を切り替えています。画像リストの末尾に行ったら先頭に戻るようにしています。

main.ts
/// <reference path="jquery.d.ts" />
/// <reference path="ImageSelector.ts" />
/// <reference path="ImageView.ts" />

$(document).ready( ()=> {
    console.log( "ready" );
    var imageSel1 = new ImageSelector([
        "http://a.yfrog.com/img618/4329/5j7qn.jpg",
        "http://a.yfrog.com/img858/8198/v63yc.jpg",
        "http://a.yfrog.com/img876/7058/tgsng.jpg",
        "http://a.yfrog.com/img814/8513/uvtjo.jpg",
        "http://a.yfrog.com/img739/3928/sgrfl.jpg",
        "http://a.yfrog.com/img876/2948/buqtv.jpg"
    ]);
    
    var imageView1 = new ImageView( "#imageView1" );
    
    imageSel1.onChangeSelectedIndex( ()=>{
        imageView1.setImage( imageSel1.getImage() );
        console.log( "setImage( " + imageSel1.getImage() + " )" );
    });
    
    imageSel1.select( 0 );
    setInterval( ()=>{ imageSel1.select( (imageSel1.getSelectedIndex() + 1) % imageSel1.getCount() ); }, 5000 );
    
});

これを実行するための以下のようなHTMLを作り、ブラウザで表示します。

imageselector.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title></title>
    <style type="text/css">
        #imageView1 img {
            width : 640px;
            height : 480px;
        }
    </style>
    <script type="text/javascript" src="http://ajax.aspnetcdn.com/ajax/jQuery/jquery-1.7.2.min.js"></script>
    <script type="text/javascript" src="ImageSelector.js"></script>
    <script type="text/javascript" src="ImageView.js"></script>
    <script type="text/javascript" src="main.js"></script>
</head>
<body>
    <div id="imageView1"><img /></div>
</body>
</html>

コマンドラインからなら、「start imageselector.html」でもOKです。ちなみにjQuery本体は、MicrosoftのCDNから直接src指定で取ってきてます。(追記:なんかMSのCDNが激重という指摘があったので、jQueryのロードに失敗する方はgoogleさんのCDNをご利用ください。サンプルページの方はgoogleに切り替えました。MSさん…)

何か画像が表示されたと思います(私の好きなボードゲーム達の写真です)。その後、5秒後ぐらいに、setIntervalにより別の画像に切り替わったと思います(画像の読み込み時間があるのでもう少しかかるかも)。
ポイントは、imageSel1.onChangeSelectedIndexの呼び出し部分です。クロージャを指定し、imageSel1の変更通知を受け取ってそれをimageView1に設定しています。

imageSel1.select( 0 ) の結果、imageView1.setImage が呼び出されるまでの一連の流れがイメージできているでしょうか?
一度イベントで関連付けてしまえば、あとはModel側を変更するだけで自動的にView側が更新されます。こういうのをObserverパターンと言います。

imageSel1とimageView1はそれぞれを知らず、互いに依存していませんが、それをこのmain関数が結び付けています。いわば、このmain関数がModelとViewをつなぐコントローラーの役目をしていると言っても良いでしょう。

この程度の規模だと、コントローラークラスを作って、入力を抽象化してコントローラーが受け取り…などと大げさなことをせずとも、これぐらいの書き方で十分かと思います。クロージャがある言語ならではです。

●フェードアウト・インで見栄え良く

ImageViewの画面切り替えがちょっと味気ないので、フェードアウト/インをさせてみましょう。
jQueryを使っているので、この処理は簡単です。

ImageView.ts
setImage( image : string ) {
    var img = this.dom.children( "img" );
    img.stop( true, true ).css( "opacity", 100 ).fadeOut( "fast", ()=>{ img.attr( "src", image ).fadeIn("fast"); } )
}

ImageViewのsetImageの中身を上記のように変えます。imgをfadeOut()させ、その終了時に第二引数のクロージャが実行されるので、そこでsrcを変更し、すぐにfadeIn()します。最初にstop()を呼び出してopacityを100に初期化しているのは、フェードアウト/インのアニメーション中にsetImage()呼び出しが割り込まれた時の為のアニメーションキャンセル処理です。

ところで、画像を扱う処理の場合、画像の事前ロード処理が必要になることが多いので、ここでpreload_images関数を自作しておきましょう。これが無いと、画像の初回表示でネットワークから読み込みが発生してその都度待ちが発生してしまいます。

Imageクラスを作ってjQueryオブジェクト化しておき、それぞれのloadイベントを待ってカウントします。全部ロードされたら、callback関数を呼び出します。内容についてはちょっとややこしい上にjQueryのmapeachを組み合わせているので、慣れていないとわかり難いかと思いますので説明を割愛します。この関数はmain.tsの中に入れておきましょう。

main.ts
function preload_images( images : string[], callback? : ()=>any = ()=>{} ){
    var counter : number = 0;
    $($.map(images, ()=>
        $(new Image()).bind("load", ()=>{ if ( ++counter >= images.length ) callback() } )
    )).each( (index, elem )=>{ $(elem).attr( "src", images[index] ); } );
}

■サムネイル一覧から画像を選択させる「ImageSelectorView」を作る

●基本はImageViewと同様

さて、最後の山場です。ImageSelectorの画像リストをサムネイルで表示し、サムネイルを選択されたらそのindexをImageSelectorのselectメソッドに伝える、ImageSelectorViewを作りましょう。

HTML本体は次のようになるでしょう。ImageSelectorViewの本体であるdiv要素と、その中のサムネイル画像の一覧である複数のimg要素です。

<div id="imageSelectorView1">
    <img />
    <img />
    <img />
    <img />
    <img />
    <img />
</div>

HTML側のCSSで、以下のような指定をして縮小表示するようにしておくのを忘れずに。

#imageSelectorView1 img {
    width : 64px;
    height : 48px;
}

尚、内部に保持するimg要素の数はImageSelectorによって実行時に決まりますので、このHTMLにあるimg要素はただのダミーです。CSSによるデザインをやりやすくするためのものですので、別になくてもかまいません。実行時にはDOM操作により一旦img要素を全部削除した上で、新しくimg要素を必要な数分、追加することにしましょう。

ImageSelectorView.tsを作ってもよいのですが、管理が面倒なのでImageView.tsに一緒にImageSelectorViewクラスも定義してしまうことにします。ImageViewの下に、ImageViewの最初のやり方と同じように、コンストラクタでidを受け取ってdomを内部で保持するものを作ります。また、ImageSlectorのインスタンスを受け取ってimsというメンバ変数として内部で保持するようにしましょう。内部のimg要素にはいろいろと操作をしたいので、これもjqImagesとして内部で保持するようにします。

ImageView.tsに追記
class ImageSelectorView {
    private dom : JQuery;
    private ims : ImageSelector;
    private jqImages : JQuery;
    
    constructor( id : string ) {
        this.dom = $( id );
    }
    
    setImageSelector( ims : ImageSelector ){
        this.ims = ims;
        
        this.dom.empty();
        ims.each( (index,image)=>{
            this.dom.append( $("<img />").attr( "src", image ).attr( "number", index ).bind( "click", function(){ ims.select( parseInt($(this).attr( "number" )) ); } ) );
        });
        
        this.jqImages = this.dom.children("img");
        
        ims.onChangeSelectedIndex( ()=>{
            this.jqImages.toggleClass( "selected", false );
            this.jqImages.eq( ims.getSelectedIndex() ).toggleClass( "selected", true );
        });
    }
    
}

●Iteratorパターンを使って内部リストを公開

setImageSelectorの中身では、以下のようなことをしています。

まず、受け取ったImageSelectorをメンバ変数imsに保持し、その後、dom(div要素)の中身をempty()で空にしています。

次に、ImageSelectorが保持している画像リストを取得して、その数だけimg要素を作ってsrcを指定してdomに追加…ということをやりたいのですが、ひとつ困ったことがあります。それは、現状のImageSelectorは、内部の画像リストを外部に公開していない、ということです。このままではImageSelectorView側から、ImageSelectorの画像リストを取得できないのです。

方法としては、ImageSelectorに、this.imageListを取得するgetImageList():string[] を追加する、という方法もあるのですが、外部からimageListの中身をいじられるリスクがあります。あまり内部を外にさらけ出したくはないものです。そこで、今回はIteratorパターンというデザインパターンを採用し、ImageSelectorにeachメソッドを追加します。

ImageSelectorクラス
    each( callback : ( number, string ) => any ) :void {
        $.each( this.imageList, (index,image)=>callback( index, image ) );
    }

つまり、引数にcallback関数を渡すと、内部の画像リストから画像を1つ1つ、callback関数に渡して複数回呼び出してくれる、というものです。callback関数の引数は、indexとstringを受け取れるようにしています。

これにより、内部の画像リストを非公開にしたまま、外部から画像リストの各要素を取得することができるようになります。

具体的にはImageSelectorViewの、ims.each の部分でそれを使用しています。img要素を作ってsrc属性にimageを指定し、クリック時イベントハンドラを定義した上でdomに追加しています。また、作成したimg要素には、管理用の属性として「number」を追加し、そこにindex番号を割り当てています。クリック時イベントハンドラ内では、このnumberを使って「何番目の画像がクリックされたか」を判定し、その番号をims.select()に与えています。これで、画像をクリックしたらその画像が選択状態になります。

ちなみに、このクリック時イベントハンドラはアロー関数式ではなく通常のfunctionになっています。これは、この関数内で使われている「this」を、実行時に決まるようにするためです。アロー関数式を使うとこのthisはImageSelectorViewのインスタンスをさす事になってしまいますが、従来のfunctionを使えばthisは実行時、つまりクリックされた画像オブジェクト自身を指すことになります。jQueryを使う際にはこの辺の違いが結構重要なので覚えておくとよいと思います。

次に、domに追加されたimg要素をまとめてchildren( "img" )で取得し、それをthis.jqImagesに保持しています。

最後に、imsonChangeSelectedIndexにイベントハンドラを設定しています。選択画像が切り替わった時に、ここでイベントを受けて、サムネイル画像要素に「selected」というCSSクラスを追加するようにします。selectedの場合にどのように表示するかはHTML側のCSSの役目ですが、基本的には周囲に色付きのborderをつけるなどの装飾が行われるでしょう。

selected CSSクラスの装飾例
#imageSelectorView1 img.selected {
    border : 2px solid #6E96AE;
}

●「入力」と「出力」を異なるタイミングで行う

この一連の流れで重要なポイントは、「画像をクリックした時には、ImageSelectorView自身には何も変更を加えない」という所です。画像をクリックしたらそれが選択状態になるのですから、ここで"selected"のCSSスタイルを適用してしまっても問題ないように思えます。しかしここでは敢えて、クリック時にはImageSelectorのselectを呼び出して「画像が選択されたよ」と通知するだけに留めています。その後、ImageSelectorからonChangeSelectedIndexイベントが通知されて始めて、「選択画像が切り替わったので、サムネイルの選択状態にもそれを反映する」という処理をしています。

このようにすることで、ModelとViewの状態の同期が非常に取りやすくなるのです。例えば、ImageSelectorのselectメソッドが、このView以外、例えばsetIntervalなどから呼び出されて変更されてしまったらどうでしょうか? もし、画像クリックでサムネイルの選択状態を切り替えていたら、そういう外部での変更には反応することができません。しかし、Modelから「変更された」ことを受けてView自身の状態を変えるようにしておけば、外部での変更がされてもView側がそれに自動的に追従できます

また、何らかの理由でImageSelectorのselectを呼び出したにも関わらず、画像選択状態が変更できないことがあるかもしれません(今回のクラスではそんな事は起きませんが…)。その場合、ImageSelectorのselectedIndexと、ImageSelectorViewの状態が、その時点からズレ始めてしまいます。

このように、入力と出力の責任の切り分けを行っておくことも、強固なシステムの構築に役立ちます。尚、必ずしもこうすることが正解、というわけではなく、例えばImageSelectorの本体がサーバ上にあって、変更イベントの通知受け取りに時間がかかるとかそういう場合には、selectメソッドを呼び出した時点で見かけ上の反応速度を向上させる為にとりあえずView側も選択状態を変えておく、という事は十分ありますので、1つの方法論に固執しないようにしてください

さて、作成したImageSelectorViewを使うのは非常に簡単です。

 var imageSelectorView1 = new ImageSelectorView( "#imageSelectorView1" );
 imageSelectorView1.setImageSelector( imageSel1 );   

これだけです。再利用性の高いクラス部品は、使用するのも簡単です。

これで画像ビュアーは完成となります。

■まとめ

確認しておいて欲しいポイントは次の通りです。

  1. ModelであるImageSelectorクラスは、それぞれのViewと密接にやりとりをしているにも関わらず、Viewに一切依存していない(Viewがなくても使用できる)。Viewとはイベントによって通信を行っている。異なるViewを作ったとしても、Modelには一切手を加えずに再利用できる。
  2. ImageViewクラスは、ImageSelector(Model)には依存していない。この為、ImageSelectorがなくても単体で任意の画像を表示する為に使用できる。反面、ImageSelectorなどと連携させるには自前でその為のコードを書く必要がある。
  3. ImageSelectorViewクラスは、ImageSelectorに依存している。ImageSelectorViewを使う為にはImageSelectorを必ず使わなくてはならない。反面、ImageSelectorをセットした後は、完全にImageSelectorViewにお任せできる。

ModelとViewを設計する際には、まずModelを完全に独立させる(外部に依存させない)ようにしてください。また、不必要に内部状態を外部へ公開しないようにしてください。ViewがModelに依存するのは構いませんが、今回のImageViewクラスのように、依存させる必要がない場合にはViewも独立させた方が再利用性が高まります。但し、あまりViewとModel間の依存をなくしてしまうと、ImageSelectorViewクラスのような使いやすさは失われ、使用のために毎回面倒な連携コードを書く手間が発生します。この辺は設計者のバランス感覚が試されます。

今回はTypeScriptを使って、サンプル以上の複雑さを持つ何かを作ってみました。それによっていろいろな事に気がつきました。

まず、なんといっても記述性の高さ!特にクラスを記述する際の気軽さというか気持ちよさというか、スッキリ書けて最高です。もはや、JavaScriptで生でクラスとか作りたくありません。オブジェクト指向による設計を、すっきりとソースコードへ落とし込めます。private修飾子があるおかげで、クラスを強固にカプセル化したまま開発を進められるのも、なんとなく気分が落ち着きます(まぁ、JavaScriptに変換した時点で丸出しになるんですが…)。

また、コンパイルエラーに何度も助けられました。文法エラーを始め、型の違いの指摘などなど。このあたりもJavaScriptには無い部分です。実行前にエラー箇所がわかるって最高です。

逆にTypeScriptを書いていて思ったのは、「イベント機構とリストの扱いをjQueryに頼るのはいいが、もう少し頼りやすい形にして欲しい」ということでした。「EventDispatcher」みたいなクラスがあって、それを継承すれば自動的にjQueryのbindなどが使えるようになる、みたいなのがあるといいですね。また、.eachや.mapなどのリスト操作メソッドが、型情報を持たせたままarrayから使えるような仕組みがあると助かるなーと思います。別に自前でArray.prototype.each とか定義しちゃえばいいんですが、その辺も標準的にやってくれないかなーと…。あんまりやりすぎると外部ライブラリ必須になっちゃうんで、アレですけども。オプションでもいいんで。

一部、イベント機構を作っていて、なんかよくわからないがあった部分がありました。本当はonChangeSelectedIndexは、次のようなものにする予定でした。

onChangeSelectedIndex( handler : ( event? : { target? : ImageSelector; index? : number; } ) => any ) : void{
    this.jq.bind( ImageSelector.CHANGE_SELECTED_INDEX, handler );
}

handlerの関数の型がポイントです。つまり、イベントに「イベントを送った自身」や、変更後のindex番号などをデータとして渡したいと思ったのですが、とはいえ、それらのデータは要らないことも多いので、()=>{}などでも受けれるようにしたい、と思い、全部のパラメータに「省略可能」を表す「?」をつけてみたのです。このコード自身はコンパイルが通ったのですが、onChangeSelectedIndexにはどんな型の関数オブジェクトも渡せませんでした(関数の型が違う、というコンパイルエラーになる)。

ためしに?を全部外して、

onChangeSelectedIndex( handler : ( event : { target : ImageSelector; index : number; } ) => any ) : void{
    this.jq.bind( ImageSelector.CHANGE_SELECTED_INDEX, handler );
}

としてみたら、なぜか全てがうまくいきました。handlerの型はeventを受け取る、とハッキリここで定義しているにも関わらず、()=>{}をhandlerとして渡してもコンパイルが通りました。まぁ便利ではあるものの、わけがわかりませんでした。

私の理解が足りないのか、TypeScriptの挙動がおかしいのか、よくわかりませが、わけがわからなかったので、今回のサンプルからは使っていません。

この辺も含めて、イベント機構が標準的に整備されてくると、いろいろ作りやすくなるかなーと思いました。

まぁそんなこともありましたが、全体的に見て、イイですよTypeScriptjQueryと一緒に使うことが前提っぽいので、もっとその辺の結合度を上げちゃってくれてもいいくらいです。(前提、は言い過ぎか…。現状だと、言語が提供するライブラリというものが無いので、その辺を担うのは今のところjQueryなのかな、ということですね)。今後のさらなる発展に期待!

■ソースコード

最後に、今回の記事の完成版のソースを以下に貼っておきます。どうもGoogle Sites(このブログを置いてるサービス)では、JavaScriptが使えないのでソースコード整形して見せれなくてごめんなさいね。

ImageSelector.ts
/// <reference path="jquery.d.ts" />
class ImageSelector {
    // list of images
    private imageList : string[] = [];
    
    // index number of the list of images
    private selectedIndex : number = -1; // not selected
    
    // jquery object for this
    private jq : JQuery;
    
    public static CHANGE_SELECTED_INDEX : string = "changeSelectedIndex";
    
    // imageList : array of 
    constructor( imageList : string[] ) {
        this.jq = $( this );
        this.imageList = imageList;
    }
    
    select( index : number ) : void {
        if ( index < 0 || index >= this.imageList.length ){
            throw new Error( "out of index" );
        }
        this.selectedIndex = index;
        this.jq.trigger( ImageSelector.CHANGE_SELECTED_INDEX );
    }
    
    getSelectedIndex() : number { 
        return this.selectedIndex; 
    }
    
    getImage() : string {
        return this.imageList[ this.selectedIndex ];
    }
    
    getCount() : number {
        return this.imageList.length;
    }
    
    onChangeSelectedIndex( handler : () => any ) : void {
        this.jq.bind( ImageSelector.CHANGE_SELECTED_INDEX, handler );
    }
    
    each( callback : ( number, string ) => any ) :void {
        $.each( this.imageList, (index,image)=>callback( index, image ) );
    }
}

ImageView.ts
/// <reference path="jquery.d.ts" />
/// <reference path="ImageSelector.ts" />

class ImageView {
    private dom : JQuery;
    
    constructor( id : string ) {
        this.dom = $( id );
    }
    
    setImage( image : string ) {
        var img = this.dom.children( "img" );
        img.stop( true, true ).css( "opacity", 100 ).fadeOut( "fast", ()=>{ img.attr( "src", image ).fadeIn("fast"); } )
    }
}

class ImageSelectorView {
    private dom : JQuery;
    private ims : ImageSelector;
    private jqImages : JQuery;
    
    constructor( id : string ) {
        this.dom = $( id );
    }
    
    setImageSelector( ims : ImageSelector ){
        this.ims = ims;
        
        this.dom.empty();
        ims.each( (index,image)=>{
            this.dom.append( $("<img />").attr( "src", image ).attr( "number", index ).bind( "click", function(){ ims.select( parseInt($(this).attr( "number" )) ); } ) );
        });
        
        this.jqImages = this.dom.children("img");
        
        ims.onChangeSelectedIndex( ()=>{
            this.jqImages.toggleClass( "selected", false );
            this.jqImages.eq( ims.getSelectedIndex() ).toggleClass( "selected", true );
        });
    }
    
}

main.ts
/// <reference path="jquery.d.ts" />
/// <reference path="ImageSelector.ts" />
/// <reference path="ImageView.ts" />
function preload_images( images : string[], callback? : ()=>any = ()=>{} ){
    var counter : number = 0;
    $($.map(images, ()=>
        $(new Image()).bind("load", ()=>{ if ( ++counter >= images.length ) callback() } )
    )).each( (index, elem )=>{ $(elem).attr( "src", images[index] ); } );
    
}

$(document).ready( ()=> {
    console.log( "ready" );
    
    var images = [
        "http://a.yfrog.com/img618/4329/5j7qn.jpg",
        "http://a.yfrog.com/img858/8198/v63yc.jpg",
        "http://a.yfrog.com/img876/7058/tgsng.jpg",
        "http://a.yfrog.com/img814/8513/uvtjo.jpg",
        "http://a.yfrog.com/img739/3928/sgrfl.jpg",
        "http://a.yfrog.com/img876/2948/buqtv.jpg"
    ];
    
    preload_images( images, ()=>{
        console.log( "preloaded all images" );
    
        var imageSel1 = new ImageSelector( images );
        
        var imageView1 = new ImageView( "#imageView1" );
        
        imageSel1.onChangeSelectedIndex( ()=>{
            imageView1.setImage( imageSel1.getImage() );
            console.log( "setImage( " + imageSel1.getImage() + " )" );
        });
        
        var imageSelectorView1 = new ImageSelectorView( "#imageSelectorView1" );
        imageSelectorView1.setImageSelector( imageSel1 );   
        
        imageSel1.select( 0 );
        
        setInterval( ()=>{ imageSel1.select( (imageSel1.getSelectedIndex() + 1) % imageSel1.getCount() ); }, 5000 );
    });
});

imageselector.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title></title>
    <style type="text/css">
    img {
    padding : 0;
    margin : 0;
    vertical-align:text-bottom;
    }
    
    #imageView1 {
    border : 2px solid #6E96AE;
    width : 640px;
    height : 480px;
    }
    #imageView1 img {
    width : 640px;
    height : 480px;
    }
   
    #imageSelectorView1 img {
    width : 64px;
    height : 48px;
    margin-right : 2px;
    margin-bottom : 4px;
    border : 2px solid #f0f0f0;
    }
   
    #imageSelectorView1 img.selected {
    border : 2px solid #6E96AE;
    }
   
    </style>
    <script type="text/javascript" src="http://ajax.aspnetcdn.com/ajax/jQuery/jquery-1.7.2.min.js"></script>
    <script type="text/javascript" src="ImageSelector.js"></script>
    <script type="text/javascript" src="ImageView.js"></script>
    <script type="text/javascript" src="main.js"></script>
</head>
<body>
<div id="imageSelectorView1"></div>
<div id="imageView1"><img /></div>
</body>
</html>

今回の記事のサンプルソースのダウンロードはこちらです。ご自由にお使いください。
Comments