1. ビューを色々いじってみる

Angularの動作原理を大まかに理解できたところで、実際にコードを触っていきます。

まずはビュー(HTMLテンプレート)の基本機能を体験するために、 AppComponent のビューを色々いじってみましょう。

Hello, World! してみる

AppComponent のビューは src/app/app.component.html に書かれていましたね。まずはこのファイルの中身を Hello, World! と表示するだけの内容にしてみましょう。

<p>Hello, World!</p>

保存すると画面が自動でリロードされて

Hello, World!

と表示されますね。

コンポーネントの変数をビューに表示してみる

では次に、コンポーネント本体が持っている変数の値をビューに表示してみましょう。

src/app/app.component.tssrc/app/app.component.html を以下のように変更します。

- title = 'angular-todo';
+ tasks = [
+   {title: '牛乳を買う', done: false},
+   {title: '可燃ゴミを出す', done: true},
+   {title: '銀行に行く', done: false},
+ ];
- <p>Hello, World!</p>
+ <ul>
+   <li>{{ tasks[0].title }} <span>{{ tasks[0].done }}</span></li>
+   <li>{{ tasks[1].title }} <span>{{ tasks[1].done }}</span></li>
+   <li>{{ tasks[2].title }} <span>{{ tasks[2].done }}</span></li>
+ </ul>

{{ コンポーネントのクラス変数 }} で変数の値を出力することができます。

ちなみに、HTMLテンプレートから利用できるクラス変数は public なものに限られます。(アクセス修飾子を書かなければデフォルトで public になります)

これで、画面には以下のようなリストが表示されているはずです。

牛乳を買う false
可燃ゴミを出す true
銀行に行く false

*ngFor による繰り返し処理を使ってみる

さて、このままだとビューの記述がタスクの個数に依存してしまっているので、繰り返し処理を使った書き方に変えてみましょう。

AngularのHTMLテンプレートで繰り返し処理をするには、 *ngFor というディレクティブ(ビューで利用できる命令の一種)を使用します。

  <ul>
-   <li>{{ tasks[0].title }} <span>{{ tasks[0].done }}</span></li>
-   <li>{{ tasks[1].title }} <span>{{ tasks[1].done }}</span></li>
-   <li>{{ tasks[2].title }} <span>{{ tasks[2].done }}</span></li>
+   <li *ngFor="let task of tasks">{{ task.title }} <span>{{ task.done }}</span></li>
  </ul>

繰り返し出力したいDOM要素に *ngFor="let 要素の変数名 of 配列の変数名" と書くことで、そのDOM要素の内側で 要素の変数名 を使えるようになります。

コードを修正して、画面の表示が先ほどと変わっていなければOKです👌

*ngIf による条件分岐を使ってみる

さて、繰り返し処理を使ったので次は条件分岐を使ってみたいと思います。条件分岐には *ngIf ディレクティブを使います。

タスクが完了済みかどうかの true false をそのまま表示するのではなく、完了済みの場合にのみ [完了] と表示するようにしてみましょう。

  <ul>
-   <li *ngFor="let task of tasks">{{ task.title }} <span>{{ task.done }}</span></li>
+   <li *ngFor="let task of tasks">
+     <span *ngIf="task.done">[完了]</span>
+     {{ task.title }}
+   </li>
  </ul>

出力するかどうかを制御したいDOM要素に *ngIf="真偽値" と書くことで、 真偽値true の場合にのみDOM要素が出力されるようになります。

これで、画面の内容は以下のようなものになるはずです。

牛乳を買う
[完了] 可燃ゴミを出す
銀行に行く

コンポーネントのスタイルを書いてみる

次はコンポーネントのスタイルを書いてみましょう。

先ほど *ngIf を使って [完了] と出力するようにしてみましたが、やっぱり完了済みのタスクはスタイルで判別できるようにしてみることにします。

  <ul>
-   <li *ngFor="let task of tasks">
-     <span *ngIf="task.done">[完了]</span>
+   <li *ngFor="let task of tasks" class="{{ task.done ? 'done' : '' }}">
      {{ task.title }}
    </li>
  </ul>

こんなふうに、完了済みの場合のみ <li> 要素に done というクラスを付けておき、その上で src/app/app.component.scss に以下のようなスタイルを書きます。

.done {
  color: gray;
  text-decoration: line-through;
}

可燃ゴミを出す だけ字が薄くなって取り消し線が引かれた見た目になっていればOKです👌

2. 新しいタスクを追加できるようにしてみる

ビューを色々といじってみて、画面の作り方はなんとなくイメージできたかと思います。ここらで新しいタスクを追加する機能を実装してみましょう👍

ngModel を使って変数と入力欄をバインドする

ひとまず何も考えずに入力欄を追加してみましょう。

  <ul>
    <li *ngFor="let task of tasks" class="{{ task.done ? 'done' : '' }}">
      {{ task.title }}
    </li>
+   <li>
+     <input type="text">
+   </li>
  </ul>

当然ながらこれだけでは入力しても何も起こりませんね。

