Angular基礎 #4 Directive と Pipe

読了 10分

前回はデータバインディングとイベント処理を扱いながら、コンポーネントとテンプレートが互いにデータをどのようにやり取りするのかを見てきました。今回はテンプレートをより表現力豊かにする 2 つのツール、DirectivePipe を整理していきます。条件に応じて画面を分岐させ、配列を反復して描画し、表示する値を見やすい形に変換する — ほぼすべての画面で使う機能です。

Directive とは #

Directive (ディレクティブ) は DOM に追加の振る舞いを与えるクラスです。「この要素を条件によって見せるか見せないか決めてくれ」、「この配列を反復して描画してくれ」、「この要素にこのスタイルを適用してくれ」のような指示 (directive) をテンプレートの中で表現するツールです。

実は私たちが #2 で作った コンポーネントも Directive の一種 です。正確には「テンプレートを持つディレクティブ」がコンポーネントです。それ以外に Angular は 2 種類のディレクティブを提供します。

  • 構造ディレクティブ (Structural Directive) — DOM の構造そのものを変えるディレクティブ。要素を追加したり削除したり反復したりします。
  • 属性ディレクティブ (Attribute Directive) — 要素の見た目や振る舞いだけを変えるディレクティブ。クラスをトグルしたりスタイルを当てたりします。

まず構造ディレクティブから見ていきます。Angular 17 から導入された 新しい制御フロー が中心です。

新しい制御フロー — @if、@for、@switch #

長らく Angular は *ngIf*ngFor*ngSwitch のような構造ディレクティブで分岐と反復を処理してきました。Angular 17 からはこれを置き換える ビルトイン制御フロー (Built-in Control Flow) 構文が導入され、新しいプロジェクトではこちらを標準として使うのが良いです。より速く、型推論がよく効き、別途 import も必要ありません。

@if — 条件付きレンダリング #

条件に応じて要素を見せたり見せなかったりするには @if を使います。

src/app/greeting.component.html
@if (user) {
  <p>こんにちは、{{ user.name }}さん!</p>
} @else {
  <p>ログインが必要です。</p>
}

else if で複数の分岐を作ることもできます。

src/app/status.component.html
@if (status === 'loading') {
  <p>読み込み中...</p>
} @else if (status === 'error') {
  <p class="error">エラーが発生しました。</p>
} @else {
  <p>完了!</p>
}

JavaScript の if/else if/else 構文とほぼ同じなので直感的です。

@for — 反復レンダリング #

配列を反復して描画するには @for を使います。

src/app/post-list.component.html
<ul>
  @for (post of posts; track post.id) {
    <li>{{ post.title }}</li>
  } @empty {
    <li>記事がありません。</li>
  }
</ul>

@for でもっとも重要な部分は track 表現式 です。Angular は配列が変わったときどの項目が追加/削除/移動されたかを識別するために track に指定された値を使います。通常は post.id のような一意な識別子を渡し、識別子がなければ track $index を使うこともできますが、可能であれば一意の ID の方が良いです。

@empty ブロックは配列が空のときに表示する内容です。以前は別途 *ngIf で処理しなければならなかった部分が、一箇所にすっきりと収まります。

@for の中では次のような コンテキスト変数 も使えます。

  • $index — 現在のインデックス (0 から)
  • $first$last — 最初/最後の項目かどうか
  • $even$odd — 偶数/奇数インデックスかどうか
src/app/post-list.component.html
@for (post of posts; track post.id; let i = $index, isFirst = $first) {
  <li [class.first]="isFirst">{{ i + 1 }}. {{ post.title }}</li>
}

@switch — 多分岐 #

値に応じて複数の分岐に分かれる場合は @switch が適しています。

src/app/role-badge.component.html
@switch (role) {
  @case ('admin') {
    <span class="badge admin">管理者</span>
  }
  @case ('editor') {
    <span class="badge editor">編集者</span>
  }
  @default {
    <span class="badge">一般ユーザー</span>
  }
}

JavaScript の switch と違って break が必要なく、一致する @case だけが実行され、一致するものがなければ @default が実行されます。

ヒント
新しい制御フロー構文は import が必要ありません。従来の *ngIf*ngForCommonModule や個別のディレクティブを import しなければなりませんでしたが、@if/@for/@switch はテンプレートコンパイラが直接処理するので imports 配列に何も追加しなくてもすぐに動きます。ボイラープレートが一段階減りました。

旧構造ディレクティブ — *ngIf、*ngFor #

