Vue.js: カスタムディレクティブ

最終更新:2026-10-20

カスタムディレクティブを使用すると、Vueのテンプレート構文を拡張し、v-で始まる特別な属性を使って、基盤となるDOMを直接操作することができます。Vueには、v-if、v-for、v-modelなどの組み込みディレクティブが用意されていますが、v-focus、v-permission、v-debounceなどの独自のディレクティブを作成することもできます。

カスタムディレクティブは、「低レベルのDOMツール」を作成するための強力な手段です。再利用可能なDOM操作を宣言型構文にカプセル化しています。5つの主要なライフサイクルフックを理解すれば、あらゆる種類のvディレクティブを作成できるようになります。

関連ディレクティブ:app.directive() v-model v-for v-if

1. 学習内容


2. 「5か所で繰り返される」権限ボタンの悪夢

(1) 課題:5つのボタンすべてで権限の確認が必要

Aliceの管理画面には、権限チェックが必要なボタンが5つありました:

ボタン5つ × 権限チェック5回 = 25行の繰り返しコード。新しいボタンを追加するには、さらにv-ifを記述する必要がある。

プロダクトマネージャーのチャーリー:

「アリス、管理画面には50個以上のボタンがあるよ。これをすっきりさせるには、『v-permission』ディレクティブが必要だね。」

(2) カスタム Vue ディレクティブを使用した解決策:1 v-permission

JS
// directives/permission.js
export const permission = {
  mounted(el, binding) {
    const { value } = binding  // 'user.create'
    const userPermissions = getCurrentUser().permissions || []
    
    if (!userPermissions.includes(value)) {
      el.parentNode?.removeChild(el)  // If you don't have permission, remove it.
    }
  }
}
JS
// main.js
import { permission } from './directives/permission'
app.directive('permission', permission)

50個のボタン、50個のv-permission—50個のv-ifを使うよりも50%簡潔です。

(3) 収益

カスタムディレクティブの後に:


3. カスタムコマンドの基礎

(1) 登録方法 3 通り

JS
// 2. Abbreviation(mounted + updated)
app.directive('color', (el, binding) => {
  el.style.color = binding.value
})

(2) ライフサイクルの5つの主要なフック

JS
app.directive('demo', {
  // 1. created(Command Creation)
  created(el, binding) {
    console.log('1. Command Creation')
  },
  
  // 2. beforeMount(Before mounting the element)
  beforeMount(el) {
    console.log('2. Before Mounting')
  },
  
  // 3. mounted(The element has been mounted)⭐ Most Commonly Used
  mounted(el, binding) {
    console.log('3. Mounted')
  },
  
  // 4. beforeUpdate(Before the dependency update)
  beforeUpdate(el, binding) {
    console.log('4. Before the update')
  },
  
  // 5. updated(After the dependency update)
  updated(el, binding) {
    console.log('5. Updated')
  },
  
  // 6. beforeUnmount(Before Uninstalling)
  beforeUnmount(el) {
    console.log('6. Before Uninstalling')
  },
  
  // 7. unmounted(After uninstallation)⭐ For cleaning
  unmounted(el) {
    console.log('7. Uninstalled')
  }
})

(3) フックパラメータの詳細な説明

JS
// el, binding, vnode, prevVnode 4 parameter
mounted(el, binding, vnode, prevVnode) {
  // el: Elements Bound to Commands
  el.style.color = 'red'
  
  // binding: Instruction Information Object
  binding.value    // Instruction Value, e.g. v-foo="bar" → bar
  binding.arg      // Parameters, e.g. v-foo:arg → 'arg'
  binding.modifiers  // Modifiers, e.g. v-foo.bar → { bar: true }
  binding.instance  // Component Instances That Use Commands
  binding.dir       // Instruction-Defined Objects
  
  // vnode: Vue Virtual Node(Generally not used)
  // prevVnode: Previous Virtual Node
}