Angularで画面からの入力をコンポーネントのロジックに渡すには、 <input> 要素とコンポーネントのクラス変数を紐付ける(バインドする) という作業が必要です。

そのために使うのが ngModel ディレクティブです。

まずは実際に ngModel を使ったコードを見てみましょう。

  export class AppComponent {
    tasks = [
      {title: '牛乳を買う', done: false},
      {title: '可燃ゴミを出す', done: true},
      {title: '銀行に行く', done: false},
    ];
+
+   newTaskTitle = '';
  }
  <li>
-   <input type="text">
+   <input type="text" [(ngModel)]="newTaskTitle">
  </li>

AppComponent にクラス変数 newTaskTitle を追加して、ビュー側では <input> 要素に [(ngModel)]="newTaskTitle" という記述を足しました。

これで <input> 要素が newTaskTitle と紐付きます💪(※ただしこの時点ではまだ動作しません)

ngModel は、Angularにおいて 双方向データバインディング を実現するもっとも基本的な手段です。

双方向データバインディングとは、コンポーネント本体とビューの間でデータを同期する仕組みのことです。今回の例だと、画面上で <input> の値を書き換えるたびに newTaskTitle の値がリアルタイムで変更されることになります。

ちなみに、「双方向」というだけあって、逆にクラス内で newTaskTitle に何かを代入する処理を実行すると、画面側にもそれが反映されます👍

例えば、今回は newTaskTitle = ''; と空文字列を初期値として代入しているので <input> の値も空欄で初期化されますが、 newTaskTitle = 'test'; などと変更すれば <input> の内容も初めから test が入力されている状態になります。

ngModel を使うために、 FormsModule をインポートする

さて、先ほどコンポーネントに ngModel を使うコードを書き足しましたが、実はこの機能は ng new しただけの雛形アプリには含まれていません。

ngModel を利用するためには、Angular標準の FormsModule というモジュールを追加でインポートする必要があります💪

Angularには「モジュール」という機構があり、必要に応じて複数のモジュールを組み合わせてアプリを構築できるようになっていることにはすでに触れましたね。「モジュールを組み合わせる」と表現していましたが、より具体的には、 あるモジュールに他のモジュールをインポートする ことによってそれを実現します。

今回は、 AppModuleFormsModule をインポートする ことで、 AppModule 内で FormsModule が持っている ngModel というディレクティブを使えるようにしておく必要がある、ということにになります。

具体的には、 src/app/app.module.ts に以下のようなコードを追記すればOKです。

  import { BrowserModule } from '@angular/platform-browser';
  import { NgModule } from '@angular/core';
+ import { FormsModule } from '@angular/forms';
  
  import { AppComponent } from './app.component';
  
  @NgModule({
    declarations: [
      AppComponent
    ],
    imports: [
-     BrowserModule
+     BrowserModule,
+     FormsModule,
    ],
    providers: [],
    bootstrap: [AppComponent]
  })
  export class AppModule { }

@NgModule({}) の中の imports: [] という配列に FormsModule を追加しただけですね 👍

ngModel の振る舞いを確認してみる

ここまでで、無事に画面の入力欄から newTaskTitle 変数の値を操作できるようになっています。

と言われても、画面には入力欄そのものしか表示されていないので、変数の値が変化しているのかが分からないですね🤔

というわけで、とりあえず動作確認のために画面に newTaskTitle の値を表示するようにしてみましょう。

   <ul>
     <li *ngFor="let task of tasks" class="{{ task.done ? 'done' : '' }}">
       {{ task.title }}
     </li>
     <li>
       <input type="text" [(ngModel)]="newTaskTitle">
     </li>
   </ul>
+
+  newTaskTitleの値: {{ newTaskTitle }}

これで画面を操作してみると、下図のように newTaskTitle の値がリアルタイムで入力値と同期していることが分かります✨

入力した内容をタスクとして追加できるようにする

これで入力欄の用意はできました。

次は「追加」ボタンを設置して、クリックしたら新しいタスクをリストに追加するという処理を書いてみましょう💪

とりあえずボタンを追加してみます。

  <ul>
    <li *ngFor="let task of tasks" class="{{ task.done ? 'done' : '' }}">
      {{ task.title }}
    </li>
    <li>
      <input type="text" [(ngModel)]="newTaskTitle">
+     <button>追加</button>
    </li>
  </ul>
  
  newTaskTitleの値: {{ newTaskTitle }}

このボタンをクリックしたときに何か処理を実行する、ということができればよさそうですね。

これは イベントバインディング という機能を使うことで簡単に実現できます。

  <ul>
    <li *ngFor="let task of tasks" class="{{ task.done ? 'done' : '' }}">
      {{ task.title }}
    </li>
    <li>
      <input type="text" [(ngModel)]="newTaskTitle">
-     <button>追加</button>
+     <button (click)="addTask()">追加</button>
    </li>
  </ul>
  
  newTaskTitleの値: {{ newTaskTitle }}

