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

前回はこちら。

Model-View-ViewModelを紐付ける(最新記事一覧)

駆け足でコードだけ載せていきましたが、ここからは肝心のModelの処理と併せてまったりと解説していきます。

でもまずはコードね。

TumblingDice.prototype.getRecent = function (displayCount) {
    /// <summary>最新投稿記事を取得</summary>
    /// <param name="displayCount" type="Number">表示件数</param>
    var cache = new Cache();

    // キャッシュと記事のIDを比較する
    if (isNotUpdateLastId(cache)) {
        // キャッシュが使用できるならば展開
        var tmp = cache.getAllData();

        if (tmp != null) {
            // 全件データから表示する件数だけを取得
            createRecentList(tmp.slice(0, displayCount - 1));
            return;
        }
    }

    // キャッシュが使用できない場合は通信を行う
    var baseUri = function (count, offset) {
        return "http://api.tumblr.com/v2/blog/" + domain + "/posts?api_key=" + apiKey + "&limit=" + count + "&offset=" + offset;
    };

    var onError = function (data) { jQuery("#recent").replaceWith(jQuery("<p/>").text("Error:" + data.meta.msg)); };
    var onEmpty = function (data) { jQuery("#recent").replaceWith("<p>Empty</p>"); };

    if (displayCount <= 20) {
        // 20件以下の場合は一回の通信でOK
        tumblrApiCall(baseUri(displayCount, 0), function (json) {

            // 一件目のIDをキャッシュに保存しておく
            if (cache.canUseStorage) {
                cache.setLastId(json[0].id);
            }

            createRecentList(json);

        }, onError, onEmpty)();
    } else {
        // 20件より多い場合は複数回通信する
        var count = 20;
        var offset = 0;
        var recentDataArray = [];

        var onSuccess = function (json) {
            // 初回時の一件目のIDをキャッシュに保存しておく
            if (offset == 0 && cache.canUseStorage) {
                cache.setLastId(json[0].id);
            }

            json.forEach(function (x) { recentDataArray.push(x); });

            offset = offset + count;

            if (offset == displayCount) {
                // すべてのデータを読み込み終えたら表示
                createRecentList(recentDataArray);
            } else {
                // 表示件数とoffsetの差が20未満になったら取得件数を変更する
                if (displayCount - offset < 20) {
                    count = displayCount - offset;
                }

                tumblrApiCall(baseUri(count, offset), onSuccess, onError, onEmpty)();
            }
        };

        tumblrApiCall(baseUri(count, offset), onSuccess, onError, onEmpty)();
    }
};

function createRecentList(dataArray) {
    /// <summary>最新記事一覧のリストをバインドする</summary>
    /// <param name="dataArray" type="Array">最新記事一覧データ</param>

    var viewModel = getRecentViewModel();
    dataArray.forEach(function (x) {
        viewModel.setData(x);
    });
    viewModel.isLoading(false);
};

以前作った時のものと比較すると差が一目瞭然ですね。

エラーが発生したときの処理だけはがっつりDOM操作してしまっていますが、まぁそれはなんて言うか、面倒だったので…。

勿論エラー時用のプロパティをViewModelに用意しておけばそちらの表示に切り替えることも可能です。

ViewとViewModelの関係

さて、これがどうやってView-ViewModelに紐付くかもちゃんと解説しましょう。

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"));

まずはここ。

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

これだけでRecentViewModelとHTMLに書かれていたプロパティ名は対応します。第二引数に対象とするノードのelementを渡すことで、バインドする要素を絞り込むことが出来ます。(これを渡さないとHTML全体にViewModelが適用される)

次にRecentViewModelの中身を見ていきます。

/// <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; };

Viewに変更を通知する場合はko.observableないしはko.observableArray()をセットします。変更を通知する必要がない場合はobservableなオブジェクトでラップしなければいいだけです。

// これでもバインドできるが、値を変更してもViewは何もおきない
this.isLoading = true;

setDataはちょっと複雑です。

this.setData = function (post) {
    
    // 日付変換(省略)

    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); });
    }
};

this.datasはobservableArrayです。this.datas()で最新状態のArrayがとれます。observableArrayにはsomeやfilterがないので一旦そっちでとってきます。

が、pushやpopと言ったメソッドは普通に使えます。だからなんか気持ち悪いんですね。

また、observableArrayはArrayでのみ初期化可能です。そのまま普通のオブジェクトを渡しても怒られます。

Knockout.jsで使える構文

さて、次にViewの方を見ていきましょう。

<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>

ViewModelをバインドしたのはidがrecentのdivです。この中にあるHTMLのElementはdata-bindを宣言することで好き勝手にViewModel内のプロパティにアクセスできます。

更にKnockout.jsは超強力なフロー制御構文を持っています。ここではifforeachを使っています。

