AngularJS x TypeScript でちょっと本格的な TODO アプリを作ってみる – AngularJS + TypeScript #2
はじめに
とりあえず何かアプリっぽいものを作ってみようということで、定番の TODO アプリに挑戦してみようと思います。
ググればいくらでも情報は出てきますが、この記事では以下にあるようないかにもチュートリアルっぽいものからもう一歩踏み込んで、より実践的かつ規模の大きな案件にも応用出来るような作りを目指してみます。
サンプルコードはこちらからどうぞ。
機能要件と実装方針
まずは TODO アプリの機能要件を大まかに書き出してみます。
- TODO の登録・編集・削除
- 登録済み TODO を一覧表示
- 完了した TODO にはチェックを付けることができる
- 登録済み TODO と完了した TODO の数をそれぞれ表示
サンプルということでシンプルに登録・編集・削除だけに絞っていますが、実装方針を以下のようにして作りを本格的にしてみるとします。
- AltJS に TypeScript を使う
- フレームワークに AngularJS を使う
- コントローラ、ディレクティブをクラス化して外部モジュールとして外出しする
完成予想イメージはこちら。
ではこれより実装していくわけですが、いきなりディレクティブ化したりクラス化したりせずに、順を追って進めていくとします。
#1. コントローラと HTML 要素だけのシンプルな構成
HTML
まずはシンプルに View は全て HTML 側に記述し、コントローラだけを TypeScript (JS) 側で書いていくとします。
<div ng-app="app"> <div ng-controller="todoController"> <section class="panel panel-default"> <header class="panel-heading"> <div class="input-group"> <input type="text" class="form-control" ng-model="message" placeholder="ToDo ..." /> <span class="input-group-btn"> <button class="btn btn-primary" ng-click="addTodoItem(message)">Add</button> </span> </div> </header> <ul class="list-group"> <li class="list-group-item" ng-repeat="todoItem in todoItems"> <div class="list-group-item-inner"> <div class="item-wrapper"> <input type="checkbox" ng-model="todoItem.done" /> </div> <label class="done-{{todoItem.done}}" ng-dblclick="updateTodoItem(todoItem)">{{todoItem.message}}</label> <div class="item-wrapper"> <button class="btn btn-danger btn-xs" ng-click="removeTodoItem(todoItem.id)">×</button> </div> </div> </li> </ul> <footer class="panel-footer"> <span class="badge">{{remaining()}} / {{todoItems.length}}</span> Items left </footer> </section> </div> </div>
テキストインプットの入力値を message
というモデルとバインドします。Add ボタンをクリックして addTodoItem()
というメソッドを呼び出し、入力した message 値を引数として渡します。
登録済みの Todo は todoItems
という変数に配列で格納され、これをループ処理で1つずつ表示していきます。
チェックボックスにチェックを入れると todoItem.done
というモデルの値 (boolean) が切り替わり、ラベルのスタイルが動的に変化します。
ラベルをダブルクリックすると updateTodoItem()
というメソッドを呼びだし、Todo の編集ビューを表示します。
削除ボタンをクリックすると removeTodoItem()
というメソッドを呼び出し、そのTodoを一覧から削除します。
remaining()
という関数を呼び出し、todoItem.done が true
となっているTodoの数を取得します。
CSS (SCSS)
Bootstrap をベースにしつつ、調整用のスタイルを加えます。
// Import libs // -------------------- @import "../../node_modules/bootstrap-sass/assets/stylesheets/bootstrap"; // Variables // -------------------- $theme-primary: #DB1E21; // Scaffoldings .text-primary { color: $theme-primary; } // TODO // -------------------- .done-true { text-decoration: line-through; color: gray; font-style: italic; } .list-group-item-inner { display: flex; label { font-size: 18px; width: 100%; padding: 0 15px; } } .item-wrapper { > * { vertical-align: sub; } }
JavaScript (TypeScript)
ビューは全て HTML 側で定義したので、コントローラだけ記述します。
/// <reference path="./typings/tsd.d.ts" /> var app = angular.module('app', []); app.controller('todoController', ['$scope', ($scope) => { $scope.todoItems = []; $scope.message = ''; var index = 0; // todoItem を追加 $scope.addTodoItem = (msg) => { $scope.todoItems.push({ id: index, message: msg, done: false }); $scope.message = ''; index++; }; // todoItem を更新 $scope.updateTodoItem = (todoItem => { var message = window.prompt('変更', todoItem.message); if (message) { var t; for (var i = 0; i < $scope.todoItems.length; i++) { t = $scope.todoItems[i]; if (t.id == todoItem.id) { t.message = message; break; } } } }; // todoItem を削除 $scope.removeTodoItem = (id) => { var index = 1; var t; for (var i = 0; i < $scope.todoItems.length; i++) { t = $scope.todoItems[i]; if (t.id == id) { index = i; break; } } $scope.todoItems.splice(index, 1); }; // 完了アイテム数を取得 $scope.remaining = () => { var count = 0; $scope.todoItems.forEach((todo) => { count += todo.done; }); return count; }; }]);
$scope.updateTodoItem()
の引数に todoItem
を受け取って変更処理を行います。変更処理はお手軽という理由からとりあえず window.prompt()
を利用しておきます。
DEMO
#2. リスト部分をまるごとディレクティブ化する
全てのビューが HTML 側に記述されているので、ここからカスタムディレクティブ化していきます。まずはリスト部分をまるごと一つのディレクティブとして切り出します。
HTML
<div ng-app="app"> <div ng-controller="todoController"> <section class="panel panel-default"> <header class="panel-heading"> <div class="input-group"> <input type="text" class="form-control" ng-model="message" placeholder="ToDo ..." /> <span class="input-group-btn"> <button class="btn btn-primary" ng-click="addTodoItem(message)">Add</button> </span> </div> </header> <todo-list todo-items="todoItems"></todo-list> <footer class="panel-footer"> <span class="badge">{{remaining()}} / {{todoItems.length}}</span> Items left </footer> </section> </div> </div>
todoList
というカスタムディレクティブ (タグ)に置き換えました。todo-items
というディレクティブ (属性) に リストデータを受け取って Todo アイテムを一覧表示させます。
JavaScript (TypeScript)
todoList
というカスタムディレクティブを作成します。
app.directive('todoList', [()=> { return { restrict: 'EA', replace: true, scope: { todoItems: '=', }, template: '<ul class="list-group">' + '<li class="list-group-item" ng-repeat="todoItem in todoItems">' + '<div class="list-group-item-inner">'+ '<div class="item-wrapper"><input type="checkbox" ng-model="todoItem.done"></div>' + '<label class="done-{{todoItem.done}}" ng-dblclick="update($event, todoItem)">{{todoItem.message}}</label>'+ '<div class="item-wrapper">'+ '<button class="btn btn-xs btn-danger" ng-click="delete($event, todoItem.id)">×</button>'+ '</div>'+ '</div>'+ '</li>'+ '</ul>', link: (scope, iElement)=> { // todoItem を更新 scope.update = ($event, todoItem) => { var message = window.prompt('変更', todoItem.message); if (message) { var t; for (var i = 0; i < scope.todoItems.length; i++) { t = scope.todoItems[i]; if (t.id == todoItem.id) { t.message = message; break; } } } }; // todoItem を削除 scope.delete = ($event, itemId) => { var index = 1; var t; for (var i = 0; i < scope.todoItems.length; i++) { t = scope.todoItems[i]; if (t.id == itemId) { index = i; break; } } scope.todoItems.splice(index, 1); }; } } }]);
一覧表示や Todo アイテムの更新・削除処理を todoList ディレクティブに移しました。したがってコントローラには Todo アイテム追加と完了アイテム数を取得する処理だけが残ります。
app.controller('todoController', ['$scope', ($scope) => { $scope.todoItems = []; $scope.message = ''; var index = 0; // todoItem を追加 $scope.addTodoItem = (msg) => { $scope.todoItems.push({ id: index, message: msg, done: false }); $scope.message = ''; index++; }; // 完了アイテム数を取得 $scope.remaining = () => { var count = 0; $scope.todoItems.forEach((todo) => { count += todo.done; }); return count; }; }]);
todoItems モデル自体はコントローラ側で管理してます。そしてディレクティブに todo-items
という属性を定義してこれを scope の todoItems
というプロパティにデータバインディングで結びつけ、そこからコントローラの todoItems モデルの値を渡します。
DEMO
#3. リストアイテムを個別のディレクティブとして切り出す
Todo リストからリストアイテムを個別のディレクティブとして切り出します。
HTML
<div ng-app="app"> <div ng-controller="todoController"> <section class="panel panel-default"> <header class="panel-heading"> <div class="input-group"> <input type="text" class="form-control" ng-model="message" placeholder="ToDo ..." /> <span class="input-group-btn"> <button class="btn btn-primary" ng-click="addTodoItem(message)">Add</button> </span> </div> </header> <todo-list class="list-group"> <!-- [1] --> <todo-item todo="todoItem" ng-repeat="todoItem in todoItems"></todo-item> </todo-list> <footer class="panel-footer"> <span class="badge">{{remaining()}} / {{todoItems.length}}</span> Items left </footer> </section> </div> </div>
todoItem
というディレクティブを新規に作りました。このディレクティブに ng-repeat
でループ処理を行い、todoItem
オブジェクトを todo
属性に渡してリストアイテムを生成させます [1] 。
JavaScript (TypeScript)
コントローラ
追加処理はコントローラ側で定義されているのと、todoItems 自体もコントローラ側で管理されているので、やはり編集・削除処理もコントローラ側に移すことにします (スミマセン…)。
app.controller('todoController', ['$scope', function($scope) { var index = 0; $scope.todoItems = []; // todoItem を追加 $scope.addTodoItem = (msg) => { $scope.todoItems.push({ id: index, message: msg, done: false }); index++; $scope.message = ''; }; // todoItem を削除 this.removeTodoItem = function(todoItem) { var index = 0; var t; for (var i = 0; i < $scope.todoItems.length; i++) { t = $scope.todoItems[i]; if (t.id == todoItem.id) { index = i; break; } } $scope.todoItems.splice(index, 1); }; // 管理している todoItem の編集モードを全てキャンセルする this.cancelAll = function() { $scope.todoItems.forEach((todoItem) => { if (todoItem.isEditMode) { todoItem.cancel(); } }); }; // 完了アイテム数を取得 $scope.remaining = () => { var count = 0; $scope.todoItems.forEach((item) => { count += item.done; }); return count; }; }]);
TodoList ディレクティブ
次にディレクティブを書いていきますが、コントローラにある処理の呼び出しはディレクティブから行いたいので、ディレクティブにコントローラをインジェクト (DI)して処理を呼び出せるようにします。コントローラのインジェクトは todoList
ディレクティブに対して行います [1] 。
app.directive('todoList', () => { return { restrict: 'EA', replace: true, controller: 'todoController' // [1] } });
TodoItem ディレクティブ
そして todoItem ディレクティブを todoList ディレクティブに依存させます [2] 。require
で依存するディレクティブを指定すると、指定ディレクティブのコントローラを link
の引数として受け取ることが可能となり、コントローラに定義された処理を呼び出すことが出来るようになります [3] 。
app.directive('todoItem', () => { return { restrict: 'EA', require: '^todoList', // [2] replace: true, template: '<div class="list-group-item">'+ '<div class="list-group-item-inner" ng-hide="isEditMode">' + '<div class="item-wrapper"><input type="checkbox" ng-model="todo.done" /></div>'+ '<label class="done-{{todo.done}}" ng-dblclick="startEdit(todo)">{{todo.message}}</label>' + '<div class="item-wrapper"><button class="btn btn-danger btn-xs" ng-click="delete(todo)">×</button></div>' + '</div>'+ '<div ng-show="isEditMode">'+ '<input ng-model="todo.message" class="form-control input-sm" todo-focus ng-blur="updateTodoItem($event)" ng-keyup="updateTodoItem($event)" />' + '</div>'+ '</div>', scope: { todo: '=' }, link: (scope: any, element, attrs, TodoController) => { // [3] scope.isEditMode = false; // [4] // 編集モードの開始 scope.startEdit = (todo) => { TodoController.cancelAll(); scope.isEditMode = true; }; // 編集終了 scope.updateTodoItem = ($event) => { if ($event.type === 'keyup') { if ($event.which !== 13) return; } else if ($event.type !== 'blur') { return; } scope.isEditMode = false; $event.stopPropagation(); }; // // 編集キャンセル scope.cancel = () => { if (!scope.isEditMode) return; scope.isEditMode = false; }; // Todo アイテムを削除 scope.delete = (todo) => { TodoController.removeTodoItem(todo); }; } } });
また、これまで編集のUIに window.prompt()を使っていましたが、少々イケてないのでラベルをテキストインプットに差し替えるUIに変更します。isEditMode
というフラグを定義し、これの値に応じて通常モードと編集モードを切り替えます [4]。
TodoFocus ディレクティブ
さらに編集モードに切り替わると同時に対象のテキストインプットにフォーカスが当たるようにしたいので、 todoFocus
というカスタムディレクティブを新規に作成します。$watch()
で isEditMode の変更を監視し、変更されたらフォーカスが当たるようにするわけですが、普通に element[0].focus();
とするとするだけではビューが切り替わるよりも早く実行されてしまってフォーカスが当たらないので、 $timeout
モジュールを使ってタイミングを調整します [5] 。
app.directive('todoFocus', ($timeout) => { return { link: (scope: any, element, attrs)=> { scope.$watch('isEditMode', (newVal) => { // [5] $timeout(() => { element[0].focus(); }, 0, false); }); } } });
DEMO
#4. コントローラとディレクトリをクラス化して外部モジュール化する
ここまででだいぶ処理の分割が出来ましたが、せっかく TypeScript を使うのだから、コントローラとディレクティブをクラス化してしまいましょう。さらに外部モジュール化すればファイルを分割出来るので、アプリの規模が大きくなっても管理しやすくなり、処理の再利用性が高まります。
JavaScript (TypeScript)
処理の実態は先程の例で出来上がっているので、ここで行うのはクラス化するための修正程度です。
コントローラ
まずはコントローラですが、追加・削除・完了アイテム数の取得処理をもたせます。
class TodoController { private index = 0; constructor(private $scope: ITodoScope) { $scope.addTodoItem = angular.bind(this, this.addTodoItem); $scope.remaining = angular.bind(this, this.remaining); this.$scope.todoItems = []; } public addTodoItem(msg:string) { this.$scope.todoItems.push({ id: this.index, message: msg, done: false, isEditMode: false }); this.$scope.message = ''; this.index++; } public removeTodoItem(id: number) { var index = 0; for (var i = 0; i < this.$scope.todoItems.length; i++) { if (this.$scope.todoItems[i].id === id) { index = i; break; } } this.$scope.todoItems.splice(index, 1); } public remaining(): number { var count = 0; this.$scope.todoItems.forEach((todoItem) => { count += todoItem.done? 1 : 0; }); return count; } }
$scope
オブジェクトは通常 ng.IScope
というインターフェースで実装されていますが、todoItems
, addTodoItem()
, remaining()
を追加したいので、専用のインターフェースを定義します。
interface ITodoScope extends ng.IScope { todoItems: TodoItem[]; addTodoItem: Function; remaining: Function; message: string; }
また、todoItems は Todo アイテムの配列なわけですが、それ用のクラスとして TodoItem
クラスも定義します。
class TodoItem { id: number; message: string; done: boolean; isEditMode: boolean; }
TodoList ディレクティブ
こちらはそのままクラス化のお作法に則って書き直すだけでOKです。
class TodoListDirective implements ng.IDirective { public restrict: string; public controller: string; constructor() { this.restrict = 'EA'; this.controller = 'todoController'; } public static Factory(): ng.IDirectiveFactory { var directive = ()=> { return new TodoListDirective(); } directive.$inject = []; return directive; } }
TodoItem ディレクティブ
class TodoItemDirective implements ng.IDirective { public restrict: string; public replace: boolean; public require: string; public template: string; public link: (scope: ITodoItemDirectiveScope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes, todoController: TodoController) => void; constructor() { this.restrict = 'EA'; this.replace = true; this.require = '^todoList'; this.template = '<div class="list-group-item">' + '<div class="list-group-item-inner" ng-hide="isEditMode">' + '<div class="item-wrapper"><input type="checkbox" ng-model="todoItem.done" /></div>' + '<label class="done-{{todoItem.done}}" ng-dblclick="startEdit(todoItem.id)">{{todoItem.message}}</label>' + '<div class="item-wrapper"><button class="btn btn-danger btn-xs" ng-click="removeTodoItem(todoItem.id)">×</button></div>' + '</div>' + '<div ng-show="isEditMode">'+ '<input ng-model="todoItem.message" class="form-control input-sm" todo-focus ng-blur="updateTodoItem($event)" ng-keyup="updateTodoItem($event)" />' + '</div>'+ '</div>'; this.link = (scope: ITodoItemDirectiveScope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes, todoController: TodoController)=> { scope.isEditMode = false; // 編集モードの開始 scope.startEdit = (id) => { scope.isEditMode = true; }; // 編集終了 scope.updateTodoItem = ($event) => { if ($event.type === 'keyup') { if ($event.which !== 13) return; } else if ($event.type !== 'blur') { return; } scope.isEditMode = false; $event.stopPropagation(); }; // 編集キャンセル scope.cancelEdit = () => { if (!scope.isEditMode) return; scope.isEditMode = false; }; // Todo アイテムを削除 scope.removeTodoItem = (id) => { todoController.removeTodoItem(id); }; } } public static Factory(): ng.IDirectiveFactory { var directive = ()=> { return new TodoItemDirective(); } directive.$inject = []; return directive; } }
特に変わったことはしてませんが、先程の例では scope の型を any
としていたのに対してこの例ではITodoItemDirectiveScope
というカスタムインターフェース型としています。
interface ITodoItemDirectiveScope extends ng.IScope { isEditMode: boolean; startEdit: Function; updateTodoItem: Function; cancelEdit: Function; removeTodoItem: Function; }
TodoFocus ディレクティブ
$timeout モジュールを使うのでインジェクトする必要があります。TodoFocusDirective インスタンス作成 (コンストラクタ) 時に引数として渡し、さらにインスタンスの $inject
というプロパティを使ってアノテーション指定することでインジェクト出来ます。
class TodoFocusDirective implements ng.IDirective { public link: (scope: any, element: ng.IAugmentedJQuery, attrs: ng.IAttributes) => void; constructor($timeout) { this.link = (scope: any, element: ng.IAugmentedJQuery, attrs: ng.IAttributes) => { scope.$watch('isEditMode', (newVal) => { $timeout(() => { element[0].focus(); }, 0, false); }); } } public static Factory(): ng.IDirectiveFactory { var directive = ($timeout) => { return new TodoFocusDirective($timeout); } directive.$inject = ['$timeout']; return directive; } }
外部モジュール化
クラス化出来たら外部モジュールとしてひとつに纏めます。外部モジュール化する方法は、各クラスやインターフェースに export
キーワードを付け、モジュール名をファイル名として外部ファイルに保存します。今回は AngularTodo
という名前で外部モジュール化します。
export interface ITodoScope extends ng.IScope { ⋮ } export interface ITodoItemDirectiveScope extends ng.IScope { ⋮ } export class TodoItem { ⋮ } export class TodoController { ⋮ } export class TodoListDirective implements ng.IDirective { ⋮ } export class TodoItemDirective implements ng.IDirective { ⋮ } export class TodoFocusDirective implements ng.IDirective { ⋮ }
外部モジュールの読み込み
import
キーワードを使って外部モジュールを読み込みます。
/// <reference path="./typings/tsd.d.ts" /> import angular = require('angular'); var app = angular.module('app', []); import AngularTodo = require('./AngularTodo'); app.controller('todoController', ['$scope', AngularTodo.TodoController]); app.directive('todoList', AngularTodo.TodoListDirective.Factory()); app.directive('todoItem', AngularTodo.TodoItemDirective.Factory()); app.directive('todoFocus', AngularTodo.TodoFocusDirective.Factory());
なお、 require()
関数は Browserify や RequireJS 等を利用する必要があります。この辺りは以下の記事を参考にしてください。
おわりに
クラス化するにあたっての設計力がまだまだ貧弱ではありますが、今回のように分割出来たことで、自分なりに色々と拡張性の可能性を感じることが出来ました。
とりわけクラス化ディレクティブに 他モジュールをインジェクトする方法で少し苦労しましたが、この記事がどなたかの参考になれば幸いです。
※ コメントはこちらのに同意の上、投稿ください。