このように、DOM要素に (click)="実行したい処理" を書くことで、要素がクリックされたときに処理を呼び出すことができます。

(click) 以外にも (change)(keyup) など DOMの標準イベント をバインドできます。

ここでは、クリック時に addTask() を実行するようにしました。なのでコンポーネント側に addTask() クラスメソッドを定義しましょう。

  import { Component } from '@angular/core';
  
  @Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.scss']
  })
  export class AppComponent {
    tasks = [
      {title: '牛乳を買う', done: false},
      {title: '可燃ゴミを出す', done: true},
      {title: '銀行に行く', done: false},
    ];
  
    newTaskTitle = '';
+
+   addTask() {
+     this.tasks.push({title: this.newTaskTitle, done: false});
+     this.newTaskTitle = '';
+   }
  }

これで、「追加」ボタンをクリックしたら newTaskTitle をタイトルとする新しいタスクが this.tasks の末尾に追加されます👍

ビューからクラス変数やクラスメソッドにアクセスするときは newTaskTitleaddTask() のように書けばよかったのに対し、コンポーネントクラス内でアクセスする場合は this.tasks this.newTasksthis. が必要なので注意しましょう。

動作を確認してみましょう。下図のようにタスクの追加ができるようになっていればOKです🙌

3. Todoアプリとして必要そうな機能を追加する

ngModel を使った双方向データバインディングと (click) のようなイベントバインディングを体験してきましたが、基本的なインタラクションはこの2つを使うだけでだいたい実装できます👍

というわけで、コンポーネント実装の基本的な流れが分かってきたところで、Todoアプリとして必要そうな機能をいくつか追加していくことにしましょう。

タイトルなしのタスクは登録できないようにする

とりあえず、現状だとタイトルを空欄のまま「追加」を押せばタイトルなしのタスクが登録できてしまってよろしくないので、何か入力しないと「追加」ボタンを押せないように対応しておきましょう。

Angularが持っている フォームのバリデーション機能 を使えば実現できます。

  <li>
-   <input type="text" [(ngModel)]="newTaskTitle">
-   <button (click)="addTask()">追加</button>
+   <input type="text" [(ngModel)]="newTaskTitle" #title="ngModel" required>
+   <button (click)="addTask()" [disabled]="title.invalid">追加</button>
  </li>

コードをこのように修正することで、タイトル入力欄が空欄だと「追加」ボタンが押せないようになるのですが、ちょっと難解ですよね。一つずつ見ていきましょう。

まず、 <input type="text">#title="ngModel" required" というコードが追記されています。

required はただのHTML5の属性ですが、 #title="ngModel" というのは初めて見る記述ですね。これは テンプレート参照変数 といって、あるDOM要素に #任意の名前 とマークすることで、他のDOM要素から 任意の名前 という変数名でそのDOM要素を参照できるようになるという代物です。

ここでは #title とマークすることで title というテンプレート参照変数を宣言し、さらにそのテンプレート参照変数に ngModel 自体を代入するということをしています。実はDOM要素がフォームコントロールの場合は、こうすることでそのテンプレート参照変数を通してフォームコントロールの状態(バリデーション結果など)にアクセスできるようになるのです。

これを理解した上で <button (click)="addTask()" [disabled]="title.invalid">追加</button> を見てみると、意味が分かりそうですね。

title.invalidtitle は、先ほどのテンプレート参照変数 title です。 title には ngModel を代入してあったので、フォームコントロールのバリデーション結果が invalid というプロパティから得られるようになっているわけですね。(バリデーションにエラーがあれば invalidtrue になります)

そして、 [disabled]="真偽値" によって、HTML5の disabled 属性を有効にするかどうかを 真偽値 の値に応じて切り替える、ということをしています。

まとめると、

  • タイトル入力欄には required 属性が付与されているので
  • ここが空欄だと title.invalidtrue になる
  • 「追加」ボタンは、 title.invalidtrue の場合に disabled 属性が付与されるので
  • タイトル入力欄が空欄だと「追加」ボタンが押せない

という実装になるわけですね👍

参考:Angular - Validating form input

タスクの完了・未完了を変更できるようにする

Todoアプリなら当然タスクの完了・未完了をチェックボックスで変更できるようにする必要があるでしょう。

これは、チェックボックス要素に ngModel を適用するだけで簡単に実装できます👍

  <li *ngFor="let task of tasks" class="{{ task.done ? 'done' : '' }}">
-   {{ task.title }}
+   <label>
+     <input type="checkbox" [(ngModel)]="task.done">
+     {{ task.title }}
+   </label>
  </li>

簡単ですね!

タスクに期日を設定できるようにする

このままだとちょっと機能的に寂しいので、タスクごとに期日を設定できるようにしてみたいと思います。