新しいプロジェクトでは新しい制御フローを使いますが、既存のプロジェクトや古いサンプルではアスタリスク (*) が付いた旧ディレクティブをまだよく目にします。

旧構文 — 参考用
<p *ngIf="user; else login">{{ user.name }}さん、ようこそ</p>
<ng-template #login>
  <p>ログインが必要です。</p>
</ng-template>

<ul>
  <li *ngFor="let post of posts; trackBy: trackById">{{ post.title }}</li>
</ul>

*ngFor には trackBy で追跡関数を別途定義する必要があり、これをうっかり忘れると毎レンダリングごとにすべての DOM が再描画されてパフォーマンス問題が発生していました。新しい @fortrack必須構文 にした理由でもあります。既存のコードに出会ったら読めるくらいにはしておく必要がありますが、新しく書くときは新しい制御フローを推奨します。公式のマイグレーションツール (ng generate @angular/core:control-flow) も提供されています。

属性ディレクティブ — ngClass、ngStyle #

要素の見た目を条件によって変えるときには属性ディレクティブを使います。もっともよく使う 2 つが ngClassngStyle です。

src/app/menu.component.html
<a
  [ngClass]="{ active: isActive, disabled: isDisabled }"
  [ngStyle]="{ color: textColor, 'font-size.px': fontSize }">
  メニュー
</a>

[ngClass] にオブジェクトを渡すとキーがクラス名、値が適用するかどうか (true/false) です。上の例で isActivetrue なら active クラスが付き、false なら外れます。

実は単純なケースなら [class.クラス名][style.プロパティ] バインディングの方が簡潔です。

src/app/menu.component.html
<a [class.active]="isActive" [style.color]="textColor">メニュー</a>

条件が単純なら [class.X]/[style.X]、複数のクラスを一度に扱うなら [ngClass]/[ngStyle] を使うように選び分けます。この 2 つのディレクティブを使うには CommonModule をコンポーネントの imports に追加する必要があります。

カスタム Directive を作る #

直接ディレクティブを作って要素に振る舞いを与えることもできます。CLI で雛形を作ります。

ターミナル
ng generate directive highlight

生成されたディレクティブにマウス hover 時に背景色を変えるロジックを入れていきます。

src/app/highlight.directive.ts
import { Directive, ElementRef, HostListener, input } from '@angular/core';

@Directive({
  selector: '[appHighlight]',
  standalone: true,
})
export class HighlightDirective {
  color = input<string>('#fffbcc');

  constructor(private el: ElementRef<HTMLElement>) {}

  @HostListener('mouseenter')
  onMouseEnter() {
    this.el.nativeElement.style.backgroundColor = this.color();
  }

  @HostListener('mouseleave')
  onMouseLeave() {
    this.el.nativeElement.style.backgroundColor = '';
  }
}

ポイントを整理すると:

  • selector: '[appHighlight]' — 角括弧で囲んだセレクタは 属性セレクタ です。<p appHighlight> のように属性として使われるという意味です。
  • ElementRef — ディレクティブが付いたホスト要素にアクセスする通り道。nativeElement で実際の DOM 要素を扱えます。
  • @HostListener — ホスト要素のイベントを購読するデコレータです。
  • input<string>('#fffbcc') — ディレクティブにもコンポーネントと同じく input() で値を受け取れます。

使い方は普通の属性のようにします。

src/app/app.component.html
<p appHighlight>マウスを乗せてみてください (デフォルトの黄色)</p>
<p appHighlight color="#cce5ff">こちらは青の背景</p>

もちろんこの程度なら CSS の :hover でも十分ですが、「条件によって hover の色を変える」とか「特定の権限のときだけ hover 効果を与える」のような動的なロジックが入るとディレクティブが光を放ちます。

Pipe とは #

Pipe (パイプ) はテンプレートの中でデータを 表示用に変換 してくれる小さな関数です。使う構文は Unix シェルのパイプに似ています。

src/app/profile.component.html
{{ value | pipeName }}
{{ value | pipeName:arg1:arg2 }}
{{ value | pipeA | pipeB }}

| の左側の値を右側のパイプに渡し、結果をまた次のパイプへ渡していくスタイルです。コンポーネントのデータ自体には触れず、画面に表示される姿だけを変えるという点がポイントです。

ビルトイン Pipe #

Angular が標準で提供するパイプの中でよく使うものを整理してみます。

src/app/profile.component.html
<!-- 日付フォーマット -->
<p>{{ today | date:'yyyy-MM-dd HH:mm' }}</p>

