Vueのカスタムディレクティブとは、v-focus のように自分で名前を付けて作れるディレクティブで、要素(DOM)を直接操作する処理を部品として再利用する仕組みです。 mounted や updated などのフックを持つオブジェクトを1つ書き、script setup なら vFocus のように v で始まる変数にするだけで、テンプレートで v-focus として使えます。

この記事では、コピーして使える5本(v-focus・v-highlight・v-click-outside・v-debounce・v-intersect)を、script setup と app.directive の両方の登録方法で載せます。さらに created から unmounted までの7フックがどの順で呼ばれるかの実行ログ、binding(value / oldValue / arg / modifiers / instance)の実際の中身、unmounted でリスナーを解除した場合としない場合の差、TypeScript の型エラー文まで、動かした結果をそのまま示します。

確認環境:Ubuntu 24.04 / Node.js 22.22.2 / Vue 3.5.43 / Vite 8.3.2 / Vitest 5.0.3 + jsdom 30.1.1 + @vue/test-utils 2.5.1 / vue-tsc 3.3.12(TypeScript 6.0.3)。5本のディレクティブは、Vite でビルドしたページを Playwright 1.56.0 の Chromium 141(ヘッドレス)で開いて動作を確かめています。

カスタムディレクティブの早見表

やりたいこと 書き方
script setup で定義する const vFocus: Directive = { mounted(el) { el.focus() } }(v で始まるキャメルケースの変数)
テンプレートで使う v-focus(vFocus → v-focus、vClickOutside → v-click-outside)
アプリ全体に登録する app.directive('focus', vFocus)(名前は v- を除く)
Options API で登録する directives: { focus: vFocus }
値を渡す v-highlight="color" → binding.value
引数を渡す v-highlight:color="…" → binding.arg が "color"
引数を動的にする v-inspect:[side]="…" → binding.arg が side の中身
修飾子を付ける v-highlight.bold="…" → binding.modifiers が { bold: true }
前回の値と比べる binding.oldValue(beforeUpdate と updated で入る)
mounted と updated で同じ処理 オブジェクトの代わりに関数を1つ書く
リスナーやタイマーを片付ける unmounted で removeEventListener / disconnect / clearTimeout
型を付ける Directive<HTMLElement, string>(要素の型、値の型の順)

7つのフックが呼ばれるタイミングは次のとおりです(後半に実行ログがあります)。

フック 呼ばれるタイミング 向いている処理
created 要素が作られた直後(属性やイベントリスナーが付く前) 他のリスナーより先に登録したい処理
beforeMount 要素が DOM に入る直前 初期スタイルの設定
mounted 要素が DOM に入った後 focus、リスナー登録、Observer の開始
beforeUpdate 親コンポーネントの再描画で要素が更新される前 更新前のスクロール位置の記録
updated 親コンポーネントと子の更新が済んだ後 値の変化に合わせた再設定
beforeUnmount 要素が取り除かれる直前 終了前の状態の保存
unmounted 要素が取り除かれた後 リスナー解除、Observer の停止、タイマーの停止

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Vueカスタムディレクティブの作り方|v-focusと引数・修飾子」

最小の例:v-focusを3通りで登録する

入力欄を表示と同時にフォーカスする v-focus から始めます。ディレクティブの本体は、フック名をキーにしたオブジェクトです。

script setup:vで始まる変数にする

<!-- FocusMin.vue -->
<script setup lang="ts">
import type { Directive } from 'vue'

const vFocus: Directive<HTMLInputElement> = {
  mounted(el) {
    el.focus()
  },
}
</script>

<template>
  <input v-focus placeholder="表示と同時にフォーカス" />
</template>

script setup では、v で始まるキャメルケースの変数がそのままカスタムディレクティブとして使えます。 vFocus は v-focus、vClickOutside は v-click-outside になります。別ファイルから import した場合も同じで、import した名前が vFocus であれば登録の手続きは要りません。

app.directive:アプリ全体で使えるようにする

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { vFocus, vHighlight, vClickOutside, vDebounce, vIntersect } from './directives'

const app = createApp(App)
// 第1引数は v- を除いた名前。テンプレートでは v-focus と書く
app.directive('focus', vFocus)
app.directive('highlight', vHighlight)
app.directive('click-outside', vClickOutside)
app.directive('debounce', vDebounce)
app.directive('intersect', vIntersect)
app.mount('#app')

第1引数は v- を除いた名前です。 こうして登録すると、どのコンポーネントでも import なしで v-focus と書けます。

Options API:directivesオプション

<!-- OptionsDemo.vue -->
<script lang="ts">
import { defineComponent } from 'vue'
import { vFocus, vHighlight } from './directives'