まずはコンポーネントクラスを以下のように修正します。

  export class AppComponent {
    tasks = [
-     {title: '牛乳を買う', done: false},
-     {title: '可燃ゴミを出す', done: true},
-     {title: '銀行に行く', done: false},
+     {title: '牛乳を買う', done: false, deadline: new Date('2021-01-01')},
+     {title: '可燃ゴミを出す', done: true, deadline: new Date('2021-01-02')},
+     {title: '銀行に行く', done: false, deadline: new Date('2021-01-03')},
    ];

-   newTaskTitle = '';
+   newTask = {
+     title: '',
+     deadline: new Date(),
+   };
  
    addTask() {
-     this.tasks.push({title: this.newTaskTitle, done: false});
-     this.newTaskTitle = '';
+     this.tasks.push({title: this.newTask.title, done: false, deadline: new Date(this.newTask.deadline)});
+     this.newTask = {
+       title: '',
+       deadline: new Date(),
+     };
    }
  }

タスクに deadline というプロパティを追加して、画面の入力値を入れておく箱も分かりやすいようにオブジェクトにしました。

これに合わせてビューも修正します。

  <ul>
    <li *ngFor="let task of tasks" class="{{ task.done ? 'done' : '' }}">
      <label>
        <input type="checkbox" [(ngModel)]="task.done">
        {{ task.title }}
+       (期日:{{ task.deadline|date:'yyyy/MM/dd' }})
      </label>
    </li>
    <li>
-     <input type="text" [(ngModel)]="newTaskTitle" #title="ngModel" required>
+     <input type="text" [(ngModel)]="newTask.title" #title="ngModel" required>
+     <input type="date" [(ngModel)]="newTask.deadline">
      <button (click)="addTask()" [disabled]="title.invalid">追加</button>
    </li>
  </ul>
  
- newTaskTitleの値: {{ newTaskTitle }}
+ newTaskの値: {{ newTask|json }}

task.deadline|date:'yyyy/MM/dd'newTask|json といった見慣れない記述が登場しましたね。

この | に続く datejsonパイプ と呼ばれるビュー命令の一種で、主にビュー上で変数の値を整形したり変換したりするためのものです。

標準でいくつかのパイプが提供されている ほか、danrevah/ngx-pipesfknop/angular-pipes といったOSSを導入すればさらに便利なパイプがたくさん利用できます。

date は日付データをフォーマットするためのパイプ、 json はオブジェクトをJSON文字列に変換して表示するためのAngular標準のパイプです。

これで、下図のように期日付きのタスクを登録できるようになりました👍

期日超過しているタスクを強調表示するようにする

せっかく期日を設定できるようにしたので、期日超過しているタスクを強調表示するようにしてみましょう。

  <label>
    <input type="checkbox" [(ngModel)]="task.done">
    {{ task.title }}
    (期日:{{ task.deadline|date:'yyyy/MM/dd' }})
+   <span *ngIf="isOverdue(task)" class="overdue">期日超過</span>
  </label>
  .done {
    color: gray;
    text-decoration: line-through;
  }
+
+ .overdue {
+   color: darkred;
+ }

このようにビューに 期日超過 と表示するための <span> 要素を追記して、その <span> 要素には *ngIfisOverdue(task) の戻り値が true のときにだけ表示されるよう設定します。

あとはその isOverdue() メソッドをコンポーネントクラスに追加すればOKですね。

  tasks = [
    {title: '牛乳を買う', done: false, deadline: new Date('2021-01-01')},
-   {title: '可燃ゴミを出す', done: true, deadline: new Date('2021-01-02')},
-   {title: '銀行に行く', done: false, deadline: new Date('2021-01-03')},
+   {title: '可燃ゴミを出す', done: true, deadline: new Date('2020-01-02')},
+   {title: '銀行に行く', done: false, deadline: new Date('2020-01-03')},
  ];

  // ...

  addTask() {
    this.tasks.push({title: this.newTask.title, done: false, deadline: new Date(this.newTask.deadline)});
    this.newTask = {
      title: '',
      deadline: new Date(),
    };
  }
+
+ isOverdue(task) {
+   return !task.done && task.deadline < (new Date()).setHours(0, 0, 0, 0);
+ }

ついでに動作確認をしやすくするためにタスクの初期データのうち2つを期日超過状態( deadline の値が2020年)に変更しました。

isOverdue() メソッドの実装は、

  • タスクが完了済みでなく
  • タスクに設定されている期日が「今日の0時0分0秒0ミリ秒」よりも以前である

