- Vue.js
Vueカスタムディレクティブの作り方|v-focusと引数・修飾子
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期連続の黒字決算。