背景
業務で Angular を使っているが、基本的な動かし方を理解仕切れてない。
そのため、簡単なアプリを作りながら、 Angular がどのように動いているのか理解しようと思う。
その学習の過程で学んだことを、メモとして残していく。
参考資料
基礎編
1. ビューを色々いじってみる
Hello, World! してみる
<p>Hello, World!</p>
コンポーネントの変数をビューに表示してみる
- 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>
*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 要素の内側で 要素の変数名
を使えるようになります。
*ngIf
による条件分岐を使ってみる
<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 要素が出力されるようになります。
コンポーネントのスタイルを書いてみる
<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>
.done {
color: gray;
text-decoration: line-through;
}
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
ディレクティブです。
export class AppComponent {
tasks = [
{title: '牛乳を買う', done: false},
{title: '可燃ゴミを出す', done: true},
{title: '銀行に行く', done: false},
];
+
+ newTaskTitle = '';
}
<li>
- <input type="text">
+ <input type="text" [(ngModel)]="newTaskTitle">
</li>
ngModel
は、 Angular において 双方向データバインディング を実現するもっとも基本的な手段です。
双方向データバインディングとは、コンポーネント本体とビューの間でデータを同期する仕組みのことです。今回の例だと、画面上で <input>
の値を書き換えるたびに newTaskTitle
の値がリアルタイムで変更されることになります。
ngModel
を使うために、 FormsModule
をインポートする
さて、先ほどコンポーネントに ngModel
を使うコードを書き足しましたが、実はこの機能は ng new
しただけの雛形アプリには含まれていません。
ngModel
を利用するためには、 Angular 標準の FormsModule
というモジュールを追加でインポートする必要があります。
Angular には「モジュール」という機構があり、必要に応じて複数のモジュールを組み合わせてアプリを構築できるようになっていることにはすでに触れましたね。「モジュールを組み合わせる」と表現していましたが、より具体的には、 あるモジュールに他のモジュールをインポートする ことによってそれを実現します。
今回は、 AppModule
に FormsModule
をインポートする ことで、 AppModule
内で FormsModule
が持っている ngModel
というディレクティブを使えるようにしておく必要がある、ということにになります。
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 の振る舞いを確認してみる
<ul>
<li *ngFor="let task of tasks" class="{{ task.done ? 'done' : '' }}">
{{ task.title }}
</li>
<li>
<input type="text" [(ngModel)]="newTaskTitle">
</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>
</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 = '';
+ }
}
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>
コードをこのように修正することで、タイトル入力欄が空欄だと「追加」ボタンが押せないようになるのですが、ちょっと難解ですよね。一つずつ見ていきましょう。
required
はただの HTML5 の属性ですが、 #title="ngModel"
というのは初めて見る記述ですね。これは テンプレート参照変数 といって、ある DOM 要素に #任意の名前
とマークすることで、他の DOM 要素から 任意の名前
という変数名でその DOM 要素を参照できるようになるという代物です。
ここでは #title
とマークすることで title
というテンプレート参照変数を宣言し、さらにそのテンプレート参照変数に ngModel
自体を代入するということをしています。実はDOM
要素がフォームコントロールの場合は、こうすることでそのテンプレート参照変数を通してフォームコントロールの状態(バリデーション結果など)にアクセスできるようになるのです。
これを理解した上で <button (click)="addTask()" [disabled]="title.invalid">追加</button>
を見てみると、意味が分かりそうですね。
title.invalid
の title
は、先ほどのテンプレート参照変数 title
です。 title
には ngModel
を代入してあったので、フォームコントロールのバリデーション結果が invalid
というプロパティから得られるようになっているわけですね。(バリデーションにエラーがあれば invalid
が true
になります)
そして、 [disabled]="真偽値"
によって、 HTML5 の disabled
属性を有効にするかどうかを 真偽値
の値に応じて切り替える、ということをしています。
まとめると、
- タイトル入力欄には
required
属性が付与されているので - ここが空欄だと
title.invalid
がtrue
になる - 「追加」ボタンは、
title.invalid
がtrue
の場合にdisabled
属性が付与されるので - タイトル入力欄が空欄だと「追加」ボタンが押せない
という実装になるわけですね。
タスクの完了・未完了を変更できるようにする
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` といった見慣れない記述が登場しましたね。
この `|` に続く `date` や `json` は <strong>[パイプ](https://angular.io/guide/pipes)</strong> と呼ばれるビュー命令の一種で、主にビュー上で変数の値を整形したり変換したりするためのものです。
[標準でいくつかのパイプが提供されている](https://angular.io/api?type=pipe) ほか、danrevah/ngx-pipes や fknop/angular-pipes といった OSS を導入すればさらに便利なパイプがたくさん利用できます。
`date` は日付データをフォーマットするためのパイプ、 `json` はオブジェクトを JSON 文字列に変換して表示するための Angular 標準のパイプです。
### 期日超過しているタスクを強調表示するようにする
``````html: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>
```
```scss:src/app/app.component.scss
.done {
color: gray;
text-decoration: line-through;
}
.overdue {
color: darkred;
}
```
このようにビューに `期日超過` と表示するための `<span>` 要素を追記して、その `<span>` 要素には `*ngIf` で `isOverdue(task)` の戻り値が `true` のときにだけ表示されるよう設定します。
あとはその `isOverdue()` メソッドをコンポーネントクラスに追加すればOKですね。
```typescript: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` の中に書いてきました。
これぐらいの規模のアプリならこのような実装でもさほど問題なさそうですが、本格的なアプリ開発をする際には <strong> UI 部品ごとにコンポーネントに分けて、コンポーネント同士を連携させながらアプリを組み上げていく</strong> ということが必要になってきます。
なのでここらでコンポーネントを分ける練習をしておきましょう。
現状このアプリが持っている機能を分解して考えてみると、
- タスクリスト
- タスクリストの1行
- タスク追加フォーム
の3つぐらいに分けられそうです。ここではこの3つのコンポーネントに分けてみることにしましょう。
### `TaskListComponent` を作る
まずタスクリストの実装を持つ `TaskListComponent` を作って、 `AppComponent` から分離してみましょう。
新しいコンポーネントを作る場合、手作業でファイルを作ってゼロからコードを書かなくても、 `ng generate` コマンドを使うことで雛形を生成することができます。
プロジェクト直下で以下のコマンドを実行してみてください。
```bash: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` が以下のように変更されていると思います。
```typescript: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
```typescript: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);
- }
}
```
```html: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>
```
```scss:src/app/app.component.scss
- .done {
- color: gray;
- text-decoration: line-through;
- }
-
- .overdue {
- color: darkred;
- }
```
#### TaskListComponent
```typescript: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);
+ }
}
```
```html: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 }}
```
```scss:
+ .done {
+ color: gray;
+ text-decoration: line-through;
+ }
+
+ .overdue {
+ color: darkred;
+ }
```
ほとんどコピペしただけですが、唯一のポイントは `src/app/app.component.html` に書いた
```html:src/app/app.component.html
<app-task-list></app-task-list>
```
これです。
導入編のコードリーディングで、 `AppComponent` の `selector` に書かれている `'app-root'` に対応して `index.html` 内の `<app-root></app-root>` の箇所に `AppComponent` のレンダリング結果が挿入されるという関係を紐解いたことを覚えているでしょうか。
今回もまったく同じで、自動生成された `TaskListComponent` の `selector` のところには `'app-task-list'` と書かれています。つまり、このコンポーネントを別のビューに挿入したい場合は `<app-task-list></app-task-list>` という要素として設置すればよいというわけです。
今回はもともと `AppComponent` に書いていたHTMLを丸ごと `TaskListComponent` に移動したので、 `AppComponent` のビューには `<app-task-list></app-task-list>` だけを書いておけば、そこに `TaskListComponent` の中身が丸ごと展開される結果になります。
この時点で一度動作を確認してみてください。先ほどまでと何ら変わらない動作になっていれば正常にコンポーネントの分割ができている証拠です。
### `TaskListItemComponent` を作る
```bash:bash
ng generate component TaskListItem
```
で `TaskListItemComponent` を作成して、 `TaskListComponent` のコードから「タスクリストの1行」の実装に関する部分を `TaskListItemComponent` に移してみましょう。
#### TaskListComponent
```typescript:src/app/task-list/task-list.component.ts
- isOverdue(task) {
- return !task.done && task.deadline < (new Date()).setHours(0, 0, 0, 0);
- }
```
```html: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 }}
```
```scss:src/app/task-list/task-list.component.scss
- .done {
- color: gray;
- text-decoration: line-through;
- }
-
- .overdue {
- color: darkred;
- }
```
#### TaskListItemComponent
```typescript: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);
+ }
}
```
```html: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>
```
```scss: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` から `TaskListItemComponent` の `task` クラス変数に対して、(自分のクラス変数である) `task` を渡す
- `TaskListItemComponent` はクラス変数 `task` を宣言し、これに [`@Input()` デコレーター](https://angular.io/api/core/Input) を付けることで親コンポーネントからデータを受け取れるようにする
ということをしています。
この `[相手の変数名]="自分のデータ"` で親コンポーネントから子コンポーネントへデータを受け渡す機能のことを、単に「データバインディング」、あるいは「双方向データバインディング」と対比して「単方向データバインディング」などと呼んだりします。
> ちなみに、ここまでで `単方向データバインディング` `イベントバインディング` `双方向データバインディング` の3種類のバインディングが登場しましたが、それぞれビューにおける記法は `[]` `()` `[()]` となっていました。初めて [(ngModel)] を見たときは 「何だこの難解な記法は!」 と思ったと思うのですが、こうして3種類出揃ってみると `[]` と `()` の両方の性質を兼ね備えている(双方向)という意味があったのだと分かりますね。
> これら3種類のバインディングの対比は、[こちらのドキュメント](https://angular.io/guide/template-syntax#binding-syntax-an-overview) に記載されている下表が分かりやすいので参考までに貼っておきます。
### `TaskFormComponent` を作る
```bash:bash
ng generate component TaskForm
```
で `TaskFormComponent` を作成して、 `TaskListComponent` のコードから「タスク追加フォーム」の実装に関する部分を `TaskFormComponent` に移してみましょう。
#### TaskListComponent
```typescript: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);
+ }
}
```
```html: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
```typescript: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(),
+ };
+ }
}
```
```html: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)});`
一見難しそうに見えますが、
```html
<app-task-form (addTask)="addTask($event)"></app-task-form>
```
このコードはもともとの
```html
<button (click)="addTask()">追加</button>
```
ととてもよく似ていますよね。もともとが「 `click` イベントの発火に合わせて `addTask()` を実行する」という処理だったのが、「 `addTask` イベントの発火に合わせて `addTask($event)` を実行する」に変わっているだけです。
つまり、 `TaskFormComponent` が `addTask` というカスタムイベントを持っていて、「追加」ボタンがクリックされたときにそのイベントを発火してくれるようになっているのです。( `$event` については後述します)
`TaskFormComponent` 側でそのカスタムイベントを作成しているのが、
```typescript
@Output() addTask = new EventEmitter();
```
このコードです。
`EventEmitter` クラスのインスタンスを代入したクラス変数 `addTask` を宣言しており、これがカスタムイベントの発火装置になります。これに [`@Output()` デコレーター](https://angular.io/api/core/Output) を付けることで、親コンポーネントに対してイベントを受け渡せるようにしているイメージです。
そして最後に
```typescript
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` として受け取ることができます。
なので、
```html
<app-task-form (addTask)="addTask($event)"></app-task-form>
```
このコードでタスクをリストに追加するという操作が可能だったわけです。
## 5. 型安全なコードにする
今さらですが、 Angular では TypeScript を使ってコードを書きます。( Angular 自体も TypeScript で書かれています)
せっかく TypeScript を採用しているのに、ここまでに書いてきたコードでは 型注釈 をまったく使ってきませんでした。
というわけで、ここらで TypeScript の強みを生かした型安全なコードにグレードアップさせておきましょう。
### `Task` インターフェースを定義する
数値や文字列などのプリミティブ型だけでなく、自分で定義したインターフェースも型として利用できます。今回のアプリでは「タスク」が重要な構造を持っているので、これをインターフェースとして定義しておくことにしましょう。
`src/models/task.ts` というファイルを新しく作って、以下のような内容を書いてください。
```typescript:
export interface Task {
title: string;
done: boolean;
deadline: Date;
}
```
### 既存のコードに型注釈を付ける
これで Task 型が定義できたので、既存のコードに型注釈を付けていきましょう。
```typescript: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);
}
```
```typescript: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);
}
```
```typescript: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 {
```
差し当たりこんなところでしょうか。
型注釈を付け加える以外に、一箇所
```typescript
isOverdue(task: Task): boolean {
return !task.done && task.deadline.getTime() < (new Date()).setHours(0, 0, 0, 0);
}
```
この部分のコードについて以下のように修正を加えました。
```typescript
- task.deadline < (new Date()).setHours(0, 0, 0, 0);
+ task.deadline.getTime() < (new Date()).setHours(0, 0, 0, 0);
```
元のコードだと Date 型と数値型(`Date.prototype.setHours()` の戻り値)を比較してしまっていてコンパイルエラーになったので、きちんと数値同士の比較になるように修正しました。早速、型の恩恵に預かることができましたね。
### 期日なしのタスクも登録できるようにインターフェースを修正する
現状だと、期日入力欄を空欄のままタスクを追加すると「現在日時」が設定されるようになっています。これは仕様として微妙なので、期日なしのタスクも登録できるようにしましょう。
まずはインターフェースを修正しましょう。
```typescript:src/models/task.ts
export interface Task {
title: string;
done: boolean;
- deadline: Date;
+ deadline: Date|null;
}
```
その上で、期日が入力されなかったときは現在日時ではなく `null` をセットするように、また期日に `null` が入っていることを考慮するように、コードを修正します。
```typescript: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);
}
```
```html: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>
```
```typescript: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,
};
}
```
これで、下図のとおり期日なしのタスクも登録できるようになりました。
型注釈を付けておけば、このようなデータ構造の変更も安心して行うことができますね。