export default defineComponent({
  // キーは v- を除いた名前
  directives: { focus: vFocus, highlight: vHighlight },
  data() {
    return { color: 'yellow' }
  },
})
</script>

<template>
  <input id="o" v-focus />
  <p id="oh" v-highlight="color">Options API</p>
</template>
登録方法 使える範囲 向いている場面
script setup の vXxx 変数(import を含む) そのコンポーネント 基本はこれ。どこから来たかが import で追える
app.directive('xxx', …) アプリ全体 ほぼ全画面で使うもの、ライブラリとして配るもの
directives: { xxx: … } そのコンポーネント Options API のコンポーネント

コピーして使えるディレクティブ5本

5本とも directives/ フォルダーに1ファイルずつ置き、vXxx という名前で export します。使う側は import するだけです。

1. v-focus:表示時と、条件が true になったときにフォーカス

// directives/focus.ts
import type { Directive } from 'vue'

type Field = HTMLInputElement | HTMLTextAreaElement

// v-focus / v-focus="条件" / v-focus.select(フォーカス時に全選択)
export const vFocus: Directive<Field, boolean | undefined, 'select'> = {
  mounted(el, { value, modifiers }) {
    if (value === false) return
    el.focus()
    if (modifiers.select) el.select()
  },
  updated(el, { value, oldValue, modifiers }) {
    // false → true に変わったときだけ動かす
    if (value && !oldValue) {
      el.focus()
      if (modifiers.select) el.select()
    }
  },
}
  • v-focus と値なしで書くと、表示と同時にフォーカスします。
  • v-focus="editing" と書くと、editing が false から true に変わったときにもフォーカスします(updated フック)。
  • .select 修飾子を付けると、フォーカスと同時に入力済みの文字を全選択します。

2. v-highlight:関数1つで書く省略形と、引数・修飾子

// directives/highlight.ts
import type { Directive } from 'vue'

// v-highlight="'yellow'" / v-highlight:color="'red'" / v-highlight.bold="'yellow'"
// 関数で書くと mounted と updated の両方で呼ばれる
export const vHighlight: Directive<HTMLElement, string, 'bold', 'background' | 'color'> = (el, binding) => {
  const { value, arg = 'background', modifiers } = binding
  if (arg === 'color') el.style.color = value
  else el.style.backgroundColor = value
  el.style.fontWeight = modifiers.bold ? 'bold' : ''
}

mounted と updated で同じ処理をするなら、オブジェクトではなく関数を1つ書けます。 この関数は mounted と updated の両方で呼ばれるので、値が変わるたびに色が付け直されます。

  • v-highlight="'yellow'":背景色を付ける(引数を省くと background)
  • v-highlight:color="'crimson'":引数で対象を文字色に切り替える
  • v-highlight.bold="'yellow'":修飾子で太字にする

3. v-click-outside:要素の外側のクリックで閉じる

// directives/clickOutside.ts
import type { Directive } from 'vue'

type Handler = (event: MouseEvent) => void
interface State {
  handler: Handler
  listener: (event: MouseEvent) => void
}

const states = new WeakMap<HTMLElement, State>()

// v-click-outside="close"
export const vClickOutside: Directive<HTMLElement, Handler> = {
  mounted(el, binding) {
    const state: State = {
      handler: binding.value,
      listener(event) {
        if (!el.contains(event.target as Node)) state.handler(event)
      },
    }
    states.set(el, state)
    document.addEventListener('click', state.listener)
  },
  updated(el, binding) {
    const state = states.get(el)
    if (state) state.handler = binding.value // 最新の関数に差し替える
  },
  unmounted(el) {
    const state = states.get(el)
    if (state) document.removeEventListener('click', state.listener)
    states.delete(el)
  },
}

document にリスナーを登録し、クリックされた場所が要素の外側ならハンドラーを呼びます。document に付けたリスナーは要素が消えても残るので、unmounted で必ず解除します(差は後半のログで示します)。

要素ごとの状態(登録したリスナー)は WeakMap に入れています。フックの間で情報を受け渡すとき、binding や vnode に書き込むのではなく、要素をキーにした WeakMap か、要素の dataset を使うと安全です。

4. v-debounce:入力が止まってから1回だけ呼ぶ

// directives/debounce.ts
import type { Directive } from 'vue'

type Handler = (event: Event) => void
interface State {
  handler: Handler
  listener: (event: Event) => void
  type: string
  timer: ReturnType<typeof setTimeout> | undefined
}

const states = new WeakMap<HTMLElement, State>()