これはもう読んで字の如くで、ifならプロパティ(もしくは関数)からTrueが返ってくれば表示しますし、foreachはArrayの中身をデータに応じてガンガン展開してくれます。なければないで何もしません。入れ子にすることも可能です。すごい。

また、visibleを指定することでdisplay:none;の切り替えをプロパティの値に応じてやってくれます。

(※本当はvisible: !isLoading()じゃなくてifnot: isLoading()としたかったんだけど、何でか動きませんでした。foreachと一緒には使えない?)

ここでの注意点は、observableなオブジェクトの値を取得して何かを判定するときは必ず()をつけると言うことです。ファンクションとして呼び出した時の値を使わないとViewに反映されません。

バインディング・コンテキスト

前述したforeachやwithによってバインディング・コンテキストを切り替えることでさくさくとViewの処理を記述出来るようになっています。

が、foreach中に親の要素にアクセスしたい時もあります。そう言う時は$parentを使います。

<a data-bind="attr: { href: $parent.dateLink(date) }, text: date"></a>

他にも色々とあるんですが、それはまぁドキュメントを読んだほうがわかりやすいです。

Model-View-ViewModelを紐付ける(タグ一覧)

タグ一覧はタグに紐付くテキスト記事がある時とない時でリンクをクリックした時の挙動が違います。仕様としてはこんな感じ。

タグがクリックされる
└関連するテキスト記事を持ってる?
 ├持ってる
 │└テキスト記事一覧は展開済み?
 │ ├展開していない
 │ │└開く
 │ └展開している
 │  └閉じる
 └持ってない
  └Show Allと同じ動き

これもKnockout.jsだけで表現できます。何はともあれ、Modelのコードを。

TumblingDice.prototype.getTagList = function () {
    /// <summary>タグ一覧取得</summary>

    var cache = new Cache();

    if (isNotUpdateLastId(cache)) {
        // キャッシュを展開する
        var tmp = cache.getAllData();
        if (tmp != null) {
            createTagList(tmp);
            return;
        }
    }

    // キャッシュが展開できなかった場合は通信を行う
    cache.setIsLoading(true);

    var baseUri = function (offset) { return "http://api.tumblr.com/v2/blog/" + domain + "/posts/?api_key=" + apiKey + "&offset=" + offset };
    var offset = 0;
    var itemList = new Array();

    var $div = jQuery("#tags");
    var lastId = cache.getLastId();

    var onError = function (data) {
        $div.find(".loading").remove();
        $div.append($("<p/>").text("Error:" + data.meta.msg));
    };

    var onEmpty = function (data) {
        $div.find(".loading").remove();

        // 既に読み込まれたデータがある場合はバインドする
        if (itemList.length > 0) {
            cache.setAllData(itemList);
            cache.setIsLoading(false);
            createTagList(itemList);

            TumblingDice.prototype.getRelational();
            return;
        }

        $div.append("<p>Empty</p>");
    };

    var onSuccess = function (json) {

        json.forEach(function (x) { itemList.push(x); });

        // jsonのlengthが20未満ならばそれ以上データはないのでキャッシュに保存し、データを展開する
        if (json.length < 20) {

            cache.setAllData(itemList);
            cache.setIsLoading(false);
            createTagList(itemList);

            TumblingDice.prototype.getRelational();

            return;
        }

        // 次の20件を取得しに行く
        offset = offset + 20;

        tumblrApiCall(baseUri(offset), onSuccess, onError, onEmpty)();
    };

    tumblrApiCall(baseUri(offset), onSuccess, onError, onEmpty)();
}

function createTagList(allData) {
    /// <summary>タグ一覧をバインド</summary>

    var tagList = new Array();

    allData.forEach(function (x) {
        x.tag.forEach(function (tag) {
            // 既に保存してあるTagの場合はカウントアップする
            if (tagList.some(function (y) { return y.tagName == tag; })) {

                tagList.filter(function (y) {
                    return y.tagName == tag;
                }).forEach(function (y) {
                    y.count = y.count + 1;
                    y.posts.push(x);
                });
            } else {
                tagList.push({ tagName: tag, count: 1, posts: [x] });
            }
        });
    });

    // 名前順にソートする
    tagList.sort(function (a, b) {
        var x = a.tagName;
        var y = b.tagName;

        if (x < y) return -1;
        if (x > y) return 1;

        return 0;
    });

    var viewModel = getTagListViewModel();

    tagList.forEach(function (x) {
        var posts = x.posts.filter(function (y) { return y.type == "text"; });

        // 関連記事があったら「Show All」を先頭に追加する
        if (posts != null && posts.length > 0) {
            posts.unshift({
                url: "http://" + domain + "/tagged/" + x.tagName,
                title: "Show All"
            });
        }

        viewModel.setData(x, posts);
    });

    viewModel.isLoading(false);
}

