Knockout.jsを使ってTumblrの情報をバインドする(1)

そんなわけでサイト内スクリプトKnockout.jsの完全適用が終わりました。

まだ微妙にCSSを書ききれていないところがあるんですが、そのうち直します。多分ね。

MVVMパターンって初めてやってみたんですが、どう考えてもViewModelでやるべきことなんだけどModelでやっちゃった方が楽だったりとか、逆もまたしかりだとか、あんまり考えすぎると泥沼にはまりそうですね。

まぁ、「No Silver Bullet」と言う銀の弾丸があるように、すべてがこれ一つで解決するなんてことはないでしょう。理想を追求し続けるか、どこかで妥協するか、その辺は個人の匙加減な気がします。

今回は折角なのでKnockout.jsのノウハウをメモしつつサイト内スクリプトの仕様を説明します。

Tumblrと通信するFunctionを作成する

しばらくKnockout.jsは全く関係ない話をします。とりあえずTumblrと通信する機能を実装しましょう。

function TumblrData() {
    /// <summary>Tumblrに登録されているデータ</summary>

    /// <field name="id" type="Number">記事のID</field>
    this.id = 0;
    /// <field name="date" type="String">投稿日</field>
    this.date = null;
    /// <field name="title" type="String">記事タイトル</field>
    this.title = null;
    /// <field name="url" type="String">記事のURL</field>
    this.url = null;
    /// <field name="type" type="String">記事のタイプ(リンク、テキストetc...)</field>
    this.type = null;
    /// <field name="tag" type="Array">記事のタグ</field>
    this.tag = new Array();
};
    
function tumblrApiCall(baseUri, onSuccess, onError, onEmpty) {
    /// <summary>Tumblrと通信</summary>
    /// <param name="baseUri" type="String">URI (GET)</param>
    /// <param name="onSuccess" type="Function">成功時処理</param>
    /// <param name="onError" type="Function">エラー時処理</param>
    /// <param name="onEmpty" type="Function">値なし時処理</param>
    /// <returns type="Function" />
    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.map(function (x) {
                    var td = new TumblrData();
                    td.id = x.id;
                    td.date = x.date;
                    td.title = x.title;
                    td.url = x.post_url;
                    td.type = x.type;
                    td.tag = x.tags;
                    return td;
                }));
            },
            error: function (req, status, error) {
                return;
            }
        });
    };
};

jQuery.ajaxのsuccessでやってる内容に関しては以前の記事とコメントに全部書いてあります。

また、後々データをキャッシュすることを想定して、Tumblrから取得したデータはすべてTumblrDataと言うデータクラスに変換してしまいます。

キャッシュに保存するのも、キャッシュから呼び出すのも、Tumblrと通信して取得するのも、全部このTumblrDataと言うインターフェースのArrayにしてしまえば色々なところで使い回しが効くからです。

キャッシュ用のクラスを作成する

キャッシュにはWeb Storageを使います。

(function (root) {

    var KEY_LAST_ID = "lastId";
    var KEY_ALL_DATA = "allData";

    var _canUseStorage = localStorage !== "undifined";
    var _storage = _canUseStorage ? localStorage : null;
    var _isLoading = false;

    function load(key) {
        /// <summary>キャッシュの読み込み</summary>
        /// <param name="key" type="String">Storageから読み込むデータのKey</param>
        /// <returns type="Object" />

        if (!_canUseStorage
            || _storage == null
            || !(key in _storage)) return null;

        return JSON.parse(_storage[key]);
    };

    function save(map) {
        /// <summary>キャッシュの保存</summary>
        /// <param name="map" type="Object">保存するデータのKey/Value</param>

        if (!_canUseStorage) return;

        for (key in map) {
            _storage[key] = JSON.stringify(map[key]);
        }
    };

    function Cache() {
        /// <summary>Tumblrのデータキャッシュへアクセスするクラス</summary>

        /// <field name="canUseStorage" type="Boolean">Local Storageが使用可能かどうかを判定</field>
        this.canUseStorage = _canUseStorage;

        this.setIsLoading = function (v) {
            /// <summary>読み込み中設定</summary>
            /// <param name="v" type="Boolean"></param>
            _isLoading = v;
        }

        this.isLoading = function () {
            /// <summary>読み込み中</summary>
            /// <returns type="Boolean" />
            return _isLoading;
        };

        this.getLastId = function () {
            /// <summary>最後に取得した記事のID</summary>
            /// <returns type="Number" />
            return load(KEY_LAST_ID);
        };

        this.setLastId = function (value) {
            /// <summary>最後に取得した記事のID</summary>
            /// <param name="value" type="Number">value</param>
            save({ lastId: value });
        };

        this.setAllData = function (allData) {
            /// <summary>全件データセット</summary>
            /// <param name="allData" type="Array">Tumblrから取得した全件データ</param>
            save({ allData: allData });
        };

        this.getAllData = function () {
            /// <summary>全件データ取得</summary>
            /// <returns type="Array" />
            return load(KEY_ALL_DATA);
        };
    }

    root.Cache = Cache;

})(this);