という条件で true になる(期日超過と見なす)ようにしています。(参考: Date.prototype.setHours()

これで、下図のように、未完了かつ期日を超過しているタスクにのみ 期日超過 というラベルが表示されるようになりました。

だんだんTodoアプリっぽくなってきましたね!✨

4. コンポーネントを分けてみる

さて、ここまですべての処理を AppComponent の中に書いてきました。

これぐらいの規模のアプリならこのような実装でもさほど問題なさそうですが、本格的なアプリ開発をする際には UI部品ごとにコンポーネントに分けて、コンポーネント同士を連携させながらアプリを組み上げていく ということが必要になってきます。

なのでここらでコンポーネントを分ける練習をしておきましょう💪

現状このアプリが持っている機能を分解して考えてみると、

  • タスクリスト
  • タスクリストの1行
  • タスク追加フォーム

の3つぐらいに分けられそうです。ここではこの3つのコンポーネントに分けてみることにしましょう。

TaskListComponent を作る

まずタスクリストの実装を持つ TaskListComponent を作って、 AppComponent から分離してみましょう。

新しいコンポーネントを作る場合、手作業でファイルを作ってゼロからコードを書かなくても、 ng generate コマンドを使うことで雛形を生成することができます🙌

プロジェクト直下で以下のコマンドを実行してみてください。

ng generate component TaskList
# 実は
# ng g c TaskList
# と略記することもできます

src/app/task-list/ 配下に

  • task-list.component.ts (コンポーネントクラス)
  • task-list.component.html (ビュー)
  • task-list.component.scss (スタイル)
  • task-list.component.spec.ts (テスト)

の4ファイルが生成され、さらに src/app/app.module.ts が以下のように変更されていると思います。

  import { BrowserModule } from '@angular/platform-browser';
  import { NgModule } from '@angular/core';
  import { FormsModule } from '@angular/forms';
  
  import { AppComponent } from './app.component';
+ import { TaskListComponent } from './task-list/task-list.component';
  
  @NgModule({
    declarations: [
      AppComponent,
+     TaskListComponent
    ],
    imports: [
      BrowserModule,
      FormsModule,
    ],
    providers: [],
    bootstrap: [AppComponent]
  })
  export class AppModule { }

コンポーネントの各種ファイルを生成して、 AppModule への登録まで自動で行ってくれたわけですね。便利!

では、先ほどまで AppComponent に書いていたコードを新たに生成された TaskListComponent に移してみましょう。

AppComponent

  import { Component } from '@angular/core';
  
  @Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.scss']
  })
  export class AppComponent {
-   tasks = [
-     {title: '牛乳を買う', done: false, deadline: new Date('2021-01-01')},
-     {title: '可燃ゴミを出す', done: true, deadline: new Date('2020-01-02')},
-     {title: '銀行に行く', done: false, deadline: new Date('2020-01-03')},
-   ];
- 
-   newTask = {
-     title: '',
-     deadline: new Date(),
-   };
- 
-   addTask() {
-     this.tasks.push({title: this.newTask.title, done: false, deadline: new Date(this.newTask.deadline)});
-     this.newTask = {
-       title: '',
-       deadline: new Date(),
-     };
-   }
- 
-   isOverdue(task) {
-     return !task.done && task.deadline < (new Date()).setHours(0, 0, 0, 0);
-   }
  }
- <ul>
-   <li *ngFor="let task of tasks" class="{{ task.done ? 'done' : '' }}">
-     <label>
-       <input type="checkbox" [(ngModel)]="task.done">
-       {{ task.title }}
-       (期日:{{ task.deadline|date:'yyyy/MM/dd' }})
-       <span *ngIf="isOverdue(task)" class="overdue">期日超過</span>
-     </label>
-   </li>
-   <li>
-     <input type="text" [(ngModel)]="newTask.title">
-     <input type="date" [(ngModel)]="newTask.deadline">
-     <button (click)="addTask()">追加</button>
-   </li>
- </ul>
- 
- newTaskの値: {{ newTask|json }}
+ <app-task-list></app-task-list>
- .done {
-   color: gray;
-   text-decoration: line-through;
- }
- 
- .overdue {
-   color: darkred;
- }

TaskListComponent

  import { Component, OnInit } from '@angular/core';
  
  @Component({
    selector: 'app-task-list',
    templateUrl: './task-list.component.html',
    styleUrls: ['./task-list.component.scss']
  })
  export class TaskListComponent implements OnInit {
  
    constructor() { }
  
+   tasks = [
+     {title: '牛乳を買う', done: false, deadline: new Date('2021-01-01')},
+     {title: '可燃ゴミを出す', done: true, deadline: new Date('2020-01-02')},
+     {title: '銀行に行く', done: false, deadline: new Date('2020-01-03')},
+   ];
+ 
+   newTask = {
+     title: '',
+     deadline: new Date(),
+   };
+ 
    ngOnInit(): void {
    }
  
+   addTask() {
+     this.tasks.push({title: this.newTask.title, done: false, deadline: new Date(this.newTask.deadline)});
+     this.newTask = {
+       title: '',
+       deadline: new Date(),
+     };
+   }
+ 
+   isOverdue(task) {
+     return !task.done && task.deadline < (new Date()).setHours(0, 0, 0, 0);
+   }
  }
- <p>task-list works!</p>
+ <ul>
+   <li *ngFor="let task of tasks" class="{{ task.done ? 'done' : '' }}">
+     <label>
+       <input type="checkbox" [(ngModel)]="task.done">
+       {{ task.title }}
+       (期日:{{ task.deadline|date:'yyyy/MM/dd' }})
+       <span *ngIf="isOverdue(task)" class="overdue">期日超過</span>
+     </label>
+   </li>
+   <li>
+     <input type="text" [(ngModel)]="newTask.title">
+     <input type="date" [(ngModel)]="newTask.deadline">
+     <button (click)="addTask()">追加</button>
+   </li>
+ </ul>
+ 
+ newTaskの値: {{ newTask|json }}
+ .done {
+   color: gray;
+   text-decoration: line-through;
+ }
+ 
+ .overdue {
+   color: darkred;
+ }

