Knockout + ContainerJS でテスタブルにToDoリストを作るチュートリアル
Knockout + ContainerJS + Require.js で テスタブル にToDoリストを作るチュートリアルです。
ポイント
- MVVMアーキテクチャでテスタブルに
- オブジェクトの生成と依存関係を、DIコンテナで一元管理
- JavaScriptソースはクラスごとに分割管理
- 1ファイル200行超えたらメンテナンスとか無理ですよね! ということで、ソースファイルはクラスごとに分割管理します。
- ソース間の依存関係解決と読み込みはrequire.jsで。
- リリース時には、必要なソースをminify && 1ファイルに結合して読み込みを最適化します。
目次
- Modelを作る
- Modelをテストする
- ViewModelを作る
- ViewModelをテストする
- View(HTML/CSS)を作る
- minify && 結合してリリース
ソースはこちらで公開しています。細かいところは省略していたりするので、ソースも合わせて参照ください。
Todoリストの仕様
作成するTodoリストの機能は以下。
- タイトルを入力して、Todoを登録できる。
- Todoにはタイトルと完了したかどうか、作成日、最終更新日の属性がある。
- 終了したTodoは「完了」にできる。
- 完了したものを未完了に戻すこともできる。
- Todoを個別に削除できる。
- 完了したTodoはまとめて削除できる。
TodoMVC で作られているものと大体同じはず。UIは以下のような感じで。
なお、本来は登録したTodoをサーバーに送って保存とか必要ですが、今回はJavaScriptプロジェクトのサンプルということでサーバーとの連携部分は省略します。サーバーで永続化する場合は、Model側のAPI呼び出し時にREST APIなりなんなりを呼び出すようにすればOK。
1. Modelを作る
まずは、Modelを作ります。
- クラスは、TodoとTodoリスト(登録されたTodoの集まりを表すもの)の二つ。
- モデルはPlainなJavaScriptクラスとして定義します。
- フレームワーク(or ライブラリ)依存にはしない派。
- 理由は、このあたり を参照。
- テスタブルで変化に強いモデルにするためには、PlainなJavaScriptクラスにしておくべきと思うのです。
Todo
- タイトル(title),完了したかどうか(completed)、作成日(createdAt)、最終更新日(lastModified)の属性を持ちます。
- タイトルは100文字まで。空文字も不可。
- 各種属性の変更を通知する仕組みを持ちます。(Observerパターン)
- 完了にする(complete)/未完了に戻す(activate)操作ができます。
- 現在時刻はTimeSourceから取得するようにしています。
- 現在時刻に依存しないテストにするためのワンポイント。
- テスト時にはTimeSourceをモックに差し替えて、テスト内での時間をコントロールします。
define([ "models/events", "utils/observable" ], function(Events, Observable){ "use strict"; var sequence = 1; /** * @class */ var Todo = function(timeSource) { Observable.apply(this, arguments); var now = timeSource.now(); this.id = sequence++; this.title = ""; this.completed = false; this.createdAt = now; this.lastModified = now; this.timeSource = timeSource; this.todoList = null; Object.seal(this); }; Todo.prototype = new Observable(); /** * @public * @return {string} */ Todo.prototype.setTitle = function( title ) { validateTitle(title); update(this, "title", title); update(this, "lastModified", this.timeSource.now()); }; /** * @public */ Todo.prototype.complete = function() { update(this, "completed", true); update(this, "lastModified", this.timeSource.now()); }; /** * @public */ Todo.prototype.activate = function() { update(this, "completed", false); update(this, "lastModified", this.timeSource.now()); }; /** * @public */ Todo.prototype.remove = function() { if (!this.todoList) throw new Error("illegal state. todoList is not set."); this.todoList.removeById( this.id ); }; /** @private */ Todo.prototype.attachTo = function( todoList ) { this.todoList = todoList; }; /** * @public * @param {TimeSource} timeSource * @param {string} title * @param {boolean?} completed * @param {Date?} createdAt * @param {Date?} lastModified */ Todo.create = function( timeSource, title, completed, createdAt, lastModified ) { validateTitle(title); var now = timeSource.now(); var todo = new Todo(timeSource); todo.title = title; todo.completed = completed || false; todo.createdAt = createdAt || now; todo.lastModified = lastModified || now; return todo; }; /** @private */ var update = function(that, propertyName, newValue) { var oldValue = that[propertyName]; that[propertyName] = newValue; that.fire(Events.UPDATED, { propertyName : propertyName, newValue : newValue, oldValue : oldValue }); }; /** @private */ var validateTitle = function(title) { if (!title) throw new Error("title is not set."); if (title.length > 100) throw new Error("title is too long."); }; return Object.freeze(Todo); });
TodoList
- Todoの配列(items)と、それを操作するメソッド(Todoの追加や削除)を持ちます。
- Todoと同じく、こちらもObserverパターンでTodoの追加/削除を通知する仕組みを用意。
define([ "container", "models/events", "models/todo", "utils/observable" ], function(ContainerJS, Events, Todo, Observable){ "use strict"; /** * @class */ var TodoList = function() { Observable.apply(this, arguments); this.timeSource = ContainerJS.Inject; this.items = []; Object.seal(this); }; TodoList.prototype = new Observable(); /** * @public */ TodoList.prototype.load = function( ) { this.fire( Events.LOADED, { items: this.items }); }; /** * @public * @param {string} title * @return {Todo} */ TodoList.prototype.add = function( title ) { var todo = Todo.create(this.timeSource, title); todo.attachTo(this); this.items.push(todo); this.fire( Events.ADDED, { added : [todo], items : this.items }); return todo; }; /** * @public */ TodoList.prototype.removeCompleted = function() { removeItems( this, function(item){ return item.completed; }); }; /** * @public * @param {number} id */ TodoList.prototype.removeById = function(id) { removeItems( this, function(item){ return item.id === id; }); }; /** @private */ var removeItems = function( that, f ) { var removed = []; that.items = that.items.filter(function(item){ if (f(item)) { removed.push(item); return false; } else { return true; } }); if (removed.length > 0) { that.fire( Events.REMOVED, { items : that.items, removed: removed }); } }; return Object.freeze(TodoList); });
2. Modelをテストする
Modelができたので、テストを書きます。
Todo spec
define([ "models/todo", "models/events", "test/mock/models/time-source" ], function( Todo, Events, TimeSource ) { describe('Todo', function() { var timeSource = new TimeSource(); timeSource.set(2013, 1, 1); it( "'new' creates a new instance.", function() { var todo = new Todo(timeSource); expect( todo.title ).toEqual( "" ); expect( todo.completed ).toEqual( false ); expect( todo.createdAt ).toEqual( new Date(2013,0,1) ); expect( todo.lastModified ).toEqual( new Date(2013,0,1) ); }); it( "'Todo.create' can creates a new instance with specified properties.", function() { var todo = Todo.create(timeSource, "title", true, new Date(1000), new Date(2000) ); expect( todo.title ).toEqual( "title" ); expect( todo.completed ).toEqual( true ); expect( todo.createdAt ).toEqual( new Date(1000) ); expect( todo.lastModified ).toEqual( new Date(2000) ); todo = Todo.create(timeSource, "title2", false, new Date(2000), new Date(3000) ); expect( todo.title ).toEqual( "title2" ); expect( todo.completed ).toEqual( false ); expect( todo.createdAt ).toEqual( new Date(2000) ); expect( todo.lastModified ).toEqual( new Date(3000) ); }); it( "'setTitle' can updates a title and fire updated events.", function() { var todo = Todo.create(timeSource, "title", false, new Date(1000), new Date(2000) ); var events = []; todo.addObserver( Events.UPDATED, function(ev) { events.push( ev ); }); todo.setTitle("title2"); expect( todo.title ).toEqual( "title2" ); expect( todo.completed ).toEqual( false ); expect( todo.createdAt ).toEqual( new Date(1000) ); expect( todo.lastModified ).not.toEqual( new Date(2000) ); expect( events.length ).toEqual( 2 ); expect( events[0].eventId ).toEqual( Events.UPDATED ); expect( events[0].propertyName ).toEqual( "title" ); expect( events[0].newValue ).toEqual( "title2" ); expect( events[0].oldValue ).toEqual( "title" ); expect( events[1].eventId ).toEqual( Events.UPDATED ); expect( events[1].propertyName ).toEqual( "lastModified" ); expect( events[1].newValue ).toEqual( todo.lastModified ); expect( events[1].oldValue ).toEqual( new Date(2000) ); }); it( "'complete' can updates a completed state and fire updated events.", function() { var todo = Todo.create(timeSource, "title", false, new Date(1000), new Date(2000) ); var events = []; todo.addObserver( Events.UPDATED, function(ev) { events.push( ev ); }); todo.complete(); expect( todo.title ).toEqual( "title" ); expect( todo.completed ).toEqual( true ); expect( todo.createdAt ).toEqual( new Date(1000) ); expect( todo.lastModified ).not.toEqual( new Date(2000) ); expect( events.length ).toEqual( 2 ); expect( events[0].eventId ).toEqual( Events.UPDATED ); expect( events[0].propertyName ).toEqual( "completed" ); expect( events[0].newValue ).toEqual( true ); expect( events[0].oldValue ).toEqual( false ); expect( events[1].eventId ).toEqual( Events.UPDATED ); expect( events[1].propertyName ).toEqual( "lastModified" ); expect( events[1].newValue ).toEqual( todo.lastModified ); expect( events[1].oldValue ).toEqual( new Date(2000) ); }); it( "'activate' can updates a completed state and fire updated events.", function() { var todo = Todo.create(timeSource, "title", true, new Date(1000), new Date(2000) ); var events = []; todo.addObserver( Events.UPDATED, function(ev) { events.push( ev ); }); todo.activate(); expect( todo.title ).toEqual( "title" ); expect( todo.completed ).toEqual( false ); expect( todo.createdAt ).toEqual( new Date(1000) ); expect( todo.lastModified ).not.toEqual( new Date(2000) ); expect( events.length ).toEqual( 2 ); expect( events[0].eventId ).toEqual( Events.UPDATED ); expect( events[0].propertyName ).toEqual( "completed" ); expect( events[0].newValue ).toEqual( false ); expect( events[0].oldValue ).toEqual( true ); expect( events[1].eventId ).toEqual( Events.UPDATED ); expect( events[1].propertyName ).toEqual( "lastModified" ); expect( events[1].newValue ).toEqual( todo.lastModified ); expect( events[1].oldValue ).toEqual( new Date(2000) ); }); }); });
TodoList spec
define([ "models/todo-list", "models/events", "test/mock/models/time-source" ], function( TodoList, Events, TimeSource ) { describe('TodoList', function() { var todoList; var events = []; beforeEach(function() { todoList = new TodoList(); todoList.timeSource = new TimeSource(); [Events.LOADED,Events.ADDED,Events.REMOVED].forEach(function(i) { todoList.addObserver( i, function(ev) { events.push( ev ); }); }); }); afterEach(function() { events = []; }); it( "'add' creates and add a new todo.", function() { todoList.add("test1"); expect( events.length ).toEqual( 1 ); expect( events[0].eventId ).toEqual( Events.ADDED ); expect( events[0].added.length ).toEqual( 1 ); expect( events[0].added[0].title ).toEqual( "test1" ); expect( events[0].items.length ).toEqual( 1 ); expect( events[0].items[0].title ).toEqual( "test1" ); todoList.add("test2"); expect( events.length ).toEqual( 2 ); expect( events[1].eventId ).toEqual( Events.ADDED ); expect( events[1].added.length ).toEqual( 1 ); expect( events[1].added[0].title ).toEqual( "test2" ); expect( events[1].items.length ).toEqual( 2 ); expect( events[1].items[0].title ).toEqual( "test1" ); expect( events[1].items[1].title ).toEqual( "test2" ); }); it( "'removeById' removes todos by id.", function() { var items = []; items.push(todoList.add("test1")); items.push(todoList.add("test2")); items.push(todoList.add("test3")); expect( todoList.items.length ).toEqual( 3 ); events = []; todoList.removeById( items[1].id ); { expect( todoList.items.length ).toEqual( 2 ); expect( events.length ).toEqual( 1 ); expect( events[0].eventId ).toEqual( Events.REMOVED ); expect( events[0].removed.length ).toEqual( 1 ); expect( events[0].removed[0].title ).toEqual( "test2" ); expect( events[0].items.length ).toEqual( 2 ); expect( events[0].items[0].title ).toEqual( "test1" ); expect( events[0].items[1].title ).toEqual( "test3" ); } todoList.removeById( items[2].id ); { expect( todoList.items.length ).toEqual( 1 ); expect( events.length ).toEqual( 2 ); expect( events[1].eventId ).toEqual( Events.REMOVED ); expect( events[1].removed.length ).toEqual( 1 ); expect( events[1].removed[0].title ).toEqual( "test3" ); expect( events[1].items.length ).toEqual( 1 ); expect( events[1].items[0].title ).toEqual( "test1" ); } todoList.removeById( 9999 ); { expect( todoList.items.length ).toEqual( 1 ); expect( events.length ).toEqual( 2 ); } todoList.removeById( items[0].id ); { expect( todoList.items.length ).toEqual( 0 ); expect( events.length ).toEqual( 3 ); expect( events[2].eventId ).toEqual( Events.REMOVED ); expect( events[2].removed.length ).toEqual( 1 ); expect( events[2].removed[0].title ).toEqual( "test1" ); expect( events[2].items.length ).toEqual( 0 ); } }); it( "'removeCompleted' removes completed todos.", function() { var items = []; items.push(todoList.add("test1")); items.push(todoList.add("test2")); items.push(todoList.add("test3")); expect( todoList.items.length ).toEqual( 3 ); events = []; todoList.removeCompleted( ); { expect( todoList.items.length ).toEqual( 3 ); expect( events.length ).toEqual( 0 ); } items[1].complete(); items[2].complete(); todoList.removeCompleted( ); { expect( todoList.items.length ).toEqual( 1 ); expect( events.length ).toEqual( 1 ); expect( events[0].eventId ).toEqual( Events.REMOVED ); expect( events[0].removed.length ).toEqual( 2 ); expect( events[0].removed[0].title ).toEqual( "test2" ); expect( events[0].removed[1].title ).toEqual( "test3" ); expect( events[0].items.length ).toEqual( 1 ); expect( events[0].items[0].title ).toEqual( "test1" ); } items[0].complete(); todoList.removeCompleted( ); { expect( todoList.items.length ).toEqual( 0 ); expect( events.length ).toEqual( 2 ); expect( events[1].eventId ).toEqual( Events.REMOVED ); expect( events[1].removed.length ).toEqual( 1 ); expect( events[1].removed[0].title ).toEqual( "test1" ); expect( events[1].items.length ).toEqual( 0 ); } }); }); });
テストが書けたら、実行環境を整備していきます。
の2つを用意。
HTML版
Jasminのテストランナーをベースに作成します。ファイル構成は以下。
├src/ │└models/ │ ├todo.js │ └todo-list.js └ test/ ├specs/ .. テストを格納するディレクトリ │└models/ │ ├todo.js │ └todo-list.js ├all-specs.js ├config.js .. テスト用のrequire.js設定ファイル └test.html .. テストをブラウザ上で実行する時に読み込ませるHTMLファイル
test.htmlは以下の内容。
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Jasmine Test Runner</title> <link rel="stylesheet" type="text/css" href="../../../lib-test/jasmine-1.0.2/jasmine.css"> <script type="text/javascript" src="../../../lib/require.js"></script> <script type="text/javascript" src="config.js"></script> </head> <body> <script type="text/javascript"> require(["jasmine/jasmine"], function() { require(["jasmine/jasmine-html","larrymyers-jasmine-reporters/jasmine.console_reporter"], function() { require(["test/all-specs"], function() { jasmine.getEnv().addReporter(new jasmine.TrivialReporter()); jasmine.getEnv().execute(); }); }); }); </script> </body> </html>
config.jsにrequire.jsの設定を書きます。pathsに依存モジュールのパスを設定。
require.config({ baseUrl: "../src/js", paths: { "container" : "../../../../minified/container", "knockout" : "./knockout-2.3.0", "test" : "../../test", "jasmine" : "../../../../lib-test/jasmine-1.0.2", "larrymyers-jasmine-reporters" : "../../../../lib-test/larrymyers-jasmine-reporters-adf6227/src" }, waitSeconds: 1 });
all-specs.js でテストを読み込みます。
define([ "test/specs/models/todo", "test/specs/models/todo-list" ]);
あとは、test.htmlをブラウザにドラッグ&ドロップすれば、テストが実行されるはず。
コマンドライン版
Antを使います。 javaタスクでRhinoからテストを実行します。
<?xml version="1.0" encoding="UTF-8"?> <project name="todo lists" default="release" basedir="."> <property name="test.dir" value="test" /> <property name="tools.dir" value="../../tools" /> <property name="rhino.home" value="${tools.dir}/rhino1_7R4" /> <property name="jasmine.home" value="../../lib-test/jasmine-1.0.2" /> <property name="jasmine-reporters.home" value="../../lib-test/larrymyers-jasmine-reporters-adf6227" /> <property name="envjs.home" value="../../lib-test/thatcher-env-js-cb738b9" /> <property name="closure-compiler.home" value="${tools.dir}/compiler-20130603" /> <target name="test"> <java jar="${rhino.home}/js.jar" fork='true' dir="test"> <arg line="-encoding utf-8 -opt -1 cui.js"/> <sysproperty key="envjs.home" value="../${envjs.home}"/> </java> </target> </project>
cui.jsの内容は以下。
- Rhinoでテストを実行するための設定をごにょごにょ書いていきます。
- config.js,all-specs.jsはhtml版と共有。
load("../../../tools/r.js"); load("../../../lib/require.js"); load(environment["envjs.home"]+"/dist/env.rhino.js"); load(environment["config"] || "config.js"); console = {}; console.log = function(str) { print(str); }; var consoleReporter = null; require(["jasmine/jasmine"], function() { require(["jasmine/jasmine-html", "larrymyers-jasmine-reporters/jasmine.console_reporter"], function() { require(["test/all-specs"], function() { consoleReporter = new jasmine.ConsoleReporter(); jasmine.getEnv().addReporter( consoleReporter ); jasmine.getEnv().execute(); }); }); }); Envjs.wait(); var filedCount = consoleReporter.executed_specs - consoleReporter.passed_specs; quit(filedCount <= 0 ? 0 : 1);
あとは、
$ ant test
で実行。テストが走ります。
Buildfile: D:\git\container-js\samples\todo-list-example\build.xml test: [java] See https://github.com/jrburke/r.js for usage. [java] [ Envjs/1.6 (Rhino; U; Windows 7 amd64 6.1; en-US; rv:1.7.0.rc2) Resig/20070309 PilotFish/1.2.13 ] [java] Runner Started. [java] Todo : 'new' creates a new instance. ... [java] Passed. [java] Todo : 'Todo.create' can creates a new instance with specified properties. ... [java] Passed. [java] Todo : 'setTitle' can updates a title and fire updated events. ... [java] Passed. [java] Todo : 'complete' can updates a completed state and fire updated events. ... [java] Passed. [java] Todo : 'activate' can updates a completed state and fire updated events. ... [java] Passed. [java] Todo: 51 of 51 passed. [java] TodoList : 'add' creates and add a new todo. ... [java] Passed. [java] TodoList : 'removeById' removes todos by id. ... [java] Passed. [java] TodoList : 'removeCompleted' removes completed todos. ... [java] Passed. [java] TodoList: 54 of 54 passed. [java] Runner Finished. [java] 8 specs, 0 failures in 0.289s. BUILD SUCCESSFUL Total time: 5 seconds
3.ViewModelを作る
Modelの次はViewModelを作っていきます。
- ViewModelは「UI独自の状態や処理を管理するオブジェクト」です。
- Modelをラップする形で、ModelとViewの仲立ちをします。
- Todoリストであれば、以下のような処理がViewModelの担当になります。
- 最終更新日時をUIで表示する文字列に変換する。
- Todoの追加ボタンや、完了したTodoを消すボタン、などの処理を実行するメソッドを提供する。
- 完了したTodoを消すボタンの利用可/不可状態を制御する。(完了したTodoが1つもなければ利用不可)
- Todoを追加する際にタイトルを設定する口を提供する。
- UI依存のロジックをViewModelとして分離することで、Modelはより純粋なビジネスロジックだけを提供する層にできます。
- UIの変更があってもViewModelだけでの変更で済み、Modelには波及しにくくなります。
「UIを抽象化してオブジェクトにしたもの」考えるとわかりやすいかも。イメージ的には以下のような感じ。
UIを抽象化したものなので、 実際のUI操作シナリオに沿った形でテストケースが書けるのもポイントです。
作成するViewModelの一覧
作成するViewModelは以下の3つです。
- TodoListView
- Todoの一覧部分に対応するViewModelです。
- TodoView
- Todo一覧の中の各Todoに対応するViewModelです。
- TodoInputForm
- 新しいTodoを作成する入力フォーム部分に対応するViewModelです。
ViewModelではKnockoutの機能を利用します。
TodoListView
- items でTodoの一覧を持ちます。
- itemsはモデルの変更通知を受けて随時更新。
- UI上のボタンを押下した際の処理をメソッドで提供します。
- Todoの追加(newTodo),完了したTodoの削除(removeCompleted)
- enableToRemoveCompleted で完了したTodoの削除ボタンが利用可かどうかを管理します。
define([ "container", "knockout", "models/events", "viewmodels/todo-view" ], function(ContainerJS, ko, Events, TodoView){ "use strict"; /** * @class */ var TodoListView = function() { this.todoList = ContainerJS.Inject; this.todoInputForm = ContainerJS.Inject; this.items = ko.observableArray(); this.numberOfCompleted = ko.observable(0); this.enableToRemoveCompleted = ko.computed(function(){ return this.numberOfCompleted() > 0; }.bind(this)); Object.seal(this); }; TodoListView.prototype.initialize = function(){ this.todoList.addObserver( Events.LOADED, this.onLoad.bind(this) ); this.todoList.addObserver( Events.ADDED, this.onAdded.bind(this) ); this.todoList.addObserver( Events.REMOVED, this.onRemoved.bind(this) ); }; TodoListView.prototype.newTodo = function() { this.todoInputForm.newTodo(); }; TodoListView.prototype.removeCompleted = function(event) { this.todoList.removeCompleted(); }; TodoListView.prototype.onLoad = function(event) { this.items(event.items.map(function(todo){ this.addObserverToTodo(todo); return new TodoView(todo); }.bind(this))); }; TodoListView.prototype.onAdded = function(event) { event.added.forEach( function( todo ) { this.addObserverToTodo(todo); this.updateNumberOfCompleted( todo.completed ? 1 : 0 ); this.items.push(new TodoView(todo)); }.bind(this)); }; TodoListView.prototype.onRemoved = function(event) { var removedIds = {}; event.removed.forEach( function( todo ) { this.updateNumberOfCompleted( todo.completed ? -1 : 0 ); removedIds[todo.id] = true; }.bind(this)); this.items.remove( function(item) { return removedIds[item.model.id]; }); }; /** @private */ TodoListView.prototype.addObserverToTodo = function(todo){ todo.addObserver( Events.UPDATED, this.onTodoUpdated.bind(this)); }; /** @private */ TodoListView.prototype.onTodoUpdated = function(event){ if (event.propertyName === "completed") { if (event.newValue !== event.oldValue) { this.updateNumberOfCompleted( event.newValue ? 1 : -1 ); } } }; /** @private */ TodoListView.prototype.updateNumberOfCompleted = function(count){ this.numberOfCompleted( this.numberOfCompleted()+count ); }; return Object.freeze(TodoListView); });
TodoView
一覧の各Todoに対応するViewModelです。
- タイトル(title)や最終更新時刻(lastModified)などを持ちます。
- Todoの完了操作や、(個別の)削除はTodoごとの操作なので、こちらに定義します。
define([ "container", "knockout", "models/events", "viewmodels/todo-view" ], function(ContainerJS, ko, Events, TodoView){ "use strict"; /** * @class */ var TodoView = function(todo) { this.model = todo; this.title = ko.observable(todo.title); this.completed = ko.observable(todo.completed); this.createdAt = ko.observable(todo.createdAt); this.lastModified = ko.observable(todo.lastModified); this.createdAtForDisplay = ko.computed(function(){ return formatDate(this.createdAt()); }.bind(this)); this.lastModifiedForDisplay = ko.computed(function(){ return formatDate(this.lastModified()); }.bind(this)); this.addObservers(); Object.seal(this); }; TodoView.prototype.addObservers = function(){ this.model.addObserver( Events.UPDATED, this.onUpdated.bind(this) ); }; TodoView.prototype.remove = function() { this.model.remove(); }; TodoView.prototype.complete = function(event) { this.model.complete(); }; TodoView.prototype.activate = function(event) { this.model.activate(); } TodoView.prototype.onUpdated = function(event) { this[event.propertyName](event.newValue); }; var formatDate = function(d){ if (!d) return ""; return d.getFullYear() + "-" + (d.getMonth() + 1) + "-" + d.getDate() + " " + d.getHours() + ":" + d.getMinutes() + ":" + d.getSeconds(); }; return Object.freeze(TodoView); });
TodoInputForm
最後はTodoInputForm。Todoの新規作成フォーム部分に対応するViewModelです。
- タイトル(title)を受け取る口とエラー情報(error)を持ちます。
- Todo追加の本処理もこちらに定義しています。
define([ "container", "knockout" ], function(ContainerJS, ko){ "use strict"; /** * @class */ var TodoInputForm = function() { this.todoList = ContainerJS.Inject; this.title = ko.observable(""); this.titleLength = ko.computed(function(){ return this.title().length; }.bind(this)); this.error = ko.observable(""); Object.seal(this); }; TodoInputForm.prototype.newTodo = function() { try { this.todoList.add( this.title() ); this.title(""); this.error(""); } catch ( exception ) { this.error(exception.message); } }; return Object.freeze(TodoInputForm); });
ViewModelとModelの生成と関連付けはContainerJSで管理します。ContainerJS用のモジュール定義(composing/modules.js)も用意しておきます。
define(["container"], function( ContainerJS ) { "use strict"; var models = function( binder ){ binder.bind("todoList").to("models.TodoList"); binder.bind("timeSource").to("models.TimeSource"); }; var viewmodels = function( binder ){ binder.bind("todoListView").to("viewmodels.TodoListView").onInitialize("initialize"); binder.bind("todoInputForm").to("viewmodels.TodoInputForm"); }; var modules = function( binder ){ models(binder); viewmodels(binder); }; return modules; });
4.ViewModelをテストする
ViewModelもテストしていきます。
- 今回はModelのモックは用意せず、ViewModel+Modelの動きをまとめてテストするようにしました。
- TimeSourceのみmockを使います。
- テストでもViewModel,Modelの作成はContainerJSを使って行います。
TodoListView spec
- Todoの追加・削除が動作することを確認。
- 追加・削除に応じて一覧のデータが増減することもチェック。
define([ "container", "models/events", "test/mock/modules", "test/utils/wait" ], function( ContainerJS, Events, modules, Wait ) { describe('TodoListView', function() { var container; var deferred; beforeEach(function() { container = new ContainerJS.Container( modules ); deferred = container.get( "todoListView" ); }); it( "first, items is empty .", function() { Wait.forFix(deferred); runs( function(){ var view = ContainerJS.utils.Deferred.unpack( deferred ); view.todoList.load(); expect( view.items().length ).toBe( 0 ); }); }); it( "can adds a new todo.", function() { Wait.forFix(deferred); runs( function(){ var view = ContainerJS.utils.Deferred.unpack( deferred ); view.todoList.load(); view.todoInputForm.title("test"); view.newTodo(); view.todoInputForm.title("test2"); view.newTodo(); view.todoInputForm.title("test3"); view.newTodo(); expect( view.items().length ).toBe( 3 ); expect( view.items()[0].title() ).toBe( "test" ); expect( view.items()[1].title() ).toBe( "test2" ); expect( view.items()[2].title() ).toBe( "test3" ); }); }); it( "can removes a todo.", function() { Wait.forFix(deferred); runs( function(){ var view = ContainerJS.utils.Deferred.unpack( deferred ); view.todoList.load(); view.todoInputForm.title("test"); view.newTodo(); view.todoInputForm.title("test2"); view.newTodo(); view.todoInputForm.title("test3"); view.newTodo(); expect( view.items().length ).toBe( 3 ); expect( view.items()[0].title() ).toBe( "test" ); expect( view.items()[1].title() ).toBe( "test2" ); expect( view.items()[2].title() ).toBe( "test3" ); view.items()[1].remove(); expect( view.items().length ).toBe( 2 ); expect( view.items()[0].title() ).toBe( "test" ); expect( view.items()[1].title() ).toBe( "test3" ); }); }); it( "can removes completed todos.", function() { Wait.forFix(deferred); runs( function(){ var view = ContainerJS.utils.Deferred.unpack( deferred ); view.todoList.load(); expect( view.enableToRemoveCompleted() ).toBe( false ); view.todoInputForm.title("test"); view.newTodo(); view.todoInputForm.title("test2"); view.newTodo(); view.todoInputForm.title("test3"); view.newTodo(); expect( view.items().length ).toBe( 3 ); expect( view.items()[0].title() ).toBe( "test" ); expect( view.items()[1].title() ).toBe( "test2" ); expect( view.items()[2].title() ).toBe( "test3" ); expect( view.enableToRemoveCompleted() ).toBe( false ); view.items()[0].complete(); expect( view.enableToRemoveCompleted() ).toBe( true ); view.items()[2].complete(); expect( view.enableToRemoveCompleted() ).toBe( true ); view.removeCompleted(); expect( view.items().length ).toBe( 1 ); expect( view.items()[0].title() ).toBe( "test2" ); expect( view.enableToRemoveCompleted() ).toBe( false ); view.items()[0].complete(); expect( view.enableToRemoveCompleted() ).toBe( true ); view.items()[0].activate(); expect( view.enableToRemoveCompleted() ).toBe( false ); }); }); }); });
TodoView spec
- Todoモデルのプロパティが正しく反映されていることを確認。
define([ "models/todo", "models/events", "viewmodels/todo-view", "test/mock/models/time-source" ], function( Todo, Events, TodoView, TimeSource ) { describe('TodoView', function() { var timeSource = new TimeSource(); beforeEach(function() { timeSource.set(2013, 1, 1); }); it( "reflects a model's value.", function() { var todo = new Todo(timeSource); todo.setTitle("test."); var view = new TodoView(todo); { expect( view.title() ).toBe( "test." ); expect( view.completed() ).toBe( false ); expect( view.createdAt() ).toEqual( new Date( 2013, 0, 1 ) ); expect( view.lastModified() ).toEqual( new Date( 2013, 0, 1 )); expect( view.createdAtForDisplay() ).toEqual( "2013-1-1 0:0:0" ); expect( view.lastModifiedForDisplay() ).toEqual( "2013-1-1 0:0:0" ); } timeSource.set(2013, 2, 10); todo.setTitle("test2."); { expect( view.title() ).toBe( "test2." ); expect( view.completed() ).toBe( false ); expect( view.createdAt() ).toEqual( new Date( 2013, 0, 1 ) ); expect( view.lastModified() ).toEqual( new Date( 2013, 1, 10 )); expect( view.createdAtForDisplay() ).toEqual( "2013-1-1 0:0:0" ); expect( view.lastModifiedForDisplay() ).toEqual( "2013-2-10 0:0:0" ); } timeSource.set(2013, 2, 12); todo.complete(); { expect( view.title() ).toBe( "test2." ); expect( view.completed() ).toBe( true ); expect( view.createdAt() ).toEqual( new Date( 2013, 0, 1 ) ); expect( view.lastModified() ).toEqual( new Date( 2013, 1, 12 )); expect( view.createdAtForDisplay() ).toEqual( "2013-1-1 0:0:0" ); expect( view.lastModifiedForDisplay() ).toEqual( "2013-2-12 0:0:0" ); } timeSource.set(2013, 3, 7); todo.activate(); { expect( view.title() ).toBe( "test2." ); expect( view.completed() ).toBe( false ); expect( view.createdAt() ).toEqual( new Date( 2013, 0, 1 ) ); expect( view.lastModified() ).toEqual( new Date( 2013, 2, 7 )); expect( view.createdAtForDisplay() ).toEqual( "2013-1-1 0:0:0" ); expect( view.lastModifiedForDisplay() ).toEqual( "2013-3-7 0:0:0" ); } }); }); });
TodoInputForm spec
- タイトルを入れてTodoを追加できる、タイトルが未入力だと追加できずエラーが表示される、といったあたりを確認。
define([ "container", "models/events", "test/mock/modules", "test/utils/wait" ], function( ContainerJS, Events, modules, Wait ) { describe('TodoInputForm', function() { var container; beforeEach(function() { container = new ContainerJS.Container( modules ); }); it( "'newTodo' can create a new Todo.", function() { var deferred = container.get( "todoInputForm" ); Wait.forFix(deferred); runs( function(){ var form = ContainerJS.utils.Deferred.unpack( deferred ); { expect( form.title() ).toBe( "" ); expect( form.titleLength() ).toBe( 0 ); expect( form.error() ).toBe( "" ); expect( form.todoList.items.length ).toBe( 0 ); } form.title("test."); { expect( form.title() ).toBe( "test." ); expect( form.titleLength() ).toBe( 5 ); expect( form.error() ).toBe( "" ); } form.newTodo(); { expect( form.title() ).toBe( "" ); expect( form.titleLength() ).toBe( 0 ); expect( form.error() ).toBe( "" ); expect( form.todoList.items.length ).toBe( 1 ); expect( form.todoList.items[0].title ).toBe( "test." ); } }); }); it( "'newTodo' fails when the title is invalid.", function() { var deferred = container.get( "todoInputForm" ); Wait.forFix(deferred); runs( function(){ var form = ContainerJS.utils.Deferred.unpack( deferred ); form.newTodo(); { expect( form.title() ).toBe( "" ); expect( form.titleLength() ).toBe( 0 ); expect( form.error() ).toBe( "title is not set." ); expect( form.todoList.items.length ).toBe( 0 ); } form.title( createStringOfLength(101) ); form.newTodo(); { expect( form.title() ).toBe( createStringOfLength(101) ); expect( form.titleLength() ).toBe( 101 ); expect( form.error() ).toBe( "title is too long." ); expect( form.todoList.items.length ).toBe( 0 ); } form.title( createStringOfLength(100) ); form.newTodo(); { expect( form.title() ).toBe( "" ); expect( form.titleLength() ).toBe( 0 ); expect( form.error() ).toBe( "" ); expect( form.todoList.items.length ).toBe( 1 ); expect( form.todoList.items[0].title ).toBe( createStringOfLength(100) ); } }); }); var createStringOfLength = function(length) { var str = ""; for ( var i=length;i>0;i-- ) str += "a"; return str; }; }); });
5.View(HTML/CSS)を作る
ModelとViewModelができたら、Viewを作成して、View/ViewModel/Modelを生成する部分のコードを書いていきます。
まずはHTML。
- 「data-bind」でViewとViewModelを繋いでいきます。
- ここにViewModelの○○で保持されるデータを流し込む
- このボタンが押されたら、ViewModelの▲▲メソッドを実行、といった感じ。
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Todo List</title> <script type="text/javascript" data-main="../js/main.js" src="../../../../lib/require.js"> </script> <link rel="stylesheet" type="text/css" href="../css/todo-list.css"> </head> <body> <div class="todo-input-form"> title: <input type="text" data-bind="value:todoInputForm.title" class="title-field"></input> <button data-bind="click:newTodo" class="add-button"> add </button> <div class="error" data-bind="text:todoInputForm.error"></div> </div > <div class="todo-list" data-bind="foreach:items, visible:true" style="display:none;"> <div class="todo"> <img src="../images/normal.png" alt="" data-bind="visible:!completed()" /> <img src="../images/check.png" alt="checked" data-bind="visible:completed" /> <span class="title" data-bind="text:title"></span> <span class="last-modified" data-bind="text:lastModifiedForDisplay"></span> <span class="commands"> <a href="javascript:void(0)" data-bind="click:activate, visible: completed()"> activate </a> <a href="javascript:void(0)" data-bind="click:complete, visible: !completed()"> complete </a> <a href="javascript:void(0)" data-bind="click:remove">remove</a> </span> </div> </div> <div class="buttons"> <button data-bind="click: removeCompleted, enable:enableToRemoveCompleted"> remove completed </button> </div> </body> </html>
CSSも書きます
@charset "UTF-8"; body { padding:10px; color: #333; } button { padding: 3px 15px; } .todo-input-form { padding: 10px; border: 1px solid #ddd; } .todo-input-form .title-field { width: 200px; } .todo-input-form .error { color: #fa1188; } .todo-list { margin-left: 10px; } .todo { margin-top: 10px; } .todo span{ padding-right: 5px; } .todo .title { font-size: 110%; } .todo .last-modified { font-size: 80%; color: #999; } .todo .commands a { padding-right: 5px; } .buttons { margin-top: 10px; padding: 10px; border: 1px solid #ddd; }
最後はmain.js。
main.jsはhtmlで最初に読まれるスクリプトで、以下の処理を行います。
require.config({ baseUrl: "../js", paths: { "container" : "../../../../minified/container", "knockout" : "knockout-2.3.0" } }); require([ "container", "knockout", "composing/modules", "models/time-source", "models/todo-list", "models/todo", "viewmodels/todo-input-form", "viewmodels/todo-list-view", "viewmodels/todo-view" ], function( ContainerJS, ko, modules ) { var container = new ContainerJS.Container( modules ); container.get("todoListView").then(function( viewModel ){ ko.applyBindings(viewModel); }, function( error ) { alert( error.toString() ); }); });
必要なソースの作成はこれで完了。動いているところはこちらでみられます。
6.minify && 結合してリリース
最後に、ソースをminify && 結合してリリースするAntタスクを定義していきます。
- minify && 結合はRequire.jsからリリースされているr.jsを使用。
- Require.jsの依存関係をたどって必要なファイルを集め、1ファイルに統合します。
- closure等を使用してminifyを行う機能もあります。
- サンプルでは、main.jsにすべてのファイルを統合する形にしています。
- リリース版ではmain.jsが統合版に置き換わり、require.jsとmain.jsのみの読み込みとなります。
- 最初に読むファイルは変わらないので、HTMLの変更は不要。
Antタスクは以下。
<?xml version="1.0" encoding="UTF-8"?> <project name="todo lists" default="release" basedir="."> <property name="test.dir" value="test" /> <property name="tools.dir" value="../../tools" /> <property name="rhino.home" value="${tools.dir}/rhino1_7R4" /> <property name="jasmine.home" value="../../lib-test/jasmine-1.0.2" /> <property name="jasmine-reporters.home" value="../../lib-test/larrymyers-jasmine-reporters-adf6227" /> <property name="envjs.home" value="../../lib-test/thatcher-env-js-cb738b9" /> <property name="closure-compiler.home" value="${tools.dir}/compiler-20130603" /> .. 略 <target name="minify"> <java fork='true' dir="${basedir}" classname="org.mozilla.javascript.tools.shell.Main"> <classpath> <path path="${rhino.home}/js.jar" /> <path path="${closure-compiler.home}/compiler.jar" /> </classpath> <jvmarg line="-Xss1m"/> <arg line="-encoding utf-8 ${tools.dir}/r.js -o dist/build-config.json"/> </java> </target> </project>
build-config.jsonに minify && 結合 の設定を書きます。
({ appDir: "../src", baseUrl: "./js", paths: { "container" : "../../../../minified/container", "knockout" : "knockout-2.3.0" }, dir: "../dst", optimize: "closure", closure: { CompilerOptions: {}, CompilationLevel: 'SIMPLE_OPTIMIZATIONS', loggingLevel: 'WARNING' }, optimizeCss: "standard.keepLines", modules: [{ name: "main" }] })
タスクを実行すると
$ ant minify
「dst」ディレクトリ以下に最適化されたソースが出力されます。あとはこれをリリースすればOK。
リリース版はこちら。最適化前と比べてjsの読み込みが最小化されています。