T-CREATOR

Nuxt で使うカスタムディレクティブの作り方

Nuxt で使うカスタムディレクティブの作り方

Nuxt プロジェクトでカスタムディレクティブを効果的に活用すれば、DOM 操作を簡潔に記述でき、コンポーネント間での処理の共通化も実現できます。この記事では、Nuxt におけるカスタムディレクティブの基本的な実装方法から実践的な活用例まで、初心者の方でも理解しやすいよう段階的に解説いたします。

カスタムディレクティブを使いこなすことで、より保守性の高いコードが書けるようになり、開発効率も大幅に向上するでしょう。

背景

Vue.js のディレクティブシステムの基本概念

Vue.js では、HTML テンプレート内で v-ifv-forv-model などのディレクティブを使って、DOM 要素に対する動的な制御を行います。これらの組み込みディレクティブに加えて、独自の処理を持つカスタムディレクティブを作成することができます。

ディレクティブの基本的な仕組みを図で確認してみましょう。

mermaidflowchart LR
  template[HTMLテンプレート] -->|v-custom| directive[カスタムディレクティブ]
  directive -->|DOM操作| element[DOM要素]
  directive -->|ライフサイクル| hooks[フック関数]
  hooks -->|created/mounted/updated| logic[カスタム処理]

ディレクティブは要素のライフサイクルに合わせてフック関数が実行され、DOM 操作や状態管理を効率的に行えます。

Nuxt におけるディレクティブの位置づけ

Nuxt アプリケーションでは、Vue.js の機能をそのまま活用しながら、SSR(サーバーサイドレンダリング)環境に適したディレクティブの実装が求められます。

Nuxt におけるディレクティブの特徴は以下の通りです。

特徴説明
SSR 対応サーバーサイドとクライアントサイドでの動作を考慮
プラグインシステムplugins​/​ ディレクトリを通じた全体登録が可能
TypeScript 統合型安全なディレクティブ開発をサポート

プラグインシステムとの関係性

Nuxt のプラグインシステムを活用することで、カスタムディレクティブをアプリケーション全体で利用できるようになります。プラグインとして登録されたディレクティブは、すべての Vue コンポーネントで自動的に利用可能となります。

mermaidflowchart TB
  nuxt[Nuxtアプリ] -->|読み込み| plugins[plugins/ディレクトリ]
  plugins -->|登録| directive[カスタムディレクティブ]
  directive -->|利用可能| components[全コンポーネント]
  components -->|実行| dom[DOM操作]

この仕組みにより、一度定義したディレクティブを複数のコンポーネントで再利用でき、コードの重複を削減できます。

課題

既存ディレクティブでは対応できない UI 要件

Vue.js の組み込みディレクティブだけでは、以下のような複雑な UI 要件に対応するのが困難な場合があります。

よくある課題例

  • モーダルウィンドウの外側クリックでの閉じる処理
  • 要素への自動フォーカス制御
  • スクロール位置に応じた要素の表示・非表示切り替え
  • ツールチップの動的表示制御

これらの処理を各コンポーネントで個別に実装すると、コードの可読性が低下し、保守が困難になってしまいます。

コンポーネント間で共通する処理の重複

複数のコンポーネントで同じような DOM 操作が必要になる場面は頻繁にあります。例えば、以下のような処理が各コンポーネントで重複しがちです。

typescript// 各コンポーネントで同じような処理を記述
onMounted(() => {
  const element = elementRef.value;
  element.addEventListener('click', handleClick);
});

onUnmounted(() => {
  const element = elementRef.value;
  element.removeEventListener('click', handleClick);
});

このような重複は開発効率を下げるだけでなく、バグの温床にもなりかねません。

DOM 操作の効率的な管理の必要性

Reactivity System と DOM 操作を適切に連携させることは、Vue.js アプリケーションの性能に大きく影響します。

管理すべき要素

  • イベントリスナーの適切な登録・削除
  • メモリリークの防止
  • 要素のライフサイクルに合わせた処理実行

これらの課題を解決するために、カスタムディレクティブが有効な手段となります。

解決策

Nuxt でのカスタムディレクティブ実装方法

Nuxt でカスタムディレクティブを実装する基本的な手順をご紹介します。まず、ディレクティブの基本構造を理解しましょう。