ほとんどコピペしただけですが、唯一のポイントは src/app/app.component.html に書いた

<app-task-list></app-task-list>

これです。

導入編のコードリーディングで、 AppComponentselector に書かれている 'app-root' に対応して index.html 内の <app-root></app-root> の箇所に AppComponent のレンダリング結果が挿入されるという関係を紐解いたことを覚えているでしょうか。

今回もまったく同じで、自動生成された TaskListComponentselector のところには 'app-task-list' と書かれています。つまり、このコンポーネントを別のビューに挿入したい場合は <app-task-list></app-task-list> という要素として設置すればよいというわけです。

今回はもともと AppComponent に書いていたHTMLを丸ごと TaskListComponent に移動したので、 AppComponent のビューには <app-task-list></app-task-list> だけを書いておけば、そこに TaskListComponent の中身が丸ごと展開される結果になります。

この時点で一度動作を確認してみてください。先ほどまでと何ら変わらない動作になっていれば正常にコンポーネントの分割ができている証拠です👍

TaskListItemComponent を作る

では、先ほどと同じように

ng generate component TaskListItem

TaskListItemComponent を作成して、 TaskListComponent のコードから「タスクリストの1行」の実装に関する部分を TaskListItemComponent に移してみましょう。

TaskListComponent

- isOverdue(task) {
-   return !task.done && task.deadline < (new Date()).setHours(0, 0, 0, 0);
- }
  <ul>
-   <li *ngFor="let task of tasks" class="{{ task.done ? 'done' : '' }}">
-     <label>
-       <input type="checkbox" [(ngModel)]="task.done">
-       {{ task.title }}
-       (期日:{{ task.deadline|date:'yyyy/MM/dd' }})
-       <span *ngIf="isOverdue(task)" class="overdue">期日超過</span>
-     </label>
-   </li>
+   <li *ngFor="let task of tasks">
+     <app-task-list-item [task]="task"></app-task-list-item>
+   </li>
    <li>
      <input type="text" [(ngModel)]="newTask.title">
      <input type="date" [(ngModel)]="newTask.deadline">
      <button (click)="addTask()">追加</button>
    </li>
  </ul>
  
  newTaskの値: {{ newTask|json }}
- .done {
-   color: gray;
-   text-decoration: line-through;
- }
- 
- .overdue {
-   color: darkred;
- }

TaskListItemComponent

- import { Component, OnInit } from '@angular/core';
+ import { Component, Input, OnInit } from '@angular/core';
  
  @Component({
    selector: 'app-task-list-item',
    templateUrl: './task-list-item.component.html',
    styleUrls: ['./task-list-item.component.scss']
  })
  export class TaskListItemComponent implements OnInit {
  
    constructor() { }
  
+   @Input() task;
+ 
    ngOnInit(): void {
    }
  
+   isOverdue(task) {
+     return !task.done && task.deadline < (new Date()).setHours(0, 0, 0, 0);
+   }
  }
- <p>task-list-item works!</p>
+ <label class="{{ task.done ? 'done' : '' }}">
+   <input type="checkbox" [(ngModel)]="task.done">
+   {{ task.title }}
+   (期日:{{ task.deadline|date:'yyyy/MM/dd' }})
+   <span *ngIf="isOverdue(task)" class="overdue">期日超過</span>
+ </label>
+ .done {
+   color: gray;
+   text-decoration: line-through;
+ }
+ 
+ .overdue {
+   color: darkred;
+ }

今回もほとんどコピペなのですが、 class="{{ task.done ? 'done' : '' }}" を付加する対象のDOM要素を <li> からその配下の <label> に変更してあります。( <li> までが TaskListComponent の関心で、その中身を TaskListItemComponent に移譲するという関係にしたかったので)

また、今回初めて見るコードが2つほど登場していました。

  • src/app/task-list.component.html<app-task-list-item [task]="task"></app-task-list-item>
  • src/app/task-list-item.component.ts@Input() task;

これらは2つでセットになっていて、

  • TaskListComponent から TaskListItemComponenttask クラス変数に対して、(自分のクラス変数である) task を渡す
  • TaskListItemComponent はクラス変数 task を宣言し、これに @Input() デコレーター を付けることで親コンポーネントからデータを受け取れるようにする

ということをしています。

この [相手の変数名]="自分のデータ" で親コンポーネントから子コンポーネントへデータを受け渡す機能のことを、単に「データバインディング」、あるいは「双方向データバインディング」と対比して「単方向データバインディング」などと呼んだりします。

ちなみに、ここまでで 単方向データバインディング イベントバインディング 双方向データバインディング の3種類のバインディングが登場しましたが、それぞれビューにおける記法は [] () [()] となっていました。初めて [(ngModel)] を見たときは 「何だこの難解な記法は!」 と思ったと思うのですが😅、こうして3種類出揃ってみると []() の両方の性質を兼ね備えている(双方向)という意味があったのだと分かりますね。

