Knockout.js と Sammy を使って Single Page Application に挑戦

Knockout.js はブラウザの履歴に対応していません。対応するには他のライブラリと組み合わせる必要があります。組み合わせるライブラリは、Knockout.js のチュートリアル Single page applications で使ってある Sammy が有力。なにせ、公式のチュートリアルで使っているくらいですからね。

Knockout.js と Sammy を使って、画面切り替え&ブラウザ履歴対応を実現する方法は次の通り。

  • ビュー(HTML)の中に複数のサブ画面を記述。
  • ビューに記述したサブ画面に対応するプロパティ*1をビューモデルに用意。
  • Sammyのルートで、表示したいサブ画面にバインドしているプロパティに、データをセット。
    • 表示したくないサブ画面にバインドしているプロパティに null をセット。
  • ビューモデルからサブ画面を切り替えたいときは location.hash を変更する。

Knockout.js のテンプレートの「with でバインドしたデータが null だった場合、その要素は描画されない」という特性を利用しています。

文章だとイメージしにくいですね。サンプルを見た方が手っとり早い。

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8"/>
        <title>Knockout Projects</title>
        <link rel="stylesheet" href="http://twitter.github.com/bootstrap/assets/css/bootstrap.css" />
        <style type="text/css">
            body {
                padding-top: 60px;
                padding-bottom: 40px;
            }
            .sidebar-nav {
                padding: 9px 0;
            }
        </style>
    </head>
    <body>
        <div class="navbar navbar-fixed-top">
            <div class="navbar-inner">
                <div class="container-fluid">
                    <a class="brand" href="#">Knockout Projects</a>
                </div>
            </div>
        </div>

        <div class="container-fluid">
            <div class="row-fluid">
                <div class="span3">
                    <!--プロジェクト一覧を表示するサイドバー-->
                    <div class="well sidebar-nav">
                        <ul class="nav nav-list" data-bind="foreach: projects">
                            <li><a href="#" data-bind="text: name, event: { click: show }"></a></li>
                        </ul>
                    </div>
                </div>

                <div class="span9">
                    <!--プロジェクトに属するタスクの一覧を表示するページ-->
                    <div class="task-list" data-bind="with: selectedProject">
                        <h2 data-bind="text: name"></h2>
                        <table>
                            <tbody data-bind="foreach: tasks">
                                <tr>
                                    <td><a href="#" data-bind="text: name, event: { click: show }"></a></td>
                                </tr>
                            </tbody>
                        </table>
                    </div>

                    <!--タスクの詳細を表示するページ-->
                    <div class="task-detail" data-bind="with: selectedTask">
                        <a href="#" data-bind="event: { click: back }">Back</a>
                        <h2 data-bind="text: name"></h2>
                        <!--ko ifnot: completed -->
                        <input type="button" value="Complete" data-bind="event: { click: complete }" />
                        <!--/ko-->
                        <!--ko if: completed -->
                        <input type="button" value="Uncomplete" data-bind="event: { click: uncomplete }" />
                        <!--/ko-->
                    </div>
                </div>
            </div>
        </div>

        <script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.7.1/jquery.min.js"></script>
        <script type="text/javascript" src="https://github.com/quirkey/sammy/raw/master/lib/min/sammy-latest.min.js"></script>
        <script type="text/javascript" src="https://github.com/downloads/SteveSanderson/knockout/knockout-2.0.0.js"></script>
        <script type="text/javascript">
        (function() {

        /**
         * タスク
         */
        var Task = function(app, project) {
            this.id = Task.nextId();
            this.name = ko.observable("");
            this.completed = ko.observable(false);
            this._project = project;
            this._app = app;
        };
        /**
         * タスクの空き ID を取得
         */
        Task.nextId = (function(){
            var _id = 0;
            var getNextId = function() {
                return ++_id;
            };
            return getNextId;
        })();
        /**
         * タスクの一覧を表示
         */
        Task.prototype.back = function() {
            this._project.show();
        };
        /**
         * タスクを完了する
         */
        Task.prototype.complete = function() {
            this.completed(true);
        };
        /**
         * タスクの完了を取り消す
         */
        Task.prototype.uncomplete = function() {
            this.completed(false);
        };
        /**
         * タスクの詳細を表示
         */
        Task.prototype.show = function() {
            // 詳細ページに移動
            window.location.hash = "#/" + this._project.id + "/" + this.id;
        };

        /**
         * プロジェクト
         */
        var Project = function(app) {
            /**
             * ID
             */
            this.id = Project.nextId();

            /**
             * プロジェクト名
             */
            this.name = ko.observable("");

            /**
             * タスク一覧
             */
            this.tasks = ko.observableArray();

            this._app = app;
        };
        /**
         * プロジェクトの空き ID を取得
         */
        Project.nextId = (function() {
            var _id = 0;
            var getNextId = function() {
                return ++_id;
            };
            return getNextId;
        })();
        /**
         * プロジェクトにタスクを追加
         */
        Project.prototype.addTask = function(name) {
            var t = new Task(this._app, this);
            t.name(name);
            this.tasks.push(t);
            return this;
        };
        /**
         * 指定した ID のタスクを取得します。
         */
        Project.prototype.findTask = function(id) {
            var task = ko.utils.arrayFirst(this.tasks(), function(t) {
                return String(id) === String(t.id);
            }, this);
            return task;
        };
        /**
         * プロジェクトに属するタスクの一覧を表示
         */
        Project.prototype.show = function() {
            // プロジェクトページに移動
            window.location.hash = "/" + this.id;
        };

        /**
         * メインのビューモデル
         */
        var MainViewModel = function() { 
            /**
             * プロジェクト一覧
             */
            this.projects = ko.observableArray();

            /**
             * 選択中のプロジェクト
             */
            this.selectedProject = ko.observable();

            /**
             * 選択中のタスク
             */
            this.selectedTask = ko.observable();
        };
        /**
         * 指定した ID のプロジェクトを取得します。
         */
        MainViewModel.prototype.findProject = function(id) {
            var project = ko.utils.arrayFirst(this.projects(), function(p) {
                return String(id) === String(p.id);
            }, this);
            return project;
        };
        /**
         * プロジェクトを追加
         */
        MainViewModel.prototype.addProject = function(name) {
            var p = new Project(this);
            p.name(name);
            this.projects.push(p);
            return p;
        };

        // テストデータ作成
        var vm = new MainViewModel();
        vm.addProject("aaa").addTask("foo").addTask("bar");
        vm.addProject("bbb").addTask("hoge").addTask("fuga");

        // ビューにバインド 
        ko.applyBindings(vm);

        // Sammy を使ってブラウザの履歴に対応する
        var app = $.sammy(function() {
            // タスクの詳細ページを表示する
            this.get("#/:project_id/:task_id", function() {
                // プロジェクト取得
                var projectId = this.params["project_id"];
                var project = vm.findProject(projectId);

                // タスク取得
                var taskId = this.params["task_id"];
                var task = project.findTask(taskId);
                
                // 画面更新
                vm.selectedProject(null);
                vm.selectedTask(task);
            });

            // プロジェクトの詳細ページを表示する
            this.get("#/:project_id", function() {
                // プロジェクト取得
                var projectId = this.params["project_id"];
                var project = vm.findProject(projectId);
                
                // 画面更新
                vm.selectedTask(null);
                vm.selectedProject(project);
            });
        });
        app.run();

        })();
        </script>
    </body>
</html>

サブ画面ごとに ko.observable() でプロパティを用意しているのがイマイチ。いい方法が思いつかなかったから仕方ないか。.NET だとバインドしているデータの型によってテンプレートを切り替えたりできるんですけどね。

サンプルはコピペで動くはず。Chrome で表示したのがこちら。
f:id:griefworker:20120301213745p:image
Bootstrap 使っているのでちょっぴりオシャレ。

サイドバーに表示されているプロジェクト名をクリックすると
f:id:griefworker:20120301213746p:image
右にタスク一覧が表示されます。

さらにタスク一覧の中から一つクリックしてみると
f:id:griefworker:20120301213747p:image
タスク一覧からタスク詳細に切り替わります。また、ブラウザの「戻る」「進む」でもちゃんとページが切り替わります。

今回のサンプルでは、タスク一覧やタスク詳細のテンプレートを使い回すことができました。これがもし、全然似ていない画面が大量にあったら、この方法だとビューが肥大化してしまうでしょうね。上手いやり方があるのかな。いや、無理に Single page application にするべきじゃないのかも。要調査。

*1:ko.observable() をセットするやつ。