typescript// plugins/directives.client.ts
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.vueApp.directive('custom', {
    // ディレクティブがバインドされた要素がDOMに挿入される前
    created(el, binding, vnode, prevVnode) {
      // 初期化処理
    },

    // バインドされた要素の親コンポーネントがマウントされる前
    beforeMount(el, binding, vnode, prevVnode) {
      // マウント前処理
    },

    // バインドされた要素の親コンポーネントがマウントされた後
    mounted(el, binding, vnode, prevVnode) {
      // マウント後処理(DOM操作を行う主要な場所)
    },

    // 親コンポーネントが更新される前
    beforeUpdate(el, binding, vnode, prevVnode) {
      // 更新前処理
    },

    // 親コンポーネントとその子要素が更新された後
    updated(el, binding, vnode, prevVnode) {
      // 更新後処理
    },

    // バインドされた要素の親コンポーネントがアンマウントされる前
    beforeUnmount(el, binding, vnode, prevVnode) {
      // アンマウント前処理
    },

    // 親コンポーネントがアンマウントされた後
    unmounted(el, binding, vnode, prevVnode) {
      // クリーンアップ処理
    },
  });
});

各フック関数では以下のパラメータが利用できます。

パラメータ説明
elディレクティブがバインドされる DOM 要素
bindingディレクティブの値、引数、修飾子などの情報
vnodeVue 仮想 DOM 要素
prevVnode前回の仮想 DOM 要素(更新時のみ)

プラグインを使った全体登録

プラグインとして登録することで、アプリケーション全体でディレクティブを利用できるようになります。

まず、プラグインファイルを作成します。

typescript// plugins/global-directives.client.ts
export default defineNuxtPlugin((nuxtApp) => {
  // フォーカスディレクティブの登録
  nuxtApp.vueApp.directive('focus', {
    mounted(el: HTMLElement) {
      // 要素がマウントされたら自動的にフォーカスを当てる
      el.focus();
    },
  });

  // 表示制御ディレクティブの登録
  nuxtApp.vueApp.directive('show', {
    mounted(el: HTMLElement, binding) {
      // binding.valueの値に応じて表示・非表示を制御
      el.style.display = binding.value ? 'block' : 'none';
    },
    updated(el: HTMLElement, binding) {
      // 値が更新された際の処理
      el.style.display = binding.value ? 'block' : 'none';
    },
  });
});

ファイル名に .client.ts を付けることで、クライアントサイドでのみ実行されるプラグインとして登録できます。

コンポーネント単位での局所的利用

特定のコンポーネントでのみ使用したいディレクティブは、コンポーネント内で直接定義することも可能です。

vue<!-- components/MyComponent.vue -->
<template>
  <div>
    <input v-local-focus type="text" />
    <button v-local-highlight="isActive">ボタン</button>
  </div>
</template>

<script setup lang="ts">
const isActive = ref(true);

// ローカルディレクティブの定義
const vLocalFocus = {
  mounted(el: HTMLInputElement) {
    el.focus();
  },
};

const vLocalHighlight = {
  mounted(el: HTMLElement, binding) {
    if (binding.value) {
      el.style.backgroundColor = '#ffeb3b';
    }
  },
  updated(el: HTMLElement, binding) {
    el.style.backgroundColor = binding.value
      ? '#ffeb3b'
      : '';
  },
};
</script>

ローカルディレクティブは v プレフィックスを付けた変数名で定義し、テンプレート内ではケバブケースで使用します。

図で実装パターンの使い分けを確認してみましょう。

mermaidflowchart TD
  decision{使用範囲は?}
  decision -->|全体| global[グローバル登録]
  decision -->|特定コンポーネント| local[ローカル定義]

  global --> plugins[plugins/ディレクトリ]
  plugins --> all[全コンポーネントで利用可能]

  local --> component[コンポーネント内定義]
  component --> specific[そのコンポーネントのみ]

使用範囲に応じて適切な実装パターンを選択することが重要です。

具体例

クリック外検出ディレクティブの実装

モーダルやドロップダウンメニューでよく使われる「要素外クリックで閉じる」機能を実装してみましょう。

まず、ディレクティブの実装を行います。

