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

【JavaScript】JavaScriptでのクラスの作り方を考える

すっかりJavaScript漬けの毎日です。さて、Tumblrの最新記事一覧取得をサンプルに色々とJavaScriptのお勉強をしているのですが、「今後何か他にやりたいことが増えるかもしれないから、一つのクラスにまとめておきたいね」と思ったので、JavaScriptでクラスを作ってみます。

そもそもJavaScriptにクラスはあるのか

思ったはいいものの、色々ぐぐってもピンとくる説明がほとんどありませんでした。

prototype使ってないから遅いしダメでーすとか、privateなスコープが作れましぇーんとか、最初は何だこのクソ言語と思ったんですが、そう言う人たちは多分、JavaとかC#とかC++みたいな「クラス」を作ろうとするからそうなるのかな、と思うようになりました。

そもそもJavaScriptには自由度が高すぎてキモいハッシュ(個人の感想です)と、第一級関数オブジェクトしか存在しません。JavaScriptで作るクラスは「Key:メソッド名/メンバ名 Value:関数/値」のハッシュでしかないんだと思います。

だから、単にハッシュを作ってあげればいいんですよ。クラス名をKeyにメソッド名と関数のハッシュを手に入れるイメージを持てれば、そんなに難しいものじゃないです。

グローバルな空間にクラスのプロパティを作成する

ちょっと意味のわからない見出しになったのでコードを書きます。

(function (root) {

    var domain = "outofmem.tumblr.com";
    var apiKey = "";

    function TumblingDice() { };
    
    root.TumblingDice = TumblingDice;
    
})(this);

thisを引数に受ける即時関数を作り、JavaScript読み込み時に実行します。受け取ったthisだって当然オブジェクト(ハッシュ)ですので、新しいKey/Valueを登録しても何の問題もありません。そこで新規にTumblingDiceと言うプロパティを作り、値を同名のクラス(関数)にします。

これでグローバルな空間、つまり、HTML内のscriptタグで「var tumblingDice = new TumblingDice()」と記述出来るようになりました。

当然こんなコードでも「var tumblingDice = new TumblingDice()」と記述出来ます。

var domain = "outofmem.tumblr.com";
var apiKey = "";

function TumblingDice() { };

が、これだとdomainやapiKeyもグローバルな空間から自由に参照できてしまいます。出来ればそんなことはやめてほしいです。

わざわざ引数にthisを渡すクロージャを作ってプロパティを設定する理由は、privateなスコープを実現するためです。

プロトタイプを作成する

次にTumblingDiceクラスのprototypeプロパティにメソッドを追加していきます。

そう、prototypeだって言ってしまえばただのハッシュです。実際ハッシュで値を設定できるでしょう?

/// <reference path="jquery-1.6.2.js">
(function (root) {

    var domain = "outofmem.tumblr.com";
    var apiKey = "";

    function TumblingDice() { };

    TumblingDice.prototype.hoge = function () {
        alert(domain);
    };

    root.TumblingDice = TumblingDice;
})(this);

これで「new TumblingDice().hoge()」とでもすれば、ちゃんとdomainの値が表示されます。

プロトタイプの注意点は、「privateメンバを参照することは出来るけど、privateメンバの値を変更すると全インスタンスに影響を及ぼす」と言うところです。

例えばこんなコード。

(function (root) {
    var domain = "outofmem.tumblr.com";

    function Hoge() {};
    Hoge.prototype.test1 = function () { alert(domain) };
    Hoge.prototype.test2 = function () { domain = "piyo" };

    this.Hoge = Hoge;
})(this);

var hoge = new Hoge();
var piyo = new Hoge();
hoge.test1();
piyo.test2();
hoge.test1();

これを実行すると最初のhoge#test1では「outofmem.tumblr.com」と言う値が表示されますが、piyo#test2実行後にもう一度hoge#test1を実行すると「piyo」になってしまいます。

そりゃまぁそうです。参照元が書き換わってるんですから。

こんなコードなら問題なく2回とも「outofmem.tumblr.com」と表示されます。

