Lab

by engineering@dwango.jp

jQuery Mobileでのアプリケーション開発にBackbone.jsを導入しよう

こんにちは、2011年度新卒エンジニアの夏目です!突然ですがみなさんJavaScript書いてますか? 最近はjQuery Mobileなどを利用したスマートフォン向けアプリ開発において、クライアントサイドでもヘビーなJavaScriptのコーディングをする機会があると思います。そのようなときコードのいたるところにHTMLが混入したり、どこでどのデータを扱っているのか分からなくなるということになりがちです。

今回はそんな悪夢のようなコーディング生活に一筋の希望の光を照らすBackbone.jsを紹介したいと思います。

対象読者

  • JavaScriptでの開発経験がある方

Backbone.jsとは

Backbone.jsはDocumentCloudが開発をしている、クライアントサイドのJavaScriptコードをModelViewControllerで構築するためのフレームワークです。backboneとは日本語で「背骨」という意味で、Backbone.jsは背骨のようにコード全体を支える役割を担ってくれます。 そもそもMVCとはソフトウェア設計の一つで、アプリケーションのデータとその手続をModel、それらのデータの取得・表示をView、入力の処理をControllerが行うという構造です。

Backbone.jsのModelgetsetでデータの取得やセットができ、savedestroyで指定したurlにクエリを送信します。 CollectionaddgetModelの追加、取得ができ、fetchで指定したurlのデータを取得し、parseでそのデータをそれぞれのmodelに格納するという流れです。 上記のデータをViewで実際にDOMへの操作を行います。Viewにはel要素があり、そこに操作したいHTML要素を指定します。elを元にデータの操作やイベントの登録を行います。 また、ViewにはdelegateEventsという機能があり、Viewの初期化時にイベントを登録することができます。

Backbone.jsではViewControllerの一種だったり、UIから発生するイベントの実行などとしても機能します。

詳しくは公式のドキュメントを確認してください。

簡単なサンプルアプリ

まずはBackbone.jsを実際に体験するために、ModelCollectionViewを利用して、クリックするたび友だちが増える簡単なサンプルを作ってみたいと思います。

このサンプルは下図に示したように、ボタンをクリックすると名前を保持したModelを作成し、それをCollectionにaddするとViewが画面に描画するという形で実装します。

簡単なサンプルアプリ図

準備

Backbone.jsは同社が開発しているUnderscore.jsに依存しているためそれぞれダウンロードします。 Underscore.jsはユーティリティライブラリで、JavaScriptでのプログラミングのサポートをしてくれる便利なメソッドが用意されています。 さらにajaxのリクエストやDOMの操作でjQueryやZeptoを利用しているのでどちらか好きな方を用意します。今回はjQuery Mobileを利用する都合上jQueryを利用したいと思います。

実装

それでは実際にコーディングしてみましょう。

friends.js

var Friend = Backbone.Model.extend({
    // 作成日時を保持
    initialize: function() {
        this.set({date: new Date()});
    }
});

var Friends = Backbone.Collection.extend({
    model: Friend
});

var FriendView = Backbone.View.extend({
    el: "#friends",
    events: {
        "click button": "addFriend" // #friends要素以下のbuttonにclickイベントを登録
    },
    initialize: function() {
        this.collection = new Friends();
        this.collection.bind("add", this.render, this); // collectionにaddされたらrenderを実行
    },
    render: function(friend) {
        $(this.el).children("ul").append(this.template(friend));
    },
    // friendを作成し、collectionに追加する
    addFriend: function() {
        var rand = Math.floor(Math.random()*this.nameTemplate.length);
        var name = this.nameTemplate[rand];
        var friend = new Friend({friendName: name});
        this.collection.add(friend);
    },
    template: function(friend) {
        return "<li>"+friend.get("friendName")+"</li>";
    },
    nameTemplate: [
        "山田",
        "小鳥遊",
        "種島",
        "伊波",
        "轟",
        "白藤",
        "佐藤",
        "相馬",
        "音尾"
    ]
});

index.html