// v-debounce="search"(input を 300ms 待つ)
// v-debounce:500="search"(待ち時間を引数で指定)
// v-debounce:500.change="search"(change イベントに付ける)
export const vDebounce: Directive<HTMLElement, Handler, 'change', string> = {
  mounted(el, { value, arg, modifiers }) {
    const wait = Number(arg ?? 300)
    const state: State = {
      handler: value,
      type: modifiers.change ? 'change' : 'input',
      timer: undefined,
      listener(event) {
        clearTimeout(state.timer)
        state.timer = setTimeout(() => state.handler(event), wait)
      },
    }
    states.set(el, state)
    el.addEventListener(state.type, state.listener)
  },
  updated(el, { value }) {
    const state = states.get(el)
    if (state) state.handler = value
  },
  unmounted(el) {
    const state = states.get(el)
    if (!state) return
    clearTimeout(state.timer) // 待機中のタイマーも止める
    el.removeEventListener(state.type, state.listener)
    states.delete(el)
  },
}

入力のたびにタイマーを掛け直し、指定した時間だけ入力が止まったらハンドラーを呼びます。検索欄で、1文字ごとに検索が走らないようにするときに使えます。引数(:500)は文字列として届くので、Number で数値に変換しています。

5. v-intersect:画面に入ったことを知らせる

// directives/intersect.ts
import type { Directive } from 'vue'

type Handler = (visible: boolean, entry: IntersectionObserverEntry) => void

const observers = new WeakMap<HTMLElement, IntersectionObserver>()

// v-intersect="onVisible" / v-intersect.once="onVisible"(最初に見えた1回だけ)
export const vIntersect: Directive<HTMLElement, Handler, 'once'> = {
  mounted(el, { value, modifiers }) {
    const observer = new IntersectionObserver((entries) => {
      for (const entry of entries) {
        value(entry.isIntersecting, entry)
        if (entry.isIntersecting && modifiers.once) observer.disconnect()
      }
    })
    observer.observe(el)
    observers.set(el, observer)
  },
  unmounted(el) {
    observers.get(el)?.disconnect()
    observers.delete(el)
  },
}

IntersectionObserver(要素が画面内に入ったかを監視するブラウザーの API)を包んだディレクティブです。.once を付けると、最初に見えた時点で監視を終了します。画像の遅延読み込みや、スクロールに合わせた表示に使えます。

5本をまとめて使う画面

// directives/index.ts
export { vFocus } from './focus'
export { vHighlight } from './highlight'
export { vClickOutside } from './clickOutside'
export { vDebounce } from './debounce'
export { vIntersect } from './intersect'
<!-- App.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { vFocus, vHighlight, vClickOutside, vDebounce, vIntersect } from './directives'

const keyword = ref('')
const editing = ref(false)
const menuOpen = ref(false)
const color = ref('yellow')
const title = ref('備品リスト')

function search(event: Event) {
  keyword.value = (event.target as HTMLInputElement).value
  console.log(`search: ${keyword.value}`)
}
function closeMenu() {
  if (menuOpen.value) console.log('click-outside: メニューを閉じる')
  menuOpen.value = false
}
function onVisible(visible: boolean) {
  console.log(`intersect: visible=${visible}`)
}
</script>

<template>
  <!-- 1. v-focus: 表示と同時にフォーカス -->
  <input id="q" v-focus v-debounce:500="search" placeholder="検索" />
  <p id="result">検索語: {{ keyword }}</p>

  <!-- 2. v-focus="条件".select: 編集に切り替えたらフォーカスして全選択 -->
  <button id="edit" @click="editing = true">編集</button>
  <input id="title" v-model="title" v-focus.select="editing" @blur="editing = false" />

  <!-- 3. v-highlight: 引数と修飾子 -->
  <p id="h1" v-highlight="color">背景色</p>
  <p id="h2" v-highlight:color.bold="'crimson'">文字色と太字</p>
  <button id="color" @click="color = 'lightgreen'">色を変える</button>

  <!-- 4. v-click-outside: 外側のクリックで閉じる -->
  <div id="menu" v-click-outside="closeMenu">
    <button id="toggle" @click="menuOpen = !menuOpen">メニュー</button>
    <ul v-if="menuOpen"><li>編集</li><li>削除</li></ul>
  </div>

  <!-- 5. v-intersect: 画面に入ったら知らせる -->
  <div style="height: 2000px"></div>
  <p id="bottom" v-intersect.once="onVisible">ここまでスクロールしました</p>
</template>

Chromiumで動かした結果

このページを Vite でビルドし、Playwright から Chromium で開いて操作しました。「console:」の行は、ページ側の console.log の出力です。

  console: intersect: visible=false
表示直後の activeElement #q
入力直後 検索語: 
  console: search: vue
