🎯

Vue 3のカスタムディレクティブをマスターしよう

に公開

Vue 3のカスタムディレクティブをマスターしよう

Vue 3のカスタムディレクティブについて、実用的な例とベストプラクティスを交えながら詳しく解説します。カスタムディレクティブを活用することで、DOM操作を効率的に行い、再利用可能な機能を実装できます。

カスタムディレクティブとは?

カスタムディレクティブは、Vueの組み込みディレクティブ(v-model、v-ifなど)を拡張し、DOM要素に対して直接的な操作を行うための機能です。主に以下の用途で使用されます:

  • DOM要素の直接操作
  • サードパーティライブラリとの統合
  • 再利用可能なDOM操作ロジックの実装

ディレクティブのライフサイクル

Vue 3のカスタムディレクティブには以下のライフサイクルフックがあります:

const myDirective = {
  // 要素がDOMに挿入される前に呼ばれる
  created(el, binding, vnode, prevVnode) {},
  
  // 要素がDOMに挿入された後に呼ばれる
  mounted(el, binding, vnode, prevVnode) {},
  
  // 親コンポーネントのVNodeが更新される前に呼ばれる
  beforeUpdate(el, binding, vnode, prevVnode) {},
  
  // 親コンポーネントと子要素のVNodeが更新された後に呼ばれる
  updated(el, binding, vnode, prevVnode) {},
  
  // 要素がDOMから削除される前に呼ばれる
  beforeUnmount(el, binding, vnode, prevVnode) {},
  
  // 要素がDOMから削除された後に呼ばれる
  unmounted(el, binding, vnode, prevVnode) {}
}

基本的なカスタムディレクティブの例

1. フォーカスディレクティブ

// main.js
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

// フォーカスディレクティブ
app.directive('focus', {
  mounted(el) {
    el.focus()
  }
})

app.mount('#app')
<!-- App.vue -->
<template>
  <div>
    <input v-focus placeholder="自動でフォーカスされます" />
  </div>
</template>

2. 色変更ディレクティブ

// main.js
app.directive('color', {
  mounted(el, binding) {
    el.style.color = binding.value
  },
  updated(el, binding) {
    el.style.color = binding.value
  }
})
<template>
  <div>
    <p v-color="'red'">赤いテキスト</p>
    <p v-color="dynamicColor">動的な色</p>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const dynamicColor = ref('blue')
</script>

実用的なカスタムディレクティブの例

1. クリックアウサイドディレクティブ

// directives/clickOutside.js
export const clickOutside = {
  mounted(el, binding) {
    el.clickOutsideEvent = function(event) {
      if (!(el === event.target || el.contains(event.target))) {
        binding.value(event)
      }
    }
    document.addEventListener('click', el.clickOutsideEvent)
  },
  unmounted(el) {
    document.removeEventListener('click', el.clickOutsideEvent)
  }
}
<template>
  <div>
    <div v-click-outside="closeModal" class="modal">
      <p>モーダルコンテンツ</p>
      <button @click="closeModal">閉じる</button>
    </div>
  </div>
</template>

<script setup>
const closeModal = () => {
  console.log('モーダルを閉じます')
}
</script>

2. 無限スクロールディレクティブ

// directives/infiniteScroll.js
export const infiniteScroll = {
  mounted(el, binding) {
    const options = {
      root: null,
      rootMargin: '0px',
      threshold: 1.0
    }
    
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          binding.value()
        }
      })
    }, options)
    
    observer.observe(el)
    el._observer = observer
  },
  unmounted(el) {
    if (el._observer) {
      el._observer.disconnect()
    }
  }
}
<template>
  <div>
    <div v-for="item in items" :key="item.id" class="item">
      {{ item.content }}
    </div>
    <div v-infinite-scroll="loadMore" class="loading-trigger">
      読み込み中...
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'

const items = ref([])
let page = 1

const loadMore = async () => {
  // 新しいデータを読み込む
  const newItems = await fetchItems(page++)
  items.value.push(...newItems)
}

