LoginSignup
2
1

More than 1 year has passed since last update.

基礎編 Angular実践入門チュートリアル

Posted at

背景

業務で Angular を使っているが、基本的な動かし方を理解仕切れてない。
そのため、簡単なアプリを作りながら、 Angular がどのように動いているのか理解しようと思う。
その学習の過程で学んだことを、メモとして残していく。

参考資料

Angular実践入門チュートリアル

基礎編

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

Hello, World! してみる

src/app/app.component.html
<p>Hello, World!</p>

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

src/app/app.component.ts
- title = 'angular-todo';
+ tasks = [
+   {title: '牛乳を買う', done: false},
+   {title: '可燃ゴミを出す', done: true},
+   {title: '銀行に行く', done: false},
+ ];
src/app/app.component.html
- <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>

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

src/app/app.component.html
 <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 要素の内側で 要素の変数名 を使えるようになります。

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

src/app/app.component.html
 <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 要素が出力されるようになります。

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

src/app/app.component.html
 <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>
src/app/app.component.scss
.done {
  color: gray;
  text-decoration: line-through;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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';

 @NgModule({
   declarations: [
     AppComponent
   ],
   imports: [
-     BrowserModule
+     BrowserModule,
+     FormsModule,
   ],
   providers: [],
   bootstrap: [AppComponent]
 })
 export class AppModule { }

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

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

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

src/app/app.component.html
 <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 }}

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

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

src/app/app.component.html
 <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() クラスメソッドを定義しましょう。

src/app/app.component.ts
 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 = '';
+   }
 }

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

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

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

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

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

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

src/app/app.component.html
 <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>

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

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 属性が付与されるので
  • タイトル入力欄が空欄だと「追加」ボタンが押せない

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

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

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

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

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

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

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

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

src/app/app.component.ts
 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 というプロパティを追加して、画面の入力値を入れておく箱も分かりやすいようにオブジェクトにしました。

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

src/app/app.component.html
 <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-pipes や fknop/angular-pipes といった OSS を導入すればさらに便利なパイプがたくさん利用できます。

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

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

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

 .overdue {
   color: darkred;
 }

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

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

src/app/app.component.ts
   {title: '牛乳を買う', done: false, deadline: new Date('2022-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 コマンドを使うことで雛形を生成することができます。

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

bash
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 が以下のように変更されていると思います。

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

src/app/app.component.ts
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);
-   }
 }
src/app/app.component.html
- <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>
src/app/app.component.scss
- .done {
-   color: gray;
-   text-decoration: line-through;
- }
- 
- .overdue {
-   color: darkred;
- }

TaskListComponent

src/app/task-list/task-list.component.ts
 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);
+   }
 }
src/app/task-list/task-list.component.html
- <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 に書いた

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 を作る

bash
ng generate component TaskListItem

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

TaskListComponent

src/app/task-list/task-list.component.ts
- isOverdue(task) {
-   return !task.done && task.deadline < (new Date()).setHours(0, 0, 0, 0);
- }
src/app/task-list/task-list.component.html
 <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 }}
src/app/task-list/task-list.component.scss
- .done {
-   color: gray;
-   text-decoration: line-through;
- }
- 
- .overdue {
-   color: darkred;
- }

TaskListItemComponent

src/app/task-list-item/task-list-item.component.ts
- 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);
+   }
 }
src/app/task-list-item/task-list-item.component.html
- <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>
src/app/task-list-item/task-list-item.component.scss
+ .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 を作る

bash
ng generate component TaskForm

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

TaskListComponent

src/app/task-list/task-list.component.ts
 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);
+   }
 }
src/app/task-list/task-list.component.html
 <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

src/app/task-form/task-form.component.ts
- 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(),
+     };
+   }
 }
src/app/task-form/task-form.component.html
- <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 = {
+     title:    "",
+     done:     false,
+     deadline: new Date()
+   };
   // ...

-   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() の戻り値)を比較してしまっていてコンパイルエラーになったので、きちんと数値同士の比較になるように修正しました。早速、型の恩恵に預かることができましたね。

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

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

まずはインターフェースを修正しましょう。

src/models/task.ts
 export interface Task {
   title: string;
   done: boolean;
-   deadline: Date;
+   deadline: Date|null;
 }

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

src/app/task-list-item/task-list-item.component.ts
  @Input() task: Task = {
    title:    "",
    done:     false,
-    deadline: new Date()
+    deadline: null
  };
// ...
 isOverdue(task: Task): boolean {
+  if (task.deadline == null) return false
   return !task.done && 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: 0,
 };

 // ...

 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: Number(this.newTask.deadline) ? new Date(this.newTask.deadline) : null,
+   });
   this.newTask = {
     title: '',
-     deadline: new Date(),
+     deadline: 0,
   };
 }

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

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

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1