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 で表示したのがこちら。
Bootstrap 使っているのでちょっぴりオシャレ。
サイドバーに表示されているプロジェクト名をクリックすると
右にタスク一覧が表示されます。
さらにタスク一覧の中から一つクリックしてみると
タスク一覧からタスク詳細に切り替わります。また、ブラウザの「戻る」「進む」でもちゃんとページが切り替わります。
今回のサンプルでは、タスク一覧やタスク詳細のテンプレートを使い回すことができました。これがもし、全然似ていない画面が大量にあったら、この方法だとビューが肥大化してしまうでしょうね。上手いやり方があるのかな。いや、無理に Single page application にするべきじゃないのかも。要調査。
*1:ko.observable() をセットするやつ。