const fetchItems = async (page) => {
  // APIからデータを取得
  return [{ id: page, content: `アイテム ${page}` }]
}

onMounted(() => {
  loadMore()
})
</script>

3. ドラッグアンドドロップディレクティブ

// directives/draggable.js
export const draggable = {
  mounted(el, binding) {
    let isDragging = false
    let startX = 0
    let startY = 0
    let initialX = 0
    let initialY = 0
    
    el.style.position = 'absolute'
    el.style.cursor = 'move'
    
    const handleMouseDown = (e) => {
      isDragging = true
      startX = e.clientX
      startY = e.clientY
      initialX = el.offsetLeft
      initialY = el.offsetTop
      
      document.addEventListener('mousemove', handleMouseMove)
      document.addEventListener('mouseup', handleMouseUp)
    }
    
    const handleMouseMove = (e) => {
      if (!isDragging) return
      
      const deltaX = e.clientX - startX
      const deltaY = e.clientY - startY
      
      el.style.left = initialX + deltaX + 'px'
      el.style.top = initialY + deltaY + 'px'
    }
    
    const handleMouseUp = () => {
      isDragging = false
      document.removeEventListener('mousemove', handleMouseMove)
      document.removeEventListener('mouseup', handleMouseUp)
      
      if (binding.value && typeof binding.value === 'function') {
        binding.value({
          x: el.offsetLeft,
          y: el.offsetTop
        })
      }
    }
    
    el.addEventListener('mousedown', handleMouseDown)
    el._cleanup = () => {
      el.removeEventListener('mousedown', handleMouseDown)
    }
  },
  unmounted(el) {
    if (el._cleanup) {
      el._cleanup()
    }
  }
}
<template>
  <div class="container">
    <div 
      v-draggable="onDragEnd" 
      class="draggable-box"
    >
      ドラッグしてください
    </div>
  </div>
</template>

<script setup>
const onDragEnd = (position) => {
  console.log('新しい位置:', position)
}
</script>

<style>
.container {
  position: relative;
  height: 400px;
  border: 1px solid #ccc;
}

.draggable-box {
  width: 100px;
  height: 100px;
  background-color: #007bff;
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 4px;
}
</style>

4. 遅延読み込みディレクティブ

// directives/lazyLoad.js
export const lazyLoad = {
  mounted(el, binding) {
    const options = {
      root: null,
      rootMargin: '50px',
      threshold: 0.1
    }
    
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const img = entry.target
          img.src = binding.value
          img.classList.remove('lazy')
          observer.unobserve(img)
        }
      })
    }, options)
    
    observer.observe(el)
    el._observer = observer
  },
  unmounted(el) {
    if (el._observer) {
      el._observer.disconnect()
    }
  }
}
<template>
  <div>
    <img 
      v-lazy-load="imageUrl" 
      class="lazy"
      alt="遅延読み込み画像"
    />
  </div>
</template>

<script setup>
const imageUrl = 'https://example.com/image.jpg'
</script>

<style>
.lazy {
  opacity: 0;
  transition: opacity 0.3s;
}

.lazy:not(.lazy) {
  opacity: 1;
}
</style>

5. 権限チェックディレクティブ

// directives/permission.js
export const permission = {
  mounted(el, binding) {
    const { value } = binding
    const userPermissions = getUserPermissions() // ユーザーの権限を取得
    
    if (!hasPermission(userPermissions, value)) {
      el.style.display = 'none'
      // または要素を削除
      // el.remove()
    }
  },
  updated(el, binding) {
    const { value } = binding
    const userPermissions = getUserPermissions()
    
    if (hasPermission(userPermissions, value)) {
      el.style.display = ''
    } else {
      el.style.display = 'none'
    }
  }
}

function getUserPermissions() {
  // 実際の実装では、ストアやAPIから取得
  return ['read', 'write'] // 例
}