これら3種類のバインディングの対比は、こちらのドキュメント に記載されている下表が分かりやすいので参考までに貼っておきます。

TaskFormComponent を作る

最後も同様に

ng generate component TaskForm

TaskFormComponent を作成して、 TaskListComponent のコードから「タスク追加フォーム」の実装に関する部分を TaskFormComponent に移してみましょう。

TaskListComponent

  export class TaskListComponent implements OnInit {
  
    constructor() { }
  
    tasks = [
      {title: '牛乳を買う', done: false, deadline: new Date('2021-01-01')},
      {title: '可燃ゴミを出す', done: true, deadline: new Date('2020-01-02')},
      {title: '銀行に行く', done: false, deadline: new Date('2020-01-03')},
    ];
  
-   newTask = {
-     title: '',
-     deadline: new Date(),
-   };
-  
    ngOnInit(): void {
    }
  
-   addTask() {
-     this.tasks.push({title: this.newTask.title, done: false, deadline: new Date(this.newTask.deadline)});
-     this.newTask = {
-       title: '',
-       deadline: new Date(),
-     };
-   }
+   addTask(task) {
+     this.tasks.push(task);
+   }
  }
  <ul>
    <li *ngFor="let task of tasks">
      <app-task-list-item [task]="task"></app-task-list-item>
    </li>
    <li>
-     <input type="text" [(ngModel)]="newTask.title">
-     <input type="date" [(ngModel)]="newTask.deadline">
-     <button (click)="addTask()">追加</button>
+     <app-task-form (addTask)="addTask($event)"></app-task-form>
    </li>
  </ul>
-
- newTaskの値: {{ newTask|json }}

TaskFormComponent

- import { Component, OnInit } from '@angular/core';
+ import { Component, EventEmitter, OnInit, Output } from '@angular/core';
  
  @Component({
    selector: 'app-task-form',
    templateUrl: './task-form.component.html',
    styleUrls: ['./task-form.component.scss']
  })
  export class TaskFormComponent implements OnInit {
  
    constructor() { }
  
+   @Output() addTask = new EventEmitter();
+
+   newTask = {
+     title: '',
+     deadline: new Date(),
+   };
+
    ngOnInit(): void {
    }
  
+   submit() {
+     this.addTask.emit({title: this.newTask.title, done: false, deadline: new Date(this.newTask.deadline)});
+     this.newTask = {
+       title: '',
+       deadline: new Date(),
+     };
+   }
  }
- <p>task-form works!</p>
+ <input type="text" [(ngModel)]="newTask.title">
+ <input type="date" [(ngModel)]="newTask.deadline">
+ <button (click)="submit()">追加</button>
+ <br>newTaskの値: {{ newTask|json }}

最後はまたちょっと難しいコードが色々登場しましたね。 解説していきます。

今回初めて見るコードは以下の箇所かと思います。

  • <app-task-form (addTask)="addTask($event)"></app-task-form>
  • @Output() addTask = new EventEmitter();
  • this.addTask.emit({title: this.newTask.title, done: false, deadline: new Date(this.newTask.deadline)});

一見難しそうに見えますが、

<app-task-form (addTask)="addTask($event)"></app-task-form>

このコードはもともとの

<button (click)="addTask()">追加</button>

ととてもよく似ていますよね。もともとが「 click イベントの発火に合わせて addTask() を実行する」という処理だったのが、「 addTask イベントの発火に合わせて addTask($event) を実行する」に変わっているだけです。

つまり、 TaskFormComponentaddTask というカスタムイベントを持っていて、「追加」ボタンがクリックされたときにそのイベントを発火してくれるようになっているのです。( $event については後述します)

TaskFormComponent 側でそのカスタムイベントを作成しているのが、

@Output() addTask = new EventEmitter();

このコードです。

EventEmitter クラスのインスタンスを代入したクラス変数 addTask を宣言しており、これがカスタムイベントの発火装置になります。これに @Output() デコレーター を付けることで、親コンポーネントに対してイベントを受け渡せるようにしているイメージです。

そして最後に

this.addTask.emit({title: this.newTask.title, done: false, deadline: new Date(this.newTask.deadline)});

このコードが、実際にイベントを発火しています。 EventEmitter クラスの emit() メソッドを呼ぶことでイベントを発火させ、その際に {title: this.newTask.title, done: false, deadline: new Date(this.newTask.deadline)} というオブジェクトをイベントにパラメータとして添付しています。

この添付したパラメータは、親コンポーネント側で $event として受け取ることができます。

なので、

<app-task-form (addTask)="addTask($event)"></app-task-form>

このコードでタスクをリストに追加するという操作が可能だったわけです。

5. 型安全なコードにする

今さらですが、AngularではTypeScriptを使ってコードを書きます。(Angular自体もTypeScriptで書かれています)