700ms 後 検索語: vue
編集クリック後の activeElement #title 選択文字列 = 備品リスト
#h1 背景 rgb(255, 255, 0) / #h2 文字色 rgb(220, 20, 60) 太さ 700
色変更後 #h1 背景 rgb(144, 238, 144)
メニューを開く → li の数 2
メニュー内をクリック → li の数 2
  console: click-outside: メニューを閉じる
外側をクリック → li の数 0
スクロール前
  console: intersect: visible=true
2往復スクロール後
ディレクティブ 確認できたこと
v-focus 表示直後に #q がフォーカスされている。「編集」を押すと #title に移り、「備品リスト」が全選択されている
v-debounce 「vue」と3文字入力した直後は検索語が空のまま。700ms 後に search が1回だけ呼ばれた
v-highlight 背景が yellow、文字色が crimson、太さが 700(bold)。色を変えると背景も lightgreen に更新された
v-click-outside メニューの内側のクリックでは閉じず、外側のクリックで閉じた
v-intersect 表示直後は visible=false。下までスクロールすると visible=true。.once なので、2往復しても通知は1回だけ

7つのフックの発火順を実行ログで見る

7つのフックすべてにログを仕込んだ v-log を作り、コンポーネントのライフサイクルフックと並べて順番を調べます。

// log.ts
export const logs: string[] = []
export const log = (line: string) => logs.push(line)
<!-- HookLog.vue -->
<script setup lang="ts">
import { ref, onBeforeMount, onMounted, onBeforeUpdate, onUpdated, onBeforeUnmount, onUnmounted } from 'vue'
import type { Directive, DirectiveBinding } from 'vue'
import { log } from './log'

const show = ref(true)
const count = ref(1)
const other = ref(0)
defineExpose({ show, count, other })

function line(hook: string, el: HTMLElement, b: DirectiveBinding<number>) {
  const parent = el.parentNode ? 'あり' : 'なし'
  log(`  v-log ${hook.padEnd(13)} value=${b.value} oldValue=${b.oldValue} 親ノード=${parent} 表示="${el.textContent}"`)
}

const vLog: Directive<HTMLElement, number> = {
  created: (el, b) => line('created', el, b),
  beforeMount: (el, b) => line('beforeMount', el, b),
  mounted: (el, b) => line('mounted', el, b),
  beforeUpdate: (el, b) => line('beforeUpdate', el, b),
  updated: (el, b) => line('updated', el, b),
  beforeUnmount: (el, b) => line('beforeUnmount', el, b),
  unmounted: (el, b) => line('unmounted', el, b),
}

onBeforeMount(() => log('コンポーネント onBeforeMount'))
onMounted(() => log('コンポーネント onMounted'))
onBeforeUpdate(() => log('コンポーネント onBeforeUpdate'))
onUpdated(() => log('コンポーネント onUpdated'))
onBeforeUnmount(() => log('コンポーネント onBeforeUnmount'))
onUnmounted(() => log('コンポーネント onUnmounted'))
</script>

<template>
  <section>
    <p v-if="show" v-log="count">count は {{ count }}</p>
    <small>other は {{ other }}</small>
  </section>
</template>

テストでは、このコンポーネントをマウントし、count を 2 に変更 → other を 1 に変更 → show を false → show を true → アンマウント、の順に操作して、そのつど溜まったログを出力しました(Vitest + jsdom)。

--- 1. マウント
コンポーネント onBeforeMount
  v-log created       value=1 oldValue=undefined 親ノード=なし 表示="count は 1"
  v-log beforeMount   value=1 oldValue=undefined 親ノード=なし 表示="count は 1"
  v-log mounted       value=1 oldValue=undefined 親ノード=あり 表示="count は 1"
コンポーネント onMounted
--- 2. 値を 1 → 2 に変更
コンポーネント onBeforeUpdate
  v-log beforeUpdate  value=2 oldValue=1 親ノード=あり 表示="count は 1"
  v-log updated       value=2 oldValue=1 親ノード=あり 表示="count は 2"
コンポーネント onUpdated
--- 3. 値は同じで、同じコンポーネントの別の値を変更
コンポーネント onBeforeUpdate
  v-log beforeUpdate  value=2 oldValue=2 親ノード=あり 表示="count は 2"
  v-log updated       value=2 oldValue=2 親ノード=あり 表示="count は 2"
コンポーネント onUpdated
--- 4. v-if を false に
コンポーネント onBeforeUpdate
  v-log beforeUnmount value=2 oldValue=2 親ノード=あり 表示="count は 2"
  v-log unmounted     value=2 oldValue=2 親ノード=なし 表示="count は 2"