こうして見直してみるとShow Allを入れるかどうかはModelじゃなくてViewModelでやるべきでは?って気がしてきますね。って言うか全部setData内でやっちゃえばいいんじゃないですかね?

ま、それはともかくとして、ViewModelとViewです。

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"));
<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>

クリックイベントを設定する

基本的には最新記事一覧と変わりません。が、今回はイベントが設定されています。

<a data-bind="attr: { href: getAllTagLink() }, text: tagWithCount, click: expandLink"></a>
expandLink: function () {
    /// <summary>タグリンクのクリック時挙動</summary>
    if (this.isExpandable) {

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

        return false;
    } else {
        return true;
    }
}

クリックされてもDOMの操作はしません。observableなisExpandedと言うオブジェクトのブール値を切り替えるだけです。でもそれはまたvisibleの判定要素としてバインドされています。

こんな感じにViewとModelの結合度を高めすぎずに色んなイベントを設定できるわけです。当然jQueryを使ってアニメーションさせてもいいでしょう。私は(主にjQuery側の)やり方がわからないし別にさせたくないのでしていません。

Model-View-ViewModelを紐付ける(関連記事)

ようやく最後です。Modelはこんな感じ。

TumblingDice.prototype.getRelational = function () {
    /// <summary>関連記事作成 タグ一覧作成時に同時に作ったキャッシュを使用する</summary>

    var cache = new Cache();

    // タグ一覧を読み込み中なら中止
    if (cache.isLoading()) return;

    var allData = cache.getAllData();

    // Text記事のみを抽出する
    var $texts = jQuery(".text");

    if ($texts.length == 0) return;

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

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

        var $tagLink = $tags.find("a");
        var currentTags = new Array();
        $tagLink.each(function () { currentTags.push(jQuery(this).text().toString().replace("#", "")); });

        // 記事のIDを取得する
        var id = $text.find("footer > .small > li > a").attr("href").match(/post\/(\d+)/)[0];

        var viewModel = getRelationalViewModel($div[0]);

        currentTags.forEach(function (tag) {

            var randomLink = randomize(allData.filter(function (x) {
                return x.id != id
                    && x.tag.some(function (y) { return y == tag; })
                    && x.type == "text";
            }), 5);

            viewModel.setData(tag, randomLink);
        });

        viewModel.isLoading(false);
    });
}

function randomize(arr, count) {
    /// <summary>配列からランダムで値を取り出す</summary>
    /// <param name="arr" type="Array">配列</param>
    /// <param name="count" type="Number">取り出す数</param>
    /// <returns type="Array" />

    if (arr == null) return null;

    var tmpArr = [];
    var length = 0;

    arr.forEach(function (x) {
        length++;
        tmpArr.push(x);
    });

    if (length <= count) return tmpArr;

    var ret = [];

    for (var i = 0; i < count; i++) {
        var sr = Math.random().toString();
        var r = "0." + sr.charAt(sr.length - 1);

        var tmpItem = tmpArr[Math.floor(r * length)];
        while (jQuery.inArray(tmpItem, ret) != -1) {
            sr = Math.random().toString();
            r = "0." + sr.charAt(sr.length - 1);
            tmpItem = tmpArr[Math.floor(r * length)];
        }
        ret.push(tmpItem);
    }

    return ret;
};

ViewModelとViewはこんな風になっています。

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

式をバインドする

簡単な方からいきましょう。data-bindでは式を書くことができます。

<p><a data-bind="attr: { href: tagLink }, text: '#' + tag"></a>の関連記事:</p>

textを見ればわかる通り、固定文字列+バインドしたオブジェクトのプロパティを設定しています。

このように文字列を設定するだけでなく、プロパティとプロパティを比較してbooleanを取得したり、色々出来ます。が、大抵の場合はそれ用のFunctionを用意した方がスマートですし、プロパティの合成もサポートされています。

バインドしたオブジェクトを取得する

Knockout.jsでは一度あるnodeにオブジェクトをバインドすると、それ以降のそのnodeに何かをapplyBindingすることは禁止されます。(バインドしたオブジェクトを削除することは出来るみたいです。

なので、一度バインドした後そのオブジェクトを使いまわしたい場合は、ko.dataFor(node)を使用します。

jQuery(".text").each(function() {
    
    var $texts = jQuery(this);
    var $div = $text.find(".relational-container");
    
    // .text.relational-containerに設定されているviewModelを一つずつ取得する
    var viewModel = getRelationalViewModel($div[0]);

});

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

まとめ

とまぁ、かなり色んなことが出来て楽しいんですが、formの部品をバインドしたりもっとちゃんと動的なWebページを作ると更に便利そうな気がするし、ドキュメントの翻訳もあるのでみんなKnockout.jsで遊びましょう。