せっかくTypeScriptを採用しているのに、ここまでに書いてきたコードでは 型注釈 をまったく使ってきませんでした。

というわけで、ここらでTypeScriptの強みを生かした型安全なコードにグレードアップさせておきましょう💪

Task インターフェースを定義する

数値や文字列などのプリミティブ型だけでなく、自分で定義したインターフェースも型として利用できます。今回のアプリでは「タスク」が重要な構造を持っているので、これをインターフェースとして定義しておくことにしましょう。

src/models/task.ts というファイルを新しく作って、以下のような内容を書いてください。

export interface Task {
  title: string;
  done: boolean;
  deadline: Date;
}

既存のコードに型注釈を付ける

これで Task 型が定義できたので、既存のコードに型注釈を付けていきましょう。

src/app/task-list/task-list.component.ts

  import { Component, OnInit } from '@angular/core';
+ import { Task } from '../../models/task';

  // ...
 
-   tasks = [
+   tasks: Task[] = [
      {title: '牛乳を買う', done: false, deadline: new Date('2021-01-01')},
      {title: '可燃ゴミを出す', done: true, deadline: new Date('2020-01-02')},
      {title: '銀行に行く', done: false, deadline: new Date('2020-01-03')},
    ];

    // ...
 
-   addTask(task) {
+   addTask(task: Task): void {
      this.tasks.push(task);
    }

src/app/task-list-item/task-list-item.component.ts

  import { Component, Input, OnInit } from '@angular/core';
+ import { Task } from '../../models/task';

  // ...
 
-   @Input() task;
+   @Input() task: Task;

    // ...
 
-   isOverdue(task) {
-     return !task.done && task.deadline < (new Date()).setHours(0, 0, 0, 0);
+   isOverdue(task: Task): boolean {
+     return !task.done && task.deadline.getTime() < (new Date()).setHours(0, 0, 0, 0);
    }

src/app/task-form/task-form.component.ts

  import { Component, EventEmitter, OnInit, Output } from '@angular/core';
+ import { Task } from '../../models/task';

  // ...
 
-   @Output() addTask = new EventEmitter();
+   @Output() addTask = new EventEmitter<Task>();

  // ..
-   submit(): {
+   submit(): void {

差し当たりこんなところでしょうか。

型注釈を付け加える以外に、一箇所

isOverdue(task: Task): boolean {
  return !task.done && task.deadline.getTime() < (new Date()).setHours(0, 0, 0, 0);
}

この部分のコードについて以下のように修正を加えました。

- task.deadline < (new Date()).setHours(0, 0, 0, 0);
+ task.deadline.getTime() < (new Date()).setHours(0, 0, 0, 0);

元のコードだと Date 型と数値型(Date.prototype.setHours() の戻り値)を比較してしまっていてコンパイルエラーになったので、きちんと数値同士の比較になるように修正しました。早速、型の恩恵に預かることができましたね🙏

期日なしのタスクも登録できるようにインターフェースを修正する

現状だと、期日入力欄を空欄のままタスクを追加すると「現在日時」が設定されるようになっています。これは仕様として微妙なので、期日なしのタスクも登録できるようにしましょう。

まずはインターフェースを修正しましょう。 deadline プロパティに ? を付けてnullableにします。

  export interface Task {
    title: string;
    done: boolean;
-   deadline: Date;
+   deadline?: Date;
  }

その上で、期日が入力されなかったときは現在日時ではなく null をセットするように、また期日に null が入っていることを考慮するように、コードを修正します。

src/app/task-list-item/task-list-item.component.ts

  isOverdue(task: Task): boolean {
-   return !task.done && task.deadline.getTime() < (new Date()).setHours(0, 0, 0, 0);
+   return !task.done && task.deadline && task.deadline.getTime() < (new Date()).setHours(0, 0, 0, 0);
  }

src/app/task-list-item/task-list-item.component.html

  <label class="{{ task.done ? 'done' : '' }}">
    <input type="checkbox" [(ngModel)]="task.done">
    {{ task.title }}
-   (期日:{{ task.deadline|date:'yyyy/MM/dd' }})
+   <span *ngIf="task.deadline">(期日:{{ task.deadline|date:'yyyy/MM/dd' }})</span>
    <span *ngIf="isOverdue(task)" class="overdue">期日超過</span>
  </label>

src/app/task-form/task-form.component.ts

  newTask = {
    title: '',
-   deadline: new Date(),
+   deadline: null,
  };

  // ...

  submit(): void {
-   this.addTask.emit({title: this.newTask.title, done: false, deadline: new Date(this.newTask.deadline)});
+   this.addTask.emit({
+     title: this.newTask.title,
+     done: false,
+     deadline: this.newTask.deadline ? new Date(this.newTask.deadline) : null,
+   });
    this.newTask = {
      title: '',
-     deadline: new Date(),
+     deadline: null,
    };
  }

これで、下図のとおり期日なしのタスクも登録できるようになりました👍

型注釈を付けておけば、このようなデータ構造の変更も安心して行うことができますね。