コンポーネント onUpdated
--- 5. v-if を true に戻す
コンポーネント onBeforeUpdate
  v-log created       value=2 oldValue=undefined 親ノード=なし 表示="count は 2"
  v-log beforeMount   value=2 oldValue=undefined 親ノード=なし 表示="count は 2"
  v-log mounted       value=2 oldValue=undefined 親ノード=あり 表示="count は 2"
コンポーネント onUpdated
--- 6. コンポーネントをアンマウント
コンポーネント onBeforeUnmount
  v-log beforeUnmount value=2 oldValue=undefined 親ノード=あり 表示="count は 2"
  v-log unmounted     value=2 oldValue=undefined 親ノード=あり 表示="count は 2"
コンポーネント onUnmounted

このログから読み取れることを整理します。

  • マウント時は created → beforeMount → mounted の順。 created と beforeMount の時点では親ノードがなく、mounted で初めて DOM に入っています。focus や要素のサイズ取得は mounted で行います。
  • ディレクティブの mounted は、コンポーネントの onMounted より先に呼ばれます。 更新時も、ディレクティブの updated → コンポーネントの onUpdated の順です。
  • beforeUpdate の時点では表示がまだ「count は 1」、updated では「count は 2」。 更新前の DOM を見たいときは beforeUpdate、更新後を見たいときは updated を使います。
  • 値が変わっていなくても、同じコンポーネントが再描画されると beforeUpdate と updated は呼ばれます(3 のログ。value も oldValue も 2)。updated の中で重い処理をするときは、binding.value !== binding.oldValue で変化を確かめてから行うと無駄がありません。
  • v-if で消えるときは beforeUnmount → unmounted。 戻すときは新しい要素として created からやり直しになり、oldValue は undefined に戻ります。
  • 4 では unmounted の時点で親ノードがなくなっています。6 のようにコンポーネントごと取り除かれる場合は、ルート要素ごとまとめて外されるため、unmounted の時点でも要素は元の親につながったままでした。unmounted では「親があるかどうか」に頼らず、自分が登録したものを片付ける処理だけを書くのが確実です。

bindingの中身を出力つきで確認する

フックの第2引数 binding に何が入るかを、4通りの書き方で出力します。

<!-- BindingDemo.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import type { Directive, DirectiveBinding } from 'vue'
import { log } from './log'

const msg = ref('こんにちは')
const side = ref('top')
const user = ref({ name: '佐藤', age: 30 })
defineExpose({ msg, side, user })

const vInspect: Directive<HTMLElement> = {
  mounted(el, binding) { dump('mounted', el, binding) },
  updated(el, binding) { dump('updated', el, binding) },
}

function dump(hook: string, el: HTMLElement, binding: DirectiveBinding) {
  log(`[${el.id}] ${hook}`)
  log(`  value     = ${JSON.stringify(binding.value)}`)
  log(`  oldValue  = ${JSON.stringify(binding.oldValue)}`)
  log(`  arg       = ${JSON.stringify(binding.arg)}`)
  log(`  modifiers = ${JSON.stringify(binding.modifiers)}`)
  log(`  instance  = msg を読める: ${(binding.instance as { msg: string } | null)?.msg}`)
  log(`  dir       = ${Object.keys(binding.dir).join(', ')}`)
}
</script>

<template>
  <p id="a" v-inspect>値なし</p>
  <p id="b" v-inspect:title.trim.upper="msg">引数と修飾子</p>
  <p id="c" v-inspect:[side]="1 + 1">動的引数</p>
  <p id="d" v-inspect="{ name: user.name, max: 10 }">オブジェクト</p>
</template>

テストでは、マウント直後のログを出力したあと、msg を「こんばんは」、side を "bottom" に変えて、updated のログ(value・oldValue・arg の行)を出力しました。

[a] mounted
  value     = undefined
  oldValue  = undefined
  arg       = undefined
  modifiers = {}
  instance  = msg を読める: こんにちは
  dir       = mounted, updated
[b] mounted
  value     = "こんにちは"
  oldValue  = undefined
  arg       = "title"
  modifiers = {"trim":true,"upper":true}
  instance  = msg を読める: こんにちは
  dir       = mounted, updated
[c] mounted
  value     = 2
  oldValue  = undefined
  arg       = "top"
  modifiers = {}
  instance  = msg を読める: こんにちは
  dir       = mounted, updated
[d] mounted
  value     = {"name":"佐藤","max":10}
  oldValue  = undefined
  arg       = undefined
  modifiers = {}
  instance  = msg を読める: こんにちは
  dir       = mounted, updated
--- msg と side を変更
[b] updated
  value     = "こんばんは"
  oldValue  = "こんにちは"
  arg       = "title"