▶ サンプル:v-focus の完全な実装

JS
export const focus = {
  mounted(el, binding) {
    if (binding.value === false) return
    if (binding.arg) {
      setTimeout(() => el.focus(), parseInt(binding.arg))
    } else {
      el.focus()
    }
  }
}
▶ 試してみよう

出力:

TEXT
初期表示: コマンドを付けた input がそのまま描画される
マウント直後に el.focus() が走り、入力欄へフォーカス
v-focus:500 なら 500ms 後、値が false なら何もしない

4. 実用的な5つの必須コマンド

(1) v-focus:オートフォーカス

JS
// directives/focus.js
export const focus = {
  mounted(el, binding) {
    if (binding.value !== false) {
      el.focus()
    }
  }
}

(2) v-permission:権限制御

JS
// directives/permission.js
import { getCurrentUser } from '@/utils/auth'

export const permission = {
  mounted(el, binding) {
    const { value, modifiers } = binding
    const user = getCurrentUser()
    
    // value: String 'user.create' or Array ['user.create', 'user.delete']
    // modifiers.disable: Disable, not remove
    const required = Array.isArray(value) ? value : [value]
    const hasPermission = required.every(p => 
      user.permissions?.includes(p)
    )
    
    if (!hasPermission) {
      if (modifiers.disable) {
        el.disabled = true
        el.title = 'No permission'
      } else {
        el.parentNode?.removeChild(el)
      }
    }
  }
}

(3) v-debounce:イベントのデバウンス

JS
// directives/debounce.js
export const debounce = {
  mounted(el, binding) {
    const { value, arg = 300 } = binding
    
    if (typeof value !== 'function') {
      console.warn('v-debounce: value must be a function')
      return
    }
    
    let timer = null
    el.__debounceTimer__ = timer
    
    el.addEventListener('click', () => {
      clearTimeout(timer)
      timer = setTimeout(() => value(), arg)
      el.__debounceTimer__ = timer
    })
  },
  
  unmounted(el) {
    if (el.__debounceTimer__) {
      clearTimeout(el.__debounceTimer__)
    }
  }
}

(4) v-copy:クリックしてコピー

JS
// directives/copy.js
export const copy = {
  mounted(el, binding) {
    el.addEventListener('click', async () => {
      try {
        await navigator.clipboard.writeText(binding.value)
        const original = el.textContent
        el.textContent = 'Copied!'
        setTimeout(() => { el.textContent = original }, 1500)
      } catch (err) {
        console.error('Copy failed:', err)
      }
    })
  }
}

(5) v-lazy-load: 画像の遅延読み込み

JS
// directives/lazyLoad.js
export const lazyLoad = {
  mounted(el, binding) {
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        el.src = binding.value
        observer.unobserve(el)
      }
    })
    observer.observe(el)
    el.__observer__ = observer
  },
  
  unmounted(el) {
    el.__observer__?.disconnect()
  }
}

▶ サンプル:v-debounce の完全な実装

JS
export const debounce = {
  mounted(el, binding) {
    const fn = binding.value
    const delay = parseInt(binding.arg) || 300
    
    if (typeof fn !== 'function') {
      console.warn('[v-debounce] value must be a function')
      return
    }
    
    let timer = null
    el.addEventListener('click', () => {
      clearTimeout(timer)
      timer = setTimeout(() => fn(), delay)
    })
    el._debounceTimer = timer
  },
  
  unmounted(el) {
    if (el._debounceTimer) clearTimeout(el._debounceTimer)
  }
}
▶ 試してみよう

出力:

TEXT
ボタン表示: クリックで handler が呼ばれる
連続クリックでは最後の 1 回だけ 300ms 後に実行(v-debounce:500 なら 500ms)
アンマウント時にタイマーを破棄してリークを防ぐ

5. 命令のパラメータ、修飾子、および値

(1) 3種類のコマンド