<html>
    <head>
        <meta http-equiv="Content-type" content="text/html; charset=utf-8">
        <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1">
        <title>hello</title>
        <script type="text/javascript" src="lib/js/underscore-min.js"></script>
        <script type="text/javascript" src="lib/js/jquery-1.6.4.min.js"></script>
        <script type="text/javascript" src="lib/js/backbone-min.js"></script>
        <script type="text/javascript" src="js/friends.js"></script>
        <script type="text/javascript">
            $(function(){
                var view = new FriendView();
            });
        </script>
    </head>
    <body>
        <div id="friends">
            <button>クリック</button>
            <ul>
            </ul>
        </div>
    </body>
</html>

デモページを用意したのでご覧ください。

このようにModelViewを分けることで非常にわかりやすいコードになります。

Tumblrの投稿取得アプリをBackbone.jsで

では次に前回のjQuery Mobileの紹介記事でデモとして作成したTumblrの投稿を取得するアプリを、Backbone.jsのModelCollectionView、とUnderscore.jsのtemplateを利用して実装してみたいと思います。またBackbone.jsにおけるControllerはurlでコントロールを行うRouterというものがあるのですが、今回はjQuery Mobileを利用してViewの切り替えを行なっているのでここでは利用しません。

下図のようにユーザ名の入力画面、検索結果画面、検索結果の詳細画面それぞれにViewを作り、入力されたユーザ名の投稿一覧をPostのCollectionに格納し、検索結果画面でCollectionを一覧表示、検索結果の詳細画面では選択されたModelを表示するといった設計にしたいと思います。

Tumblrの投稿取得アプリ図

実装

では実装してみましょう。

tumblr.js

$(function(){
    var Post = Backbone.Model.extend({
        // TumblrのPostから適切なタイトルを取得する
        getTitle: function() {
            var text;
            switch (this.get("type")) {
                case "regular": { text = this.get("regular-title"); break; }
                case "link": { text = this.get("link-text"); break; }
                case "quote": { text = this.get("quote-source"); break; }
                case "photo": { text = this.get("photo-caption"); break; }
                case "conversation": { text = this.get("conversation-title"); break; }
                case "video": { text = this.get("video-caption"); break; }
            }
            return this.splitTags(text) || "no title";
        },
        // TumblrのPostから詳細データを取得する
        getDetail: function() {
            var content;
            switch (this.get("type")) {
                case "regular": {
                    content = this.get("regular-body");
                    break;
                }
                case "link": {
                    content = this.linkTemplate({url: this.get("link-url"), text: this.get("link-text")});
                    break;
                }
                case "quote": {
                    content = this.get("quote-text") + "<br>" + this.get("quote-source");
                    break;
                }
                case "photo": {
                    content = this.imgTemplate({url: this.get("url"), img: this.get("photo-url-400"), caption: this.get("photo-caption")});
                    break;
                }
                case "conversation": {
                    content = this.get("conversation-text");
                    break;
                }
                case "video": {
                    content = this.videoTemplate({video: this.get("video-player"), caption: this.get("video-caption")});
                    break;
                }
            }
            return content || "no content";
        },
        splitTags : function(text) {
            return text.replace(/<\/?[^>]+>/gi, "");
        },
        linkTemplate: _.template("<a href='<%= url %>' data-role='button'><%= text %></a><br>URL: <%= url %>"),
        imgTemplate: _.template("<a class='src' href='<%= url %>'><img class='img' src='<%= img %>' /><span class='caption'><%= caption %></span></a>"),
        videoTemplate: _.template("<%= video %><span class='caption'><%= caption %></span>")
    });

    var Posts = Backbone.Collection.extend({
        model: Post,
        // Collectionのurlを設定
        initialize: function(data) {
            this.url = "http://" + data.userId + ".tumblr.com/api/read/json";
        },
        // urlから取得したデータからModelになる部分を返す
        parse: function(resp) {
            return resp.posts;
        }
    });

    window.SearchPage = Backbone.View.extend({
        el: "#search",
        events: {
            "click button": "search"
        },
        // 検索結果ページにユーザIDを渡して画面遷移させる
        search: function() {
            var userId = $("#user-id").val();
            if(userId){
                if(typeof page.resultPage === "undefined"){
                    page.resultPage = new ResultPage(userId);
                } else {
                    page.resultPage.reset(userId);
                }
                page.resultPage.render();
                $.mobile.changePage("#result");
            }
        }
    });

    window.ResultPage = Backbone.View.extend({
        el: "#result",
        events: {
            "click li": "changePage"
        },
        initialize: function(userId) {
            this.reset(userId);
        },
        render: function(userId) {
            // JSONP形式でcollectionのurlに非同期通信を行う
            this.collection.fetch({dataType: "jsonp", success:$.proxy(this.add, this)});
            return this;
        },
        // collectionのfetchが成功したらlistにそれぞれのPostをappendしていく
        add: function(collection, resp) {
            var that = this;
            var list = $("#post-list");
            collection.each(function(post, key) {
                list.append(that.template({text: post.getTitle(), id: post.cid}));
            });
            list.listview("refresh");
        },
        // DOMとcollectionをリセット
        reset: function(userId) {
            $("#post-list").empty();
            $(this.el).find(".user-id").text(userId);
            this.collection = new Posts({userId: userId});
        },
        template: _.template("<li id='<%= id %>'><a><span class='text'><%= text %></span></a></li>"),
        // 検索結果の詳細ページに遷移する
        changePage: function(event) {
            var id = event.currentTarget.id;
            if(typeof page.resultDetailPage === "undefined") {
                page.resultDetailPage = new ResultDetailPage();
            }
            page.resultDetailPage.render(this.collection.getByCid(id));
            $.mobile.changePage("#detail");
        }
    });

    window.ResultDetailPage = Backbone.View.extend({
        render: function(post) {
            this.model = post;
            $("#post-title").text(this.model.getTitle());
            $("#post-content").html(this.model.getDetail());
            return this;
        }
    });
});