[c] updated
  value     = 2
  oldValue  = 2
  arg       = "bottom"

(updated のログは [b] と [c] だけを抜き出しています。[a] と [d] も updated は呼ばれ、value と oldValue は同じ値でした。)

bindingプロパティの早見表

プロパティ 中身 実際の出力例
binding.value ="…" に書いた式の結果 v-inspect="1 + 1" → 2、値なしは undefined
binding.oldValue 更新前の値。beforeUpdate と updated で入る mounted では undefined、更新後は "こんにちは"
binding.arg : の後ろの引数 v-inspect:title → "title"、:[side] → "top"
binding.modifiers . で付けた修飾子のオブジェクト .trim.upper → {"trim":true,"upper":true}、なしは {}
binding.instance ディレクティブを使っているコンポーネントのインスタンス defineExpose した msg を読めた
binding.dir ディレクティブの定義オブジェクトそのもの キーは mounted, updated
  • value には式の結果が入ります。 v-inspect="1 + 1" は 2 でした。文字列を渡すときは v-highlight="'yellow'" のように、引用符の中にもう1組の引用符が必要です。
  • 複数の値を渡したいときはオブジェクトにします([d])。binding.value.name のように取り出せます。
  • 動的引数 :[side] は、side の中身が変わると arg も変わります("top" → "bottom")。このとき value は 2 のままなので、arg の変化に対応したい場合は updated で arg も見ます。
  • script setup のコンポーネントでは、instance から読めるのは defineExpose で公開したものだけです。 defineExpose を外して同じ出力をしたところ、instance.msg は undefined になりました。コンポーネントの値が必要なら、instance に頼らず value として渡すほうが見通しがよくなります。

フックの引数は全部で4つあります。第1引数 el は対象の要素、第2引数が binding、第3引数 vnode は要素を表す VNode、第4引数 prevVnode は更新前の VNode(beforeUpdate と updated だけ)です。公式ガイドでは、el 以外の引数は読み取り専用として扱い、変更しないよう案内されています。

unmountedでリスナーを解除する理由

v-click-outside のように、要素の外(document や window)へ登録したリスナーは、要素が消えても自動では外れません。 解除を書かない版と比べてみます。

<!-- LeakDemo.vue -->
<script setup lang="ts">
import type { Directive } from 'vue'
import { log } from './log'

// 解除を書いていない版
const vOutsideNoCleanup: Directive<HTMLElement, () => void> = {
  mounted(el, binding) {
    document.addEventListener('click', (event) => {
      if (!el.contains(event.target as Node)) binding.value()
    })
  },
}
</script>

<template>
  <div v-outside-no-cleanup="() => log('解除なし: 呼ばれた')">パネル</div>
</template>

比べる相手は、先ほどの vClickOutside を同じ形のテンプレートに付けたコンポーネントです。それぞれ「マウント → アンマウント」を3回繰り返したあと、document をクリックして、ハンドラーが何回呼ばれるかを数えました。

解除なし: 3回マウント→アンマウントした後に document をクリック → ハンドラー 3 回
解除あり: 3回マウント→アンマウントした後に document をクリック → ハンドラー 0 回

解除を書かない版では、もう画面にないコンポーネントのハンドラーが3回呼ばれました。 画面を開くたびにリスナーが1つずつ増えていくので、閉じたはずのメニューの処理が動いたり、メモリが少しずつ増えたりします。unmounted で removeEventListener を呼ぶ版では 0 回です。

mounted で始めたもの unmounted で行う片付け
document.addEventListener / window.addEventListener 同じ関数を渡して removeEventListener
el.addEventListener el.removeEventListener(要素ごと消えるなら必須ではないが、書いておくと確実)
new IntersectionObserver / ResizeObserver / MutationObserver disconnect()
setTimeout / setInterval clearTimeout / clearInterval
外部ライブラリのインスタンス そのライブラリの破棄用メソッド

removeEventListener には、登録したときと同じ関数を渡す必要があります。そのため、mounted で作った関数を WeakMap に保存し、unmounted で取り出しています。

TypeScriptで型を付ける:Directiveの型引数

Directive 型は4つの型引数を取ります。Vue 3.5.43 の型定義では次の順です。

順番 意味 例
1 対象の要素の型(el) HTMLElement、HTMLInputElement
2 値の型(binding.value) string、(event: MouseEvent) => void
3 使える修飾子の名前 'bold'、'once'
4 引数の型(binding.arg) string、決まった文字列だけを許すユニオン型

よく使うのは最初の2つで、Directive<HTMLElement, string> のように書きます。3つ目と4つ目まで書くと、テンプレートで修飾子や引数を書き間違えたときにも気づけます。