これ、「var _cache = new Cache();」みたいなことをクロージャの中でしておいて、root.Cacheには_cacheを返すfunctionを設定したほうが良さそうな気がしてきました。(擬似シングルトン)

まぁそのうち直します。

また、こんな関数も用意しておきます。

function isNotUpdateLastId(cache) {
    /// <summary>現在表示されているページの一番大きいIDとキャッシュのLastIdを比較し、取得したIDがキャッシュより古ければTrue</summary>
    /// <param name="cache" type="Cache">キャッシュ</param>
    /// <returns type="Boolean" />

    // Storageが使えないブラウザの場合かキャッシュにlastIdがなければfalse
    var lastId = cache.getLastId();

    if (lastId == null) return false;

    // パーマネントリンクからIDを取得する
    var m = jQuery("footer > .small > li > a").attr("href").match(/post\/(\d+)/);

    // 取得できなかった場合、もしくはIDがキャッシュより新しければfalse
    if (!m || m[1] > lastId) {
        return false;
    }

    return true;
}

何をやってるかは、まぁ書いてある通りです。

既にキャッシュ済みの一番新しい記事のIDと、今表示されている一番新しい記事のIDを比較して、キャッシュのデータを使うかTumblrと通信するかを判定するための関数です。

HTMLとViewModelを用意する

やっとこさKnockout.jsの話です。

最新記事一覧やタグ一覧がおいてあったサイドバーのHTMLはこんな感じでした。

<div id="sidebar">
    <div id="recent">
        <h3>Recent</h3>
        <span class="loading">loading recent...</span>
    </div>
    <div id="tags">
        <h3>Tag List</h3>
        <span class="loading">loading tag list...</span>
    </div>
</div>

jQueryで[recent]や[tags]を引っ掛け、もりもりとDOMを生成していました。

Knockout.js導入後はこんな感じになっています。

<div id="sidebar">
    <div id="recent">
        <h3>Recent</h3>
        <span class="loading" data-bind="if: isLoading()">loading recent...</span>
        <ul id="sidebar-recent" data-bind="foreach: datas, visible: !isLoading()" style="display: none;">
            <li class="recent-day">
                <a data-bind="attr: { href: $parent.dateLink(date) }, text: date"></a>
                <ul class="recent-posts" data-bind="foreach: posts">
                    <li class="recent-post"><a data-bind="attr: { href: url }, text: title"></a></li>
                </ul>
            </li>
        </ul>
    </div>
    <div id="tags">
        <h3>Tag List</h3>
        <span class="loading" data-bind="if: isLoading()">loading tag list...</span>
        <ul id="tag-list" data-bind="foreach: datas, visible: !isLoading()" style="display: none;">
            <li class="tag-link">
                <a data-bind="attr: { href: getAllTagLink() }, text: tagWithCount, click: expandLink"></a>
                <ul class="tag-post" data-bind="foreach: posts, visible: isExpanded()">
                    <li><a data-bind="attr: { href: url }, text: title"></a></li>
                </ul>
            </li>
        </ul>
    </div>
</div>

また、関連記事のViewはこんな風になっています。これはblock:Textblock:HasTags内(つまり、テキスト記事かつタグがあった場合)でのみレンダリングされるようになっています。

<h3>関連記事</h3>
<div class="relational-container">
    <span class="loading" data-bind="if: isLoading()">now relational post loading...</span>
    <div class="relational" data-bind="foreach: relational, visible: !isLoading()" style="display: none;">
        <p><a data-bind="attr: { href: tagLink }, text: '#' + tag"></a>の関連記事:</p>
        <ul class="relational-posts" data-bind="foreach: randomLink">
            <li class="relational-link">
                <a data-bind="attr: { href: url }, text: title"></a>
            </li>
        </ul>
    </div>
</div>

Knockout.jsを使うことで

  • レイアウトの構造がHTMLレベルでわかる
  • idやclassなどを事前に設定できるのでCSSが書きやすい

などのメリットがあります。あと、JS側のコードはめっちゃ減ります。特に何らかのJSON(Array)をDOMに変換するようなコードは一切なくなりました。

まぁその代わりViewModelの記述が増えるからどっこいどっこいなんだけどね!と言うことでViewModelのコード。