(function (root) {
    var domain = "outofmem.tumblr.com";

    function Hoge() {
        var domainCopy = domain;
        this.test1 = function () { alert(domainCopy); };
        this.test2 = function () { domainCopy = "piyo"; };
    };
    //Hoge.prototype.test1 = function () { alert(domain) };
    //Hoge.prototype.test2 = function () { domain = "piyo" };

    this.Hoge = Hoge;
})(this);

var hoge = new Hoge();
var piyo = new Hoge();
hoge.test1();
piyo.test2();
hoge.test1();

が、prototypeを使っていない以上極々わずかなオーバーヘッドが生じます。じゃあこれならどうでしょう。

(function (root) {
    var domain = "outofmem.tumblr.com";

    function Hoge() {
        this.domainCopy = domain;
    };
    
    Hoge.prototype.test1 = function () { alert(this.domainCopy) };
    Hoge.prototype.test2 = function () { this.domainCopy = "piyo" };

    this.Hoge = Hoge;
})(this);

確かにこれは実行できます。でもdomainCopyがpublicになっちゃうよね?ってことで、悩んでる人が多いみたいです。

しかしまぁ、基本的にprivateメンバの値を直接書き換えたくなるようなことは滅多にないです。あるとしたら何らかの理由でクラス単位でステートを持っておきたいってことになるんですが、そもそも関数型プログラミングパラダイムが使えるのにわざわざステートを持たなきゃいけない理由ってなんでしょう?関数オブジェクトを受け取ったり返したりすることで解決できないか設計を見直した方が早いんじゃないですか?(過激派)

まぁそれは冗談としても(8割ぐらい本気でそう思ってるけど)、本気でdomainを隠蔽したいのであれば性能がどうこう言わずに素直に二つ目の(function Hoge内にvar domainCopyを作る)やり方でやるのが一番無難だと思います。皆さん何を勘違いされてるのかわかりませんが、オブジェクトを作成するって言うのは本来とても高コストな処理です。甘えるな。(過激派)

また、このプロトタイプを使って継承関係を作ることが出来ます。

(function (root) {
    var domain = "outofmem.tumblr.com";

    function Hoge() {
        this.fuga = "fugafuga";
    };

    Hoge.prototype.test1 = function () { alert(domain); };
    Hoge.prototype.test2 = function () { alert(this.fuga); }

    this.Hoge = Hoge;
})(this);

function Piyo() { };
Piyo.prototype = new Hoge();
var piyo = new Piyo();

piyo.test1();
piyo.test2();

if (piyo instanceof Hoge) {
    alert("piyo is Hoge");
} else {
    alert("piyo is not Hoge");
}

if (piyo instanceof Piyo) {
    alert("piyo is Piyo");
}

このコードは順番に「outofmem.tumblr.com」「fugafuga」「piyo is Hoge」「piyo is Piyo」を表示します。

まぁ確かに継承は出来るんですが、何度も言うようにすべてはハッシュか関数かの世界なので、自力でパターンマッチをキメる必要がなければ単純にプロトタイプをコピーできるだけです。

とりあえず完成系のコード

上記の内容を全部盛り込んだわけではないですが、とりあえず現在進行形で動いているTumblrの最新記事一覧取得のソースです。