VUE
<!-- 1. v-directive="value" (value) -->
<input v-foo="username">

<!-- 2. v-directive:arg(Parameters,Fixed String) -->
<input v-foo:delay="500">

<!-- 3. v-directive.modifier(Modifiers,Boolean objects) -->
<input v-foo.bar>

<!-- 4. Combination -->
<input v-foo:delay.bar="500">

(2) JSの受信方法

JS
app.directive('demo', (el, binding) => {
  // v-demo="123"
  binding.value  // 123
  
  // v-demo:abc
  binding.arg  // 'abc'
  
  // v-demo.foo
  binding.modifiers  // { foo: true }
  
  // v-demo:abc.foo="123"
  binding.value    // 123
  binding.arg       // 'abc'
  binding.modifiers // { foo: true }
})

(3) 5つの主要な組み合わせシナリオ

VUE
<!-- Scene 1:v-permission:disable -->
<button v-permission:disable="'user.create'">
  <!-- arg='disable', value='user.create' -->
</button>

<!-- Scene 2:v-debounce:500 -->
<button v-debounce:500="handler">
  <!-- arg='500'(500ms Image Stabilization) -->
</button>

<!-- Scene 3:v-once.lazy -->
<img v-once.lazy="imageUrl">
<!-- modifiers.lazy=true, value=imageUrl -->
</template>

▶ サンプル:v-permission の完全な実装

JS
import { getCurrentUser } from '@/utils/auth'

export const permission = {
  mounted(el, binding) {
    const { value, modifiers } = binding
    const user = getCurrentUser()
    const required = Array.isArray(value) ? value : [value]
    const ok = required.every(p => user.permissions?.includes(p))
    
    if (!ok) {
      if (modifiers.disable) {
        el.disabled = true
        el.style.opacity = '0.5'
        el.title = 'No permission'
      } else {
        el.parentNode?.removeChild(el)
      }
    }
  },
  
  updated(el, binding) {
    // Permissions may change(User Role Switching),Re-examine
    this.mounted(el, binding)
  }
}
▶ 試してみよう

出力:

TEXT
権限あり: ボタンはそのまま表示されクリックできる
権限なし: ボタンが DOM から削除される
v-permission.disable なら disabled + 透明度 0.5 + title No permission

6. 総合演習:組み込みコマンドを使用するための5つのヒント

▶ サンプル:組み込みコマンドを使用するための5つのヒント

JS
// Draw on Vue Implementation of Built-in Instructions
import { createApp } from 'vue'

const app = createApp({})

// 1. v-show(Control display)
app.directive('show', {
  mounted(el, binding) { el.style.display = binding.value ? '' : 'none' },
  updated(el, binding) { el.style.display = binding.value ? '' : 'none' }
})

// 2. v-text(Settings textContent)
app.directive('text', {
  mounted(el, binding) { el.textContent = binding.value },
  updated(el, binding) { el.textContent = binding.value }
})

// 3. v-html(Settings innerHTML,XSS Risks)
app.directive('html', {
  mounted(el, binding) { el.innerHTML = binding.value },
  updated(el, binding) { el.innerHTML = binding.value }
})

// 4. v-once(Render only once)
app.directive('once', {
  mounted(el, binding, vnode) {
    if (binding.value !== undefined) {
      vnode.context[binding.arg] = binding.value
    }
  }
})
▶ 試してみよう

出力:

TEXT
v-show: 値に応じて display を none と空文字で切り替え
v-text / v-html: textContent / innerHTML を binding.value で更新
v-once: マウント時の 1 回だけ値を反映

(1) 📊 よくある5つの間違いに関するクイックリファレンス

エラー 症状 解決策
「v-」を含まないディレクティブ名 機能しない v-focus(focusではない)
value は関数ではありません 実行しないでください v-debounce="handler" を確認してください
マウントされていないリソースのクリーンアップ メモリリーク タイマー/オブザーバーのクリーンアップ
Vueの組み込みディレクティブのオーバーライド エラー 「v-if」などの名前ではない
コマンド値の変更が認識されない 1回のみマウントされる updated フックを使用している