(function (root) {

    var recentViewModel = new RecentViewModel();

    function RecentViewModel() {
        /// <summary>最新記事一覧のViewModel</summary>

        /// <field name="isLoading" type="Boolean">読み込み中フラグ</field>
        this.isLoading = ko.observable(true);
        /// <field name="datas" type="observableArray">日付に紐付く記事のリンク</field>
        this.datas = ko.observableArray();
        /// <field name="dateLink" type="Function">日付のリンク</field>
        this.dateLink = function (date) { return "http://outofmem.tumblr.com/day/" + date; };

        this.setData = function (post) {
            
            // 日付変換
            var dateArray = post.date.split(" ");
            var match = dateArray[0].match(/(\d+)-(\d+)-(\d+)/);
            var day = [match[1], match[2], match[3]];

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

            var date = day.join("/");

            if (!this.datas().some(function (x) { return date == x.date; })) {
                // キーがなければ新たに作成
                this.datas.push({ date: date, posts: ko.observableArray([post]) });
            } else {
                // キーがある場合はposts配列にデータを追加
                this.datas().filter(function (x) { return date == x.date; })
                                        .forEach(function (x) { x.posts.push(post); });
            }
        };
    };

    ko.applyBindings(recentViewModel, document.getElementById("recent"));

    var tagListViewModel = new TagListViewModel();

    function TagListViewModel() {
        /// <summary>タグ一覧のViewModel</summary>

        var self = this;

        /// <field name="isLoading" type="Boolean">読み込み中フラグ</field>
        this.isLoading = ko.observable(true);

        /// <field name="datas" type="observableArray">そのタグがついた記事の一覧</field>
        this.datas = ko.observableArray();

        this.setData = function (data, posts) {
            /// <summary>タグに紐付く情報のセット</summary>
            /// <param name="data" type="Object">Description</param>
            /// <param name="posts" type="Array">Description</param>

            self.datas.push({
                tag: data.tagName,
                count: data.count,
                tagWithCount: data.tagName + " (" + data.count + ")",
                posts: posts,
                isExpandable: posts != null && posts.length > 0,
                isExpanded: ko.observable(false),
                getAllTagLink: function () {
                    /// <summary>タグそのものへのリンク取得</summary>
                    /// <returns type="String" />

                    return !this.isExpandable ? "http://outofmem.tumblr.com/tagged/" + this.tag : "/#/";
                },
                expandLink: function () {
                    /// <summary>タグリンクのクリック時挙動</summary>
                    if (this.isExpandable) {

                        if (this.isExpanded()) {
                            this.isExpanded(false);
                        } else {
                            this.isExpanded(true);
                        }

                        return false;
                    } else {
                        return true;
                    }
                }
            });
        };
    }

    ko.applyBindings(tagListViewModel, document.getElementById("tags"));

    function RelationalViewModel() {
        /// <field name="isLoading" type="Boolean">読み込み中フラグ</field>
        this.isLoading = ko.observable(true);

        /// <field name="relational" type="observableArray">関連記事データ</field>
        this.relational = ko.observableArray();

        this.setData = function (tag, randomLink) {
            /// <summary>Description</summary>
            /// <param name="tag" type="String">タグ</param>
            /// <param name="randomLink" type="Array">タグから取得したランダムなデータ</param>

            this.relational.push({
                tag: tag,
                tagLink: "http://outofmem.tumblr.com/tagged/" + tag,
                // Tagと紐付くPostをキャッシュに登録されているデータから取得する
                randomLink: randomLink
            });
        };
    }

    jQuery(".text").each(function () {
        var $text = jQuery(this);
        var $div = $text.find(".relational-container");

        // Text記事のタグを取得する
        var $tags = $text.find("footer > .tags");
        if ($tags.length == 0) return;

        ko.applyBindings(new RelationalViewModel(), $div[0]);
    });

    root.getRecentViewModel = function () {
        /// <summary>最新記事一覧ViewModelの取得</summary>
        /// <returns type="RecentViewModel" />
        return recentViewModel;
    };

    root.getTagListViewModel = function () {
        /// <summary>タグ一覧ViewModelの取得</summary>
        /// <returns type="TagListViewModel" />
        return tagListViewModel;
    };

    root.getRelationalViewModel = function (node) {
        /// <summary>関連記事ViewModelの取得</summary>
        /// <returns type="RelationalViewModel" />
        return ko.dataFor(node);
    };

})(this);

ko.dataFor(element)の存在に気づくのが遅かったせいでわざわざViewModelのGetterまで作ってしまいましたが、VSDocで返り値の型が書けて便利なので残してあります。

まとめ

ごめんなさい…流石にコードを貼り付けすぎて文字数が全然足りないです。

個別のModelの処理とModel-View-ViewModelの紐付けは別の記事にしてあげます。なるべく早めに書けるよう善処します。