ディレクティブ(属性・構造・カスタム)
ディレクティブはAngular特有の強力な機能で、コンポーネントのテンプレート上でDOM要素に再利用可能な振る舞いを付与します。Reactにはない概念であり、大規模開発でのコード再利用性を大きく向上させます。
1. ディレクティブの3つの種類
Angularのディレクティブは以下の3種類に分類されます。すべてのディレクティブは @Directive デコレータ(またはそのスーパーセットである @Component)で定義されます。
| 種類 | デコレータ | 役割 | 例 |
|---|---|---|---|
| コンポーネントディレクティブ | @Component | テンプレートを持つディレクティブ(UIの構成単位) | <app-button> |
| 構造ディレクティブ | @Directive | DOMツリーの構造を追加・削除・変更する | *ngIf, *ngFor, @if, @for |
| 属性ディレクティブ | @Directive | 要素の外観や振る舞いを変更する(DOMは追加・削除しない) | ngClass, ngStyle, [appHighlight] |
コンポーネントディレクティブ
@Component は内部的に @Directive を拡張したものです。テンプレート(template/templateUrl)とスタイル(styles/styleUrls)を持ち、UIの構成単位となります。
@Component({
selector: 'app-button',
standalone: true,
template: `<button class="btn"><ng-content /></button>`,
})
export class ButtonComponent {}
構造ディレクティブ
アスタリスク(*)プレフィックスが構造ディレクティブの目印です。内部的には <ng-template> に変換されてDOMの追加・削除を行います。Angular 17以降は @if / @for のような制御フロー構文も利用できます。
<!-- 従来の構造ディレクティブ -->
<div *ngIf="isLoggedIn">ようこそ!</div>
<li *ngFor="let item of items; let i = index">{{ i }}: {{ item.name }}</li>
<!-- Angular 17+ 制御フロー構文(推奨) -->
@if (isLoggedIn) {
<div>ようこそ!</div>
}
@for (item of items; track item.id) {
<li>{{ item.name }}</li>
}
属性ディレクティブ
既存の要素にスタイルやクラス、イベント処理を付与します。DOM構造は変えません。
<p [ngClass]="{ 'active': isActive, 'error': hasError }">テキスト</p>
<p [appHighlight]="'#ffeb3b'">ホバーでハイライト</p>
Reactには「ディレクティブ」という概念は存在しません。最も近い代替手段は以下の通りです:
- 構造ディレクティブ → JSX 内の条件式 (
{condition && <Component />}) やリスト (items.map(...)) - 属性ディレクティブ → カスタムHooks(
useHover,useClickOutside)やHOC(Higher-Order Component)
Angularのディレクティブは、テンプレートに 宣言的 に記述できる点が大きな違いです。
2. 組み込み属性ディレクティブ
Angularには標準で提供される便利な属性ディレクティブがあります。CommonModule(または各スタンドアロンディレクティブ)をインポートして使用します。
ngClass — 動的なクラスの付与
ngClass はオブジェクト、配列、文字列の3つの形式で記述できます。
<!-- オブジェクト形式(推奨): キーがクラス名、値が真偽値 -->
<div [ngClass]="{ 'active': isActive, 'disabled': isDisabled, 'error': hasError }">
オブジェクト形式
</div>
<!-- 配列形式: 複数クラスをまとめて適用 -->
<div [ngClass]="['base-style', isActive ? 'active' : '', sizeClass]">
配列形式
</div>
<!-- 文字列形式: 単純なケース -->
<div [ngClass]="isActive ? 'active' : 'inactive'">
文字列形式
</div>
<!-- 単一クラスの切り替えには [class.クラス名] の短縮記法が便利 -->
<div [class.active]="isActive" [class.error]="hasError">
短縮記法(シンプルなケースに最適)
</div>
Reactでは clsx や classnames ライブラリを使って同様のことを実現します:
// React + clsx
import clsx from 'clsx';
<div className={clsx({ active: isActive, disabled: isDisabled, error: hasError })}>
テキスト
</div>
Angularの ngClass はビルトインで同等の機能を提供します。
ngStyle — 動的なインラインスタイルの付与
<!-- オブジェクト形式でインラインスタイルを指定 -->
<div [ngStyle]="{ 'color': textColor, 'font-size.px': fontSize, 'opacity': isVisible ? 1 : 0 }">
動的スタイル
</div>
<!-- 単一プロパティの場合は [style.プロパティ名] の短縮記法が便利 -->
<div [style.color]="textColor" [style.font-size.px]="fontSize">
短縮記法
</div>
ngModel — 双方向バインディング
フォーム要素に双方向バインディングを提供します。FormsModule のインポートが必要です。
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-simple-form',
standalone: true,
imports: [FormsModule],
template: `
<input [(ngModel)]="username" placeholder="ユーザー名を入力" />
<p>入力値: {{ username }}</p>
`,
})
export class SimpleFormComponent {
username = '';
}
[(ngModel)] は「バナナ・イン・ア・ボックス」構文と呼ばれます。内部的には [ngModel]="value" (入力バインディング) と (ngModelChange)="value = $event" (出力バインディング) の組み合わせです。
Reactの value={value} onChange={(e) => setValue(e.target.value)} に相当します。
3. カスタム属性ディレクティブの作成
カスタム属性ディレクティブを使うと、複数のコンポーネントに共通の振る舞いを横断的に適用できます。以下では、ホバー時に背景色を変える HighlightDirective を作成します。
生成コマンド
ng generate directive highlight
# または短縮形
ng g d highlight
実装例
import {
Directive,
ElementRef,
HostBinding,
HostListener,
Input,
Renderer2,
} from '@angular/core';
@Directive({
selector: '[appHighlight]',
standalone: true,
})
export class HighlightDirective {
// ディレクティブ自体をインプットとして使う([appHighlight]="'yellow'" で色を指定)
@Input() appHighlight = 'yellow';
// @HostBinding でホスト要素のプロパティを直接バインド
@HostBinding('style.transition') transition = 'background-color 0.3s ease';
constructor(private el: ElementRef, private renderer: Renderer2) {}
@HostListener('mouseenter')
onMouseEnter(): void {
// Renderer2 を使うことでサーバーサイドレンダリング(SSR)でも安全に動作する
this.renderer.setStyle(this.el.nativeElement, 'backgroundColor', this.appHighlight);
}
@HostListener('mouseleave')
onMouseLeave(): void {
this.renderer.removeStyle(this.el.nativeElement, 'backgroundColor');
}
}
import { Component } from '@angular/core';
import { HighlightDirective } from './highlight.directive';
@Component({
selector: 'app-root',
standalone: true,
imports: [HighlightDirective],
template: `
<!-- デフォルト色(yellow)でハイライト -->
<p appHighlight>ホバーしてみてください</p>
<!-- カスタム色を指定 -->
<p [appHighlight]="'#a5d6a7'">緑色でハイライト</p>
<button [appHighlight]="'#ef9a9a'">赤色でハイライト</button>
`,
})
export class AppComponent {}
Renderer2 を使う理由
| アプローチ | メリット | デメリット |
|---|---|---|
el.nativeElement.style.color = '...' | シンプル | SSR非対応、テスト困難 |
Renderer2.setStyle(...) | SSR対応、テスト容易 | コードが若干冗長 |
React のカスタムHooksは コンポーネントの状態・副作用 を再利用するための仕組みです。一方、Angularのディレクティブは DOM要素そのもの に振る舞いを付与します。
// React: useHover フックでホバー状態を管理
function useHover() {
const [isHovered, setIsHovered] = useState(false);
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
el.addEventListener('mouseenter', () => setIsHovered(true));
el.addEventListener('mouseleave', () => setIsHovered(false));
return () => { /* cleanup */ };
}, []);
return { ref, isHovered };
}
// 使用側でスタイルを自分で適用する必要がある
function MyComponent() {
const { ref, isHovered } = useHover();
return <p ref={ref} style={{ background: isHovered ? 'yellow' : '' }}>テキスト</p>;
}
Angularのディレクティブはテンプレートに appHighlight と書くだけで適用でき、ロジックとスタイリングが一体化しています。
4. カスタム構造ディレクティブの作成
構造ディレクティブは TemplateRef と ViewContainerRef を使って、DOMへの要素の追加・削除を制御します。
| クラス | 役割 |
|---|---|
TemplateRef | *appXxx が適用された <ng-template> の参照を保持する |
ViewContainerRef | テンプレートをDOMにレンダリングするコンテナ |
実装例: ロールベースの権限制御ディレクティブ
import { Directive, Input, OnInit, TemplateRef, ViewContainerRef } from '@angular/core';
import { AuthService } from './auth.service';
@Directive({
selector: '[appHasRole]',
standalone: true,
})
export class HasRoleDirective implements OnInit {
private requiredRole = '';
@Input() set appHasRole(role: string) {
this.requiredRole = role;
this.updateView();
}
constructor(
private templateRef: TemplateRef<any>,
private vcr: ViewContainerRef,
private authService: AuthService,
) {}
ngOnInit(): void {
this.updateView();
}
private updateView(): void {
this.vcr.clear(); // 一度クリアしてから再評価
if (this.authService.hasRole(this.requiredRole)) {
// テンプレートをDOMに埋め込む
this.vcr.createEmbeddedView(this.templateRef);
}
}
}
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class AuthService {
private currentUserRoles = ['user', 'editor'];
hasRole(role: string): boolean {
return this.currentUserRoles.includes(role);
}
}
<!-- 'admin' ロールを持つユーザーにのみ表示 -->
<div *appHasRole="'admin'">
<h3>管理者メニュー</h3>
<ul>
<li>ユーザー管理</li>
<li>システム設定</li>
</ul>
</div>
<!-- 'editor' ロールには表示 -->
<button *appHasRole="'editor'">記事を編集</button>
<!-- 全ユーザーに表示 -->
<p>誰でも見られるコンテンツ</p>
Reactでは条件付きレンダリングをJSX内のインライン条件式で実現します:
// React: インライン条件式
function AdminMenu() {
return (
<>
{hasRole('admin') && (
<div>
<h3>管理者メニュー</h3>
</div>
)}
{hasRole('editor') && <button>記事を編集</button>}
</>
);
}
// またはカスタムコンポーネントでラップ
function RequireRole({ role, children }) {
return hasRole(role) ? children : null;
}
Angularの *appHasRole はテンプレート上に宣言的に記述でき、権限制御ロジックをコンポーネントから完全に分離できます。大規模チームでは、この標準化が保守性の向上に直結します。
5. HostDirective(合成ディレクティブ)
Angular 15以降、hostDirectives プロパティを使うことでディレクティブの 合成(Composition) が可能になりました。コンポーネントやディレクティブが別のディレクティブの機能を取り込めます。
基本的な使い方
import { Component } from '@angular/core';
import { HighlightDirective } from './highlight.directive';
import { TooltipDirective } from './tooltip.directive';
@Component({
selector: 'app-button',
standalone: true,
// hostDirectives でディレクティブを合成する
hostDirectives: [
{
directive: HighlightDirective,
// 親から inputs/outputs を公開(エイリアスも設定可能)
inputs: ['appHighlight: color'],
},
{
directive: TooltipDirective,
inputs: ['appTooltip: tooltip'],
outputs: ['tooltipShown'],
},
],
template: `<button class="btn"><ng-content /></button>`,
})
export class ButtonComponent {}
<!-- color 入力で HighlightDirective に値を渡せる -->
<app-button color="#b3e5fc" tooltip="クリックで送信">送信</app-button>
合成 vs 継承
| 観点 | 合成(hostDirectives) | 継承 |
|---|---|---|
| 複数機能の組み合わせ | 容易(複数ディレクティブを列挙するだけ) | 単一継承のため困難 |
| 疎結合 | 高い(依存関係が明確) | 低い(親クラスの変更が影響する) |
| テスト容易性 | 高い(各ディレクティブを独立してテスト可能) | 低い(継承階層のセットアップが必要) |
| Angular の推奨 | ✅ 推奨 | ❌ 非推奨 |
Reactでは複数のHOCやHooksを組み合わせることで似た合成パターンを実現します:
// React: HOC の合成
const EnhancedButton = withTooltip(withHighlight(Button));
// または Hooks を組み合わせる
function ButtonComponent({ color, tooltip }) {
const highlightProps = useHighlight(color);
const tooltipProps = useTooltip(tooltip);
return <button {...highlightProps} {...tooltipProps}>送信</button>;
}
hostDirectives はAngularがフレームワークレベルでサポートする合成APIで、ボイラープレートを大幅に削減できます。
6. ディレクティブ vs React Hooks
どちらも「ロジックの再利用」を目的としていますが、アプローチが異なります。
| 観点 | Angularディレクティブ | React Hooks |
|---|---|---|
| 適用方法 | HTMLテンプレートに属性として記述 | コンポーネント関数の内部で呼び出す |
| 対象 | DOM要素に直接作用する | コンポーネントの状態・ライフサイクル・副作用 |
| DOM操作 | ElementRef / Renderer2 で直接・安全に操作 | useRef + useEffect で間接的に操作 |
| 再利用の粒度 | 任意のDOM要素に宣言的に適用できる | コンポーネント単位でロジックを混ぜ込む |
| テンプレートへの影響 | 構造ディレクティブはDOMの有無を制御できる | JSX内の条件式・リストで制御する |
| 依存性注入 | DI経由でサービスを受け取れる | Context API / 外部ストアを利用する |
| テスト | TestBed でホスト要素を用意してテスト | renderHook / RTL で独立してテスト可能 |
まとめ
- Angularディレクティブ: DOM中心で宣言的。テンプレートに属性を書くだけでUIの振る舞いを付与できる。大規模チームでの一貫したコーディングスタイルの標準化に優れる。
- React Hooks: コンポーネントロジック中心。状態管理・副作用・コンテキストのカプセル化に優れ、JavaScript関数として柔軟に組み合わせられる。
7. 実践パターン
a. ツールチップディレクティブ
ホバー時に簡易ツールチップを表示するディレクティブです。
import {
Directive,
ElementRef,
HostListener,
Input,
OnDestroy,
Renderer2,
} from '@angular/core';
@Directive({
selector: '[appTooltip]',
standalone: true,
})
export class TooltipDirective implements OnDestroy {
@Input() appTooltip = '';
private tooltipEl: HTMLElement | null = null;
constructor(private el: ElementRef, private renderer: Renderer2) {}
@HostListener('mouseenter')
onMouseEnter(): void {
this.tooltipEl = this.renderer.createElement('span');
this.renderer.appendChild(
this.tooltipEl,
this.renderer.createText(this.appTooltip),
);
this.renderer.setStyle(this.tooltipEl, 'position', 'absolute');
this.renderer.setStyle(this.tooltipEl, 'background', '#333');
this.renderer.setStyle(this.tooltipEl, 'color', '#fff');
this.renderer.setStyle(this.tooltipEl, 'padding', '4px 8px');
this.renderer.setStyle(this.tooltipEl, 'border-radius', '4px');
this.renderer.setStyle(this.tooltipEl, 'font-size', '12px');
this.renderer.setStyle(this.tooltipEl, 'white-space', 'nowrap');
this.renderer.setStyle(this.tooltipEl, 'z-index', '1000');
this.renderer.setStyle(this.el.nativeElement, 'position', 'relative');
this.renderer.appendChild(this.el.nativeElement, this.tooltipEl);
}
@HostListener('mouseleave')
onMouseLeave(): void {
this.removeTooltip();
}
ngOnDestroy(): void {
this.removeTooltip();
}
private removeTooltip(): void {
if (this.tooltipEl) {
this.renderer.removeChild(this.el.nativeElement, this.tooltipEl);
this.tooltipEl = null;
}
}
}
<button [appTooltip]="'クリックで保存します'">保存</button>
<span [appTooltip]="'必須項目です'">ユーザー名 *</span>
b. クリック外検知ディレクティブ
要素の外側がクリックされたことを検知してイベントを発火します。ドロップダウンやモーダルの閉じる処理に便利です。
import { Directive, ElementRef, EventEmitter, HostListener, Output } from '@angular/core';
@Directive({
selector: '[appClickOutside]',
standalone: true,
})
export class ClickOutsideDirective {
@Output() clickOutside = new EventEmitter<void>();
constructor(private elementRef: ElementRef) {}
// document 全体のクリックイベントを購読する
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent): void {
const target = event.target as Node;
if (!this.elementRef.nativeElement.contains(target)) {
this.clickOutside.emit();
}
}
}
<div class="dropdown" (appClickOutside)="closeDropdown()">
<button (click)="toggleDropdown()">メニュー ▼</button>
@if (isOpen) {
<ul class="dropdown-menu">
<li>項目1</li>
<li>項目2</li>
</ul>
}
</div>
c. 自動フォーカスディレクティブ
要素がDOMに挿入された直後に自動でフォーカスを当てます。モーダルや検索フォームに有用です。
import { AfterViewInit, Directive, ElementRef, Input } from '@angular/core';
@Directive({
selector: '[appAutoFocus]',
standalone: true,
})
export class AutoFocusDirective implements AfterViewInit {
// false にすると自動フォーカスを無効化できる
@Input() appAutoFocus = true;
constructor(private el: ElementRef<HTMLElement>) {}
ngAfterViewInit(): void {
if (this.appAutoFocus) {
// 次のマイクロタスクで実行し、アニメーション完了後にフォーカスする
Promise.resolve().then(() => this.el.nativeElement.focus());
}
}
}
<!-- モーダルが開いたら検索フィールドに自動フォーカス -->
<input appAutoFocus type="text" placeholder="検索..." />
<!-- 条件付きで自動フォーカス -->
<input [appAutoFocus]="shouldFocus" type="email" />
d. 権限制御ディレクティブ
セクション4 で実装した *appHasRole の応用として、より細かい権限(permission)単位で制御するパターンです。
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
import { AuthService } from './auth.service';
@Directive({
selector: '[appHasPermission]',
standalone: true,
})
export class HasPermissionDirective {
@Input() set appHasPermission(permission: string | string[]) {
const permissions = Array.isArray(permission) ? permission : [permission];
this.vcr.clear();
if (permissions.some(p => this.authService.hasPermission(p))) {
this.vcr.createEmbeddedView(this.templateRef);
}
}
constructor(
private templateRef: TemplateRef<any>,
private vcr: ViewContainerRef,
private authService: AuthService,
) {}
}
<!-- 単一権限 -->
<button *appHasPermission="'article:delete'">記事を削除</button>
<!-- 複数権限(いずれかを持てばOK) -->
<div *appHasPermission="['article:edit', 'article:publish']">
編集・公開メニュー
</div>
e. 数値のみ入力制限ディレクティブ
テキストフィールドへの非数値文字入力をブロックします。
import { Directive, HostListener } from '@angular/core';
@Directive({
selector: '[appNumbersOnly]',
standalone: true,
})
export class NumbersOnlyDirective {
@HostListener('keypress', ['$event'])
onKeyPress(event: KeyboardEvent): void {
const allowedKeys = ['Backspace', 'Tab', 'Enter', 'ArrowLeft', 'ArrowRight', 'Delete'];
if (allowedKeys.includes(event.key)) {
return; // 制御キーは許可
}
if (!/^\d$/.test(event.key)) {
event.preventDefault(); // 数値以外を拒否
}
}
@HostListener('paste', ['$event'])
onPaste(event: ClipboardEvent): void {
const pastedText = event.clipboardData?.getData('text') ?? '';
if (!/^\d+$/.test(pastedText)) {
event.preventDefault(); // 数値以外のペーストを拒否
}
}
}
<!-- 郵便番号・電話番号フィールドなど -->
<input appNumbersOnly type="text" placeholder="郵便番号(数値のみ)" maxlength="7" />
<input appNumbersOnly type="text" placeholder="電話番号(数値のみ)" />
実践パターンのディレクティブはすべてスタンドアロン(standalone: true)で実装しています。各ディレクティブを独立したファイルに分けることで、テストや再利用がしやすくなります。共有ディレクティブは shared/directives/ などのディレクトリに配置し、SharedModule(またはバレルファイル index.ts)でまとめてエクスポートするのがベストプラクティスです。