/// <reference path="jquery-1.6.2.js">
(function (root) {

    var domain = "outofmem.tumblr.com";
    var apiKey = "";

    function TumblingDice() { };

    /**
    * 最新投稿記事を取得
    */
    TumblingDice.prototype.recent = function (displayCount) {
        var limit = displayCount >= 20 ? 20 : displayCount;
        var baseUri = "http://api.tumblr.com/v2/blog/" + domain + "/posts/?api_key=" + apiKey + "&limit=" + limit;
        var $div = jQuery("#recent");
        var storage = sessionStorage;

        var c = tumblrApiCall(baseUri
            , function (json) { // onSuccess
                var postDataArray = new Array();

                json.map(function (x) {
                    return { "date": x.date, "url": x.post_url, "title": x.title };
                }).forEach(function (ref) {
                    // 記事の日付を取得
                    var date = convertDate(ref.date);

                    if (!postDataArray.some(function (x) { return date == x.date; })) {
                        // キーがなければ新たに作成
                        postDataArray.push({ "date": date, "li": [{ "url": ref.url, "title": ref.title}] });
                    } else {
                        // キーがある場合はli配列にデータを追加
                        postDataArray.filter(function (x) { return date == x.date; })
                                        .forEach(function (x) { x.li.push({ "url": ref.url, "title": ref.title }); });
                    }
                });

                // データをキャッシュに保存
                storage.lastId = json[0].id;
                storage.dataArray = JSON.stringify(postDataArray);

                // 最新記事一覧作成
                $div.find("#loading").remove();
                $div.append(createRecentList(postDataArray));
            }, function (data) { // onError
                $div.append($("<p/>").text("Error:" + data.meta.msg));
            }, function (data) { // onEmpty
                $div.append("<p>Empty</p>");
            });

        //sessionStorageが使えないブラウザの場合
        //もしくはsessionStorageにlastIdがない場合は普通に通信しに行く
        if (typeof storage === 'undefined' || !("lastId" in storage)) {
            c();
            return;
        }

        //パーマネントリンクからIDを取得する
        if (!jQuery("footer > .small > li > a").attr("href").match(/post\/(\d+)/)) {
            //取得できなかった場合は普通に通信
            c();
            return;
        }

        var id = RegExp.$1;

        //IDがキャッシュより新しければ通信
        if (id > storage.lastId) {
            c();
            return;
        }

        //sessionStorageに保存されているキャッシュを展開する
        $div.find("#loading").remove();
        $div.append(createRecentList(JSON.parse(storage.dataArray)));
    };

    /**
    * Tumblrと通信
    */
    function tumblrApiCall(baseUri, onSuccess, onError, onEmpty) {
        return function () {
            jQuery.ajax({
                type: "GET",
                url: baseUri,
                dataType: "jsonp",
                success: function (data) {
                    // ステータスコードを見て200以外であればError
                    if (data.meta.status != 200) {
                        if (onError != null) onError(data);
                        return;
                    }

                    // postsがなければEmpty
                    var json = data.response.posts;
                    if (json == null || json.length <= 0) {
                        if (onEmpty != null) onEmpty(data);
                        return;
                    }

                    onSuccess(json);
                }
            });
        };
    };

    /**
    * Tumblrの日付変換
    */
    function convertDate(date) {
        var dateArray = date.split(" ");

        var y, m, d;

        if (dateArray[0].match(/(\d+)-(\d+)-(\d+)/)) {
            y = RegExp.$1;
            m = RegExp.$2;
            d = RegExp.$3;
        }

        // UTCで15時以降の時間なら日付を1増やす
        if (dateArray[1].match(/(\d+):\d+:\d+/)) {
            var h = RegExp.$1;
            if (h >= 15) {
                d = 1 + (+d);
                if (d < 10) d = "0" + d;
            }
        }

        return [y, m, d].join("/");
    };

    /**
    * 最新記事一覧のリストを作成する
    */
    function createRecentList(dataArray) {
        var $ul = jQuery("<ul/>").attr({ id: "sidebar-recent" });
        var $a = jQuery("<a/>");
        var $inner = jQuery("<ul/>").attr({ class: "recent-posts" });
        var $recentDay = jQuery("<li/>").attr({ class: "recent-day" });
        var $recentPost = jQuery("<li/>").attr({ class: "recent-post" });

        dataArray.forEach(function (postData) {
            // 日付のリンクを作成する
            var $daysLink = $a.clone().attr({ href: "http://" + domain + "/day/" + postData.date })
                                          .text(postData.date);

            // 日付にネストするulを作る
            var $recentPosts = $inner.clone();
            postData.li.forEach(function (li) {
                var $postLink = $a.clone().attr({ href: li.url }).text(li.title);
                $recentPosts.append($recentPost.clone().append($postLink));
            });

            // 日付のliの中に$innerUlを入れる
            $ul.append($recentDay.clone().append($daysLink), $recentPosts);
        });

        return $ul;
    };

    root.TumblingDice = TumblingDice;
})(this);

まとめ

なんだか思ったより長くなってしまった。