メインコンテンツまでスキップ

ディレクティブ(属性・構造・カスタム)

ディレクティブはAngular特有の強力な機能で、コンポーネントのテンプレート上でDOM要素に再利用可能な振る舞いを付与します。Reactにはない概念であり、大規模開発でのコード再利用性を大きく向上させます。

1. ディレクティブの3つの種類

Angularのディレクティブは以下の3種類に分類されます。すべてのディレクティブは @Directive デコレータ(またはそのスーパーセットである @Component)で定義されます。

種類デコレータ役割
コンポーネントディレクティブ@Componentテンプレートを持つディレクティブ(UIの構成単位)<app-button>
構造ディレクティブ@DirectiveDOMツリーの構造を追加・削除・変更する*ngIf, *ngFor, @if, @for
属性ディレクティブ@Directive要素の外観や振る舞いを変更する(DOMは追加・削除しない)ngClass, ngStyle, [appHighlight]

コンポーネントディレクティブ

@Component は内部的に @Directive を拡張したものです。テンプレート(template/templateUrl)とスタイル(styles/styleUrls)を持ち、UIの構成単位となります。

app-button.component.ts
@Component({
selector: 'app-button',
standalone: true,
template: `<button class="btn"><ng-content /></button>`,
})
export class ButtonComponent {}

構造ディレクティブ

アスタリスク(*)プレフィックスが構造ディレクティブの目印です。内部的には <ng-template> に変換されてDOMの追加・削除を行います。Angular 17以降は @if / @for のような制御フロー構文も利用できます。

structural-examples.html
<!-- 従来の構造ディレクティブ -->
<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構造は変えません。

attribute-examples.html
<p [ngClass]="{ 'active': isActive, 'error': hasError }">テキスト</p>
<p [appHighlight]="'#ffeb3b'">ホバーでハイライト</p>
React との比較

Reactには「ディレクティブ」という概念は存在しません。最も近い代替手段は以下の通りです:

  • 構造ディレクティブ → JSX 内の条件式 ({condition && <Component />}) やリスト (items.map(...))
  • 属性ディレクティブ → カスタムHooks(useHover, useClickOutside)やHOC(Higher-Order Component)

Angularのディレクティブは、テンプレートに 宣言的 に記述できる点が大きな違いです。

2. 組み込み属性ディレクティブ

Angularには標準で提供される便利な属性ディレクティブがあります。CommonModule(または各スタンドアロンディレクティブ)をインポートして使用します。

ngClass — 動的なクラスの付与

ngClass はオブジェクト、配列、文字列の3つの形式で記述できます。

ngclass-examples.html
<!-- オブジェクト形式(推奨): キーがクラス名、値が真偽値 -->
<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 との比較

Reactでは clsxclassnames ライブラリを使って同様のことを実現します:

// React + clsx
import clsx from 'clsx';
<div className={clsx({ active: isActive, disabled: isDisabled, error: hasError })}>
テキスト
</div>

Angularの ngClass はビルトインで同等の機能を提供します。

ngStyle — 動的なインラインスタイルの付与

ngstyle-example.html
<!-- オブジェクト形式でインラインスタイルを指定 -->
<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 のインポートが必要です。

simple-form.component.ts
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)] は「バナナ・イン・ア・ボックス」構文と呼ばれます。内部的には [ngModel]="value" (入力バインディング) と (ngModelChange)="value = $event" (出力バインディング) の組み合わせです。

Reactの value={value} onChange={(e) => setValue(e.target.value)} に相当します。

3. カスタム属性ディレクティブの作成

カスタム属性ディレクティブを使うと、複数のコンポーネントに共通の振る舞いを横断的に適用できます。以下では、ホバー時に背景色を変える HighlightDirective を作成します。

生成コマンド

ng generate directive highlight
# または短縮形
ng g d highlight

実装例

highlight.directive.ts
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');
}
}
app.component.ts(使用例)
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との違い

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. カスタム構造ディレクティブの作成

構造ディレクティブは TemplateRefViewContainerRef を使って、DOMへの要素の追加・削除を制御します。

クラス役割
TemplateRef*appXxx が適用された <ng-template> の参照を保持する
ViewContainerRefテンプレートをDOMにレンダリングするコンテナ

実装例: ロールベースの権限制御ディレクティブ

has-role.directive.ts
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);
}
}
}
auth.service.ts(簡易実装)
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 との比較

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) が可能になりました。コンポーネントやディレクティブが別のディレクティブの機能を取り込めます。

基本的な使い方

button.component.ts
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 との比較

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. ツールチップディレクティブ

ホバー時に簡易ツールチップを表示するディレクティブです。

tooltip.directive.ts
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. クリック外検知ディレクティブ

要素の外側がクリックされたことを検知してイベントを発火します。ドロップダウンやモーダルの閉じる処理に便利です。

click-outside.directive.ts
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に挿入された直後に自動でフォーカスを当てます。モーダルや検索フォームに有用です。

auto-focus.directive.ts
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)単位で制御するパターンです。

has-permission.directive.ts
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. 数値のみ入力制限ディレクティブ

テキストフィールドへの非数値文字入力をブロックします。

numbers-only.directive.ts
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)でまとめてエクスポートするのがベストプラクティスです。