(2) 📊 5つの主要なパフォーマンス指標の比較

実装 再利用性 パフォーマンス 適用性
v-if + ユーティリティ関数 ❌ ⭐⭐⭐ 1回限り
カスタムコマンド ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ DOMツール
グローバルコンポーネント ⭐⭐⭐ ⭐⭐⭐ 複雑なUI
コンポーザブル ⭐⭐⭐⭐ ⭐⭐⭐⭐ ビジネスロジック
ピニア ⭐⭐⭐⭐ ⭐⭐⭐ 世界的な状況

関連ディレクティブ:app.directive() v-model v-for v-if

❓ よくある質問

Q カスタムディレクティブとコンポーネントのどちらを選ぶべきですか?
A 低レベルのDOM操作(フォーカス、スクロール、キャンバスなど)にはディレクティブを使用します。複雑なUI(状態、イベント、データを含むもの)にはコンポーネントを使用します。ディレクティブはステートレスであるのに対し、コンポーネントはステートフルです。
Q ディレクティブの value はリアクティブですか?
A value の変更により、updated フックがトリガーされます。value の変更を監視するには、watch(binding.value) を使用するか、updated フック内で新旧の値を比較してください。
Q v-permission:disable はどのように記述しますか?
A arg='disable'、modifiers.disable=true と記述します。ディレクティブ binding.modifiers.disable によって、これを無効にするか削除するかが決まります。
Q ディレクティブで TypeScript を使用できますか?
A はい。Vue 3.3 以降では、defineDirective および TypeScript のジェネリックがサポートされています。ただし、通常は JavaScript オブジェクトを使用するほうが簡単です。
Q グローバルディレクティブとローカルディレクティブのどちらを選べばよいですか?
A 業務固有の機能(v-permission)にはローカルディレクティブを使用してください。汎用的なツール(v-focus / v-copy)にはグローバルディレクティブを使用してください。このチュートリアルでは、メンテナンスが容易なローカルディレクティブの使用を推奨しています。
Q コマンドは参照を渡すことができますか?
A はい。<input v-my-directive="myRef" />のように、コマンド内のbinding.valueが参照オブジェクトとなります。ただし、これは推奨されません(アンチパターン)。
Q VueUse ライブラリにはディレクティブはありますか?
A はい。vFocus / vClickOutside / vLazyLoad / vInfiniteScroll などはすべて、VueUse が提供するディレクティブです。独自に作成するよりも、VueUse を使用することをお勧めします。

📖 まとめ


📝 練習問題

  1. 基礎問題(難易度:⭐)

    v-focus ディレクティブの実装:

    • arg パラメータ(ミリ秒単位の遅延)を受け付けます
    • 修飾子を受け付けます(prevent:デフォルトの動作を無効にします)
  2. 上級問題(難易度:⭐⭐)

    v-permissionのフルバージョンの実装:

    • 単一の権限文字列に対応:v-permission="'user.create'"
    • 権限の配列に対応しています:v-permission="['user.read', 'user.write']"
    • サポートされている修飾子:.disable(削除するのではなく無効化します)
    • currentUser を使用する場合(Injection による提供/注入)
  3. チャレンジ問題(難易度:⭐⭐⭐)

    完全な「命令セット」を実装する:

    1. 5つのディレクティブ:v-focus / v-permission / v-debounce / v-copy / v-lazy-load
    2. 各命令の完全な実装 + TypeScriptの型
    3. directives/index.js からすべてをエクスポートする
    4. 5つのテストケース(Vitestを使用)
    5. 実際のシナリオでの活用(Eコマースのバックエンドにおける5つの異なるシナリオ)
    6. 各コマンドのAPIおよびパラメータを文書化する
Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%