<!-- TypeNg.vue -->
<script setup lang="ts">
import { vFocus, vHighlight, vClickOutside } from './directives'
</script>

<template>
  <p v-highlight="123">A: 値の型が違う</p>
  <p v-highlight:size="'red'">B: 引数が違う</p>
  <p v-highlight.italic="'red'">C: 修飾子が違う</p>
  <div v-focus>D: 要素が違う</div>
  <div v-click-outside="'close'">E: 関数ではない</div>
</template>

npx vue-tsc --noEmit の出力です。

a2/TypeNg.vue(6,19): error TS2322: Type 'number' is not assignable to type 'string'.
a2/TypeNg.vue(7,18): error TS2322: Type '"size"' is not assignable to type '"background" | "color" | undefined'.
a2/TypeNg.vue(8,18): error TS2353: Object literal may only specify known properties, and 'italic' does not exist in type 'Partial<Record<"bold", boolean>>'.
a2/TypeNg.vue(10,25): error TS2322: Type 'string' is not assignable to type 'Handler'.
行 検出された内容
A 値が string のはずのところに number(TS2322)
B 引数 size は background / color に含まれない(TS2322)
C 修飾子 italic は定義にない(TS2353)
D エラーなし。要素の型(input 用のディレクティブを div に付けた)は、テンプレートではチェックされなかった
E 関数のはずのところに文字列(TS2322)

値・引数・修飾子の3つはテンプレートでチェックされ、要素の型はフックの中で el を扱うときの補完に使われる、という分担になっています。

app.directiveで登録したディレクティブに型を付ける

app.directive で登録したディレクティブは、そのままではテンプレートの型チェックの対象になりません。Vue の公式ガイド(TypeScript の章)にあるとおり、GlobalDirectives インターフェイスを拡張すると、値の型チェックが働くようになります。

// global.d.ts
import type { vFocus, vHighlight, vClickOutside, vDebounce, vIntersect } from './directives'

declare module 'vue' {
  interface GlobalDirectives {
    vFocus: typeof vFocus
    vHighlight: typeof vHighlight
    vClickOutside: typeof vClickOutside
    vDebounce: typeof vDebounce
    vIntersect: typeof vIntersect
  }
}

import なしで v-focus と v-highlight を使う次のコンポーネントで確かめました。

<!-- GlobalApp.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const color = ref('yellow')
</script>

<template>
  <!-- import なしで使える -->
  <input id="g" v-focus />
  <p id="gh" v-highlight="color">グローバル登録</p>
</template>

このファイルの v-highlight="color" を v-highlight="123" に書き換えて vue-tsc を実行した結果です。

global.d.ts vue-tsc の出力
あり a2/GlobalApp.vue(9,27): error TS2322: Type 'number' is not assignable to type 'string'.
なし エラーなし(型チェックされない)

コンポーネントに付けるときの注意

カスタムディレクティブはコンポーネントにも付けられ、その場合はコンポーネントのルート要素に適用されます。ルートが1つのコンポーネントと2つのコンポーネントで試します。

Single.vue はルートが p 要素1つだけのコンポーネント、Multi.vue はルートが2つあるコンポーネントです。

<!-- Multi.vue -->
<template>
  <h2>見出し</h2>
  <p>本文</p>
</template>
<!-- OnComponent.vue -->
<script setup lang="ts">
import { vHighlight } from './directives'
import Single from './Single.vue'
import Multi from './Multi.vue'
</script>

<template>
  <Single v-highlight="'yellow'" />
  <Multi v-highlight="'yellow'" />
</template>
ルート1つ: background=yellow
ルート2つ: h2 background=""
[Vue warn]: Runtime directive used on component with non-element root node. The directives will not function as intended.

ルートが2つあるコンポーネントでは、ディレクティブは適用されず、警告が出ました。 公式ガイドでも、コンポーネントへのカスタムディレクティブの使用は推奨されていません。コンポーネントの中の特定の要素に効かせたいときは、そのコンポーネントの中でディレクティブを付けるか、props で必要な値を渡す形にします。

Vue 2からのフック名対応表

Vue 2 のカスタムディレクティブを Vue 3 へ移すときは、フック名を書き換えます。Vue 3 移行ガイドに載っている対応は次のとおりです。

Vue 2 Vue 3 補足
(なし) created 新しく追加。属性やイベントリスナーが付く前に呼ばれる
bind beforeMount 名前の変更
inserted mounted 名前の変更
(なし) beforeUpdate 新しく追加
update (削除) updated を使う
componentUpdated updated 名前の変更
(なし) beforeUnmount 新しく追加
unbind unmounted 名前の変更