function hasPermission(userPermissions, requiredPermission) {
  return userPermissions.includes(requiredPermission)
}
<template>
  <div>
    <button v-permission="'admin'">管理者のみ表示</button>
    <button v-permission="'write'">編集権限が必要</button>
    <button v-permission="'read'">読み取り権限が必要</button>
  </div>
</template>

ディレクティブの引数と修飾子

引数の使用

// main.js
app.directive('pin', {
  mounted(el, binding) {
    el.style.position = 'fixed'
    el.style.top = binding.arg + 'px'
  }
})
<template>
  <div v-pin:top="200">上から200pxの位置に固定</div>
</template>

修飾子の使用

// main.js
app.directive('on', {
  mounted(el, binding) {
    const { arg, modifiers, value } = binding
    const eventName = arg || 'click'
    
    // 修飾子に基づいてイベントリスナーを設定
    if (modifiers.once) {
      el.addEventListener(eventName, value, { once: true })
    } else if (modifiers.capture) {
      el.addEventListener(eventName, value, { capture: true })
    } else {
      el.addEventListener(eventName, value)
    }
  }
})
<template>
  <button v-on:click.once="handleClick">一度だけクリック可能</button>
  <button v-on:click.capture="handleClick">キャプチャモード</button>
</template>

ベストプラクティス

1. 適切なライフサイクル管理

// ✅ 良い例:適切にクリーンアップ
export const myDirective = {
  mounted(el, binding) {
    const handler = binding.value
    el._handler = handler
    document.addEventListener('click', handler)
  },
  unmounted(el) {
    if (el._handler) {
      document.removeEventListener('click', el._handler)
    }
  }
}

2. パフォーマンスの考慮

// ✅ 良い例:IntersectionObserverを使用
export const lazyLoad = {
  mounted(el, binding) {
    const observer = new IntersectionObserver((entries) => {
      // 必要な時のみ処理
    })
    observer.observe(el)
    el._observer = observer
  },
  unmounted(el) {
    el._observer?.disconnect()
  }
}

3. 型安全性(TypeScript使用時)

// directives/types.ts
export interface DirectiveBinding<T = any> {
  value: T
  oldValue: T | null
  arg?: string
  modifiers: Record<string, boolean>
  instance: any
  dir: any
}

// directives/clickOutside.ts
export const clickOutside = {
  mounted(el: HTMLElement, binding: DirectiveBinding<(event: Event) => void>) {
    el.clickOutsideEvent = function(event: Event) {
      if (!(el === event.target || el.contains(event.target as Node))) {
        binding.value(event)
      }
    }
    document.addEventListener('click', el.clickOutsideEvent)
  },
  unmounted(el: HTMLElement) {
    document.removeEventListener('click', (el as any).clickOutsideEvent)
  }
}

4. ディレクティブの登録方法

// main.js - グローバル登録
import { createApp } from 'vue'
import App from './App.vue'
import { clickOutside, infiniteScroll } from './directives'

const app = createApp(App)

app.directive('click-outside', clickOutside)
app.directive('infinite-scroll', infiniteScroll)

app.mount('#app')
<!-- コンポーネント内でのローカル登録 -->
<script setup>
import { clickOutside } from './directives/clickOutside'

// ローカル登録
const vClickOutside = clickOutside
</script>

<template>
  <div v-click-outside="handleClickOutside">
    コンテンツ
  </div>
</template>

まとめ

Vue 3のカスタムディレクティブは、以下の利点を提供します:

  • DOM操作の効率化: 直接的なDOM操作を可能にする
  • 再利用性: 複数のコンポーネントで同じ機能を共有
  • パフォーマンス: 適切なライフサイクル管理でメモリリークを防止
  • 柔軟性: 引数や修飾子でカスタマイズ可能

カスタムディレクティブを活用することで、より効率的で保守性の高いVueアプリケーションを構築できます。ただし、過度な使用は避け、適切な場面で使用することが重要です。

参考リンク

Discussion