index.html

jQuery Mobileを利用し#search#result#detailというページを用意します。前回と変わったことろはtumblr.jsを読み込み、SearchPageをnewするということだけです。

<html>
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1">
        <title>tumblr投稿閲覧</title>
        <link rel="stylesheet" href="../lib/css/jquery.mobile-1.0rc1.min.css" />

        <script type="text/javascript" src="../lib/js/underscore-min.js"></script>
        <script type="text/javascript" src="../lib/js/jquery-1.6.4.min.js"></script>
        <script type="text/javascript" src="../lib/js/backbone-min.js"></script>
        <script type="text/javascript" src="js/tumblr.js"></script>
        <script type="text/javascript" src="../lib/js/jquery.mobile-1.0rc1.min.js"></script>
        <script type="text/javascript">
            $(function(){
                window.page = {};
                page.searchPage = new SearchPage();
            });
        </script>
    </head>
    <body>
        <div data-role="page" id="search">
            <div data-role="header">
                <h1>tumblr投稿閲覧</h1>
            </div>
            <div data-role="fieldcontain">
                ユーザー名: <input type="search" id="user-id">
                <button>検索</button>
            </div>
        </div>

        <div data-role="page" id="result" data-add-back-btn="true" data-back-btn-text="戻る">
            <div data-role="header">
                <h1>結果 : <span class="user-id"></span></h1>
            </div>
            <ul data-role="listview" id="post-list" data-inset="true">
            </ul>
        </div>

        <div data-role="page" id="detail" data-add-back-btn="true" data-back-btn-text="戻る">
            <div data-role="header">
                <h1 id="post-title"></h1>
            </div>
            <div data-role="content" id="post-content"></div>
        </div>
    </body>
</html>

実行すると以下のようになります

検索画面
結果取得画面

こちらもデモページを用意したので、ぜひ試してください。

Backbone.jsを利用してコードをMVCに切り分けることで、見やすく簡潔になりました。

まとめ

スマートフォンアプリの開発にJavaScriptを導入することも多くなり、みなさんもJavaScriptに触れる機会が増えているかと思います。そのときはBackbone.jsを導入してハッピーなコーディングライフを送ってみてはいかがでしょうか。