フック名がコンポーネントのライフサイクルと同じ並びになったので、覚えやすくなりました。もう1つの変更点として、コンポーネントのインスタンスは vnode.context ではなく binding.instance から参照します。登録も Vue.directive(…) から app.directive(…) に変わっています。

ディレクティブ・コンポーザブル・コンポーネントの使い分け

再利用の方法はほかにもあるので、どれを選ぶかの目安を表にします。

やりたいこと 向いている方法 理由
要素そのものを操作する(focus、スクロール、外部ライブラリの適用) カスタムディレクティブ 要素を直接受け取れ、テンプレートに1語足すだけで使える
状態と処理をまとめて再利用する(取得、カウンター、フォームの状態) コンポーザブル(use 関数) ref や computed を返せる
見た目ごと再利用する コンポーネント テンプレートとスロットを持てる
表示の切り替え、クラスやスタイルの付け替え v-if / v-show / :class / :style ビルトインで足りる

公式ガイドでは、カスタムディレクティブは DOM を直接操作する以外に方法がない場合に使い、できるだけ v-bind などのビルトインのテンプレート機能を使うことがすすめられています。「要素に対する低レベルの操作」に絞って使うと、ディレクティブのよさが活きます。

つまずきやすい箇所と直し方

起きること 理由 直し方
v-focus と書いても何も起きない 変数名が focus や Focus になっている script setup では vFocus と v で始める
created で el.focus() してもフォーカスが移らない まだ DOM に入っていない(親ノード=なし) mounted で行う
文字列を渡したのに色が付かない v-highlight="yellow" は変数 yellow を読む(Property "yellow" was accessed during render but is not defined on instance. の警告が出る) v-highlight="'yellow'" と引用符を重ねる
値が同じなのに updated が何度も呼ばれる コンポーネントの再描画のたびに呼ばれる value !== oldValue を確かめる
画面を閉じてもハンドラーが動く document に付けたリスナーが残っている unmounted で removeEventListener
引数の数値が計算に使えない :500 は文字列 "500" として届く Number で変換する
ルートが複数のコンポーネントで効かない 適用先の要素を決められない コンポーネントの中の要素に付ける

よくある質問

Q. mounted と updated の両方に同じ処理を書くのが手間です。まとめられますか? A. ディレクティブをオブジェクトではなく関数で書くと、その関数が mounted と updated の両方で呼ばれます。この記事の v-highlight がその形です。片付け(unmounted)が必要なものはオブジェクトで書きます。

Q. ディレクティブの中で ref や computed を使えますか? A. ディレクティブのフックは setup の外で呼ばれるので、状態を持って返すような使い方には向きません。状態が必要な処理はコンポーザブルに置き、要素の操作だけをディレクティブに任せると、役割がはっきりします。

Q. 要素ごとの情報はどこに保存すればよいですか? A. 要素をキーにした WeakMap が扱いやすいです。要素がなくなれば WeakMap の中身も回収の対象になります。文字列で足りる情報なら、要素の dataset に入れる方法もあります。

Q. 同じ要素に複数のカスタムディレクティブを付けられますか? A. 付けられます。この記事の App.vue でも、検索欄に v-focus と v-debounce:500 を同時に付けて、両方が働くことを確かめています。

まとめ

  • カスタムディレクティブは、フックを持つオブジェクト。script setup では vFocus のように v で始まる変数にすると v-focus として使える
  • アプリ全体で使うなら app.directive('focus', vFocus)。名前は v- を除いて登録する
  • フックは created → beforeMount → mounted、更新時は beforeUpdate → updated、終了時は beforeUnmount → unmounted。DOM に触る処理は mounted から
  • binding には value / oldValue / arg / modifiers / instance / dir が入る。値が同じでも updated は呼ばれるので、oldValue と比べる
  • document や window に付けたリスナー、Observer、タイマーは unmounted で片付ける
  • 型は Directive<HTMLElement, string>。修飾子と引数の型まで書くと、テンプレートの書き間違いを vue-tsc が見つけてくれる
  • Vue 2 からは bind → beforeMount、inserted → mounted、componentUpdated → updated、unbind → unmounted に書き換える

参考

  • Vue.js 公式ガイド「カスタムディレクティブ」 https://ja.vuejs.org/guide/reusability/custom-directives
  • Vue.js 公式ガイド TypeScript と Composition API のページ(グローバルディレクティブの型付け) https://ja.vuejs.org/guide/typescript/composition-api
  • Vue 3 移行ガイド「カスタムディレクティブ」 https://v3-migration.vuejs.org/ja/breaking-changes/custom-directives

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Vueカスタムディレクティブの作り方|v-focusと引数・修飾子」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?