<!-- 通貨フォーマット -->
<p>{{ price | currency:'JPY':'symbol':'1.0-0' }}</p>

<!-- 百分率 -->
<p>{{ ratio | percent:'1.0-1' }}</p>

<!-- 大文字小文字 -->
<p>{{ name | uppercase }}</p>
<p>{{ name | lowercase }}</p>

<!-- オブジェクトを JSON 文字列に (デバッグ用) -->
<pre>{{ user | json }}</pre>

<!-- 小数桁数 -->
<p>{{ score | number:'1.2-2' }}</p>

<!-- スライス -->
<p>{{ longText | slice:0:50 }}...</p>

この中で特に知っておきたいのが async パイプです。

src/app/posts.component.html
@for (post of posts$ | async; track post.id) {
  <li>{{ post.title }}</li>
}

async パイプは RxJS の ObservablePromise を受け取って自動的に購読し、値が届くと画面に表示してくれて、コンポーネントが消えるときには自動的に購読解除までしてくれます。手動で subscribe() / unsubscribe() を管理する必要がないので、#1 で軽く触れた RxJS の流れをテンプレートで安全に扱う標準パターンです。

datecurrencypercentnumberslice のようなパイプは CommonModule に入っており、async も同様です。

カスタム Pipe を作る #

直接パイプを作っていきます。長い文字列を切って省略記号を付ける truncate パイプです。

ターミナル
ng generate pipe truncate
src/app/truncate.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'truncate',
  standalone: true,
})
export class TruncatePipe implements PipeTransform {
  transform(value: string, limit = 30, ellipsis = '...'): string {
    if (!value) return '';
    return value.length > limit ? value.slice(0, limit) + ellipsis : value;
  }
}

ポイントは PipeTransform インターフェースの transform() メソッドです。最初のパラメータが | の左側の値で、その後ろが : で渡される引数です。

使い方は次のとおりです。

src/app/post-list.component.html
@for (post of posts; track post.id) {
  <li>
    <h3>{{ post.title | truncate:20 }}</h3>
    <p>{{ post.body | truncate:100:' ...(続きを読む)' }}</p>
  </li>
}

もちろんコンポーネントでも imports: [TruncatePipe] で追加する必要があります。

純粋 vs 非純粋 Pipe #

@Pipe デコレータには pure というオプションがあり、デフォルト値は true です。純粋パイプ (pure pipe) は入力値が変わったときだけ再計算するので非常に効率的です。同じ入力にはキャッシュされた結果をそのまま使います。

pure: false に設定した 非純粋パイプ (impure pipe) は変更検知サイクルごとに毎回再実行されます。配列内部の項目が変わっても (参照はそのまま) 新しい結果を反映する必要があるなどの特殊な場合に使いますが、それだけパフォーマンスコストがあります。可能なら入力自体を新しい参照に変えて純粋パイプを使う方が安全です。

注記
「純粋」という単語が React の useMemo や関数型プログラミングの純粋関数と似た概念です。同じ入力には同じ出力を保証するのでキャッシュできるわけです。実務ではほぼすべてのパイプがデフォルト値 (pure: true) で十分で、非純粋が必要になる場面は稀です。

まとめ #

今回の記事ではテンプレートの表現力を広げる 2 つのツール、DirectivePipe を整理しました。要点をもう一度束ねると:

  • 新しい制御フロー @if / @for / @switch が分岐と反復の標準 (Angular 17+)
  • @for では track 表現式が必須
  • 単純なクラス/スタイルは [class.X] / [style.X]、複数を扱うときは [ngClass] / [ngStyle]
  • カスタムディレクティブは @Directive + ElementRef + @HostListener でホスト要素に振る舞いを追加
  • パイプは value | pipe:arg でテンプレートの中でデータを変換
  • async パイプは Observable/Promise を自動購読・解除までしてくれる、もっとも有用なビルトイン
  • カスタムパイプは PipeTransformtransform() メソッドで作る

ここまで来ると、コンポーネントの中で画面を描くのに必要なツールはほとんど揃ったことになります。しかしコンポーネントが直接データを抱えていて、API 呼び出しも直接やって、ビジネスロジックまで抱え込み始めると、すぐに肥大化します。こうした責務を切り分けて収める先がまさに サービス (Service) です。

次回の「Angular基礎 #5 Service と依存性注入」では、サービスクラスを作る方法、そして Angular のもっとも強力な武器の 1 つである 依存性注入 (Dependency Injection) でそのサービスをコンポーネントに繋ぐ方法を扱っていきます。

X