typescript// plugins/click-outside.client.ts
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.vueApp.directive('click-outside', {
    mounted(el: HTMLElement, binding) {
      // クリックイベントハンドラーを定義
      const clickHandler = (event: Event) => {
        // クリックされた要素が対象要素の外側かチェック
        if (!el.contains(event.target as Node)) {
          // 外側がクリックされた場合、バインドされた関数を実行
          binding.value(event);
        }
      };

      // ドキュメントにイベントリスナーを追加
      document.addEventListener('click', clickHandler);

      // クリーンアップ用に要素にハンドラーを保存
      (el as any)._clickOutsideHandler = clickHandler;
    },

    unmounted(el: HTMLElement) {
      // イベントリスナーを削除してメモリリークを防ぐ
      const handler = (el as any)._clickOutsideHandler;
      if (handler) {
        document.removeEventListener('click', handler);
        delete (el as any)._clickOutsideHandler;
      }
    },
  });
});

次に、実際の使用例を見てみましょう。

vue<!-- components/Modal.vue -->
<template>
  <div v-if="isOpen" class="modal-overlay">
    <div v-click-outside="closeModal" class="modal-content">
      <h2>モーダルタイトル</h2>
      <p>モーダルの内容がここに入ります。</p>
      <button @click="closeModal">閉じる</button>
    </div>
  </div>
</template>

<script setup lang="ts">
interface Props {
  isOpen: boolean;
}

interface Emits {
  (e: 'close'): void;
}

const props = defineProps<Props>();
const emit = defineEmits<Emits>();

const closeModal = () => {
  emit('close');
};
</script>

<style scoped>
.modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 1000;
}

.modal-content {
  background: white;
  padding: 2rem;
  border-radius: 8px;
  max-width: 500px;
  width: 90%;
}
</style>

要素の表示/非表示制御ディレクティブ

アニメーション効果を含む表示・非表示制御ディレクティブを作成してみましょう。

typescript// plugins/fade-toggle.client.ts
interface FadeOptions {
  duration?: number;
  show?: boolean;
}

export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.vueApp.directive('fade-toggle', {
    mounted(el: HTMLElement, binding) {
      const options: FadeOptions = binding.value || {};
      const duration = options.duration || 300;

      // 初期スタイルを設定
      el.style.transition = `opacity ${duration}ms ease-in-out`;
      el.style.opacity = options.show ? '1' : '0';

      // 要素にオプションを保存
      (el as any)._fadeOptions = options;
    },

    updated(el: HTMLElement, binding) {
      const options: FadeOptions = binding.value || {};
      const prevOptions = (el as any)._fadeOptions || {};

      // 表示状態が変更された場合のみアニメーション実行
      if (options.show !== prevOptions.show) {
        el.style.opacity = options.show ? '1' : '0';
        (el as any)._fadeOptions = options;
      }
    },
  });
});

使用例では、リアクティブなデータと連動させた表示制御を実装します。

vue<!-- components/FadeExample.vue -->
<template>
  <div>
    <button @click="toggleVisibility">
      {{ isVisible ? '非表示にする' : '表示する' }}
    </button>

    <div
      v-fade-toggle="{ show: isVisible, duration: 500 }"
      class="fade-content"
    >
      <p>フェードイン・フェードアウトする要素です。</p>
    </div>
  </div>
</template>

<script setup lang="ts">
const isVisible = ref(true);

const toggleVisibility = () => {
  isVisible.value = !isVisible.value;
};
</script>

<style scoped>
.fade-content {
  padding: 1rem;
  margin-top: 1rem;
  background-color: #e3f2fd;
  border-radius: 4px;
}
</style>

フォーカス管理ディレクティブ

アクセシビリティを向上させるフォーカス管理ディレクティブを実装します。

typescript// plugins/focus-management.client.ts
interface FocusOptions {
  auto?: boolean;
  delay?: number;
  condition?: boolean;
}

export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.vueApp.directive('auto-focus', {
    mounted(el: HTMLElement, binding) {
      const options: FocusOptions = binding.value || {
        auto: true,
      };

      // 条件が指定されていて、falseの場合は何もしない
      if (
        options.condition !== undefined &&
        !options.condition
      ) {
        return;
      }

      // 自動フォーカスが有効な場合
      if (options.auto !== false) {
        const delay = options.delay || 0;

        setTimeout(() => {
          // フォーカス可能な要素を探す
          const focusableElement = el.matches(
            'input, textarea, select, button, [tabindex]'
          )
            ? el
            : (el.querySelector(
                'input, textarea, select, button, [tabindex]'
              ) as HTMLElement);

          if (focusableElement) {
            focusableElement.focus();
          }
        }, delay);
      }
    },

    updated(el: HTMLElement, binding) {
      const options: FocusOptions = binding.value || {};
      const prevOptions = (el as any)._focusOptions || {};

      // 条件が変更されてtrueになった場合にフォーカスを実行
      if (options.condition && !prevOptions.condition) {
        const focusableElement = el.matches(
          'input, textarea, select, button, [tabindex]'
        )
          ? el
          : (el.querySelector(
              'input, textarea, select, button, [tabindex]'
            ) as HTMLElement);

        if (focusableElement) {
          focusableElement.focus();
        }
      }

      (el as any)._focusOptions = options;
    },
  });
});

このディレクティブの実用的な使用例をご紹介します。

vue<!-- components/SearchForm.vue -->
<template>
  <div>
    <form @submit.prevent="handleSubmit">
      <input
        v-auto-focus="{ auto: true, delay: 100 }"
        v-model="searchQuery"
        type="text"
        placeholder="検索キーワードを入力してください"
        class="search-input"
      />
      <button type="submit" class="search-button">
        検索
      </button>
    </form>

    <!-- 条件付きフォーカス制御の例 -->
    <div v-if="showAdvancedSearch">
      <input
        v-auto-focus="{
          condition: showAdvancedSearch,
          delay: 200,
        }"
        v-model="advancedQuery"
        type="text"
        placeholder="詳細検索"
        class="advanced-input"
      />
    </div>

    <button
      @click="showAdvancedSearch = !showAdvancedSearch"
    >
      {{
        showAdvancedSearch
          ? '詳細検索を隠す'
          : '詳細検索を表示'
      }}
    </button>
  </div>
</template>

<script setup lang="ts">
const searchQuery = ref('');
const advancedQuery = ref('');
const showAdvancedSearch = ref(false);

const handleSubmit = () => {
  console.log('検索実行:', {
    searchQuery: searchQuery.value,
    advancedQuery: advancedQuery.value,
  });
};
</script>

<style scoped>
.search-input,
.advanced-input {
  width: 300px;
  padding: 0.5rem;
  margin-right: 0.5rem;
  border: 1px solid #ccc;
  border-radius: 4px;
}

.search-button {
  padding: 0.5rem 1rem;
  background-color: #42b883;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

実装のポイント

  • 要素のライフサイクルに合わせた適切な処理実行
  • メモリリークを防ぐためのイベントリスナーの削除
  • 型安全性を確保するための TypeScript 活用
  • 条件分岐や遅延実行などの柔軟な制御オプション

これらの具体例を参考に、プロジェクトに応じたカスタムディレクティブを実装してみてください。

まとめ

カスタムディレクティブを活用することで、Nuxt アプリケーションの開発効率と保守性が大幅に向上します。

カスタムディレクティブの主要なメリット

メリット詳細
コードの再利用性一度実装したディレクティブを複数のコンポーネントで活用
関心の分離DOM 操作ロジックをコンポーネントから分離
宣言的な記述テンプレート内で直感的に DOM 制御を表現
メンテナンス性共通処理の一元管理により、修正が容易

活用シーンと今後の発展性

カスタムディレクティブは以下のような場面で特に威力を発揮します。

  • ユーザーインターフェースの操作性向上
  • アクセシビリティ機能の実装
  • パフォーマンス最適化のための DOM 制御
  • サードパーティライブラリとの統合

Vue 3 の Composition API と組み合わせることで、より柔軟で型安全なディレクティブの実装が可能になります。また、Nuxt の強力なプラグインシステムを活用すれば、チーム開発での標準化も進めやすくなるでしょう。

今回ご紹介した基本的な実装パターンを応用して、プロジェクト固有の要件に合わせたディレクティブを開発してみてください。適切に設計されたカスタムディレクティブは、開発チーム全体の生産性向上に大きく貢献することでしょう。

関連リンク