Vueのイベント修飾子とは、@click.prevent のようにイベント名の後ろへドットで付け足し、preventDefault や stopPropagation などの定型処理をテンプレート側に書ける仕組みです。 ハンドラーの中身を「やりたい処理」だけに保てるので、コードが読みやすくなります。

この記事では、イベント修飾子・キー修飾子・システム修飾子・マウスボタン修飾子を1枚の表にまとめ、それぞれが内部で行うネイティブ処理を並べました。さらに @click.prevent.self と @click.self.prevent の違い、.exact を付けたときと付けないときの違いを、クリックログの実行結果で示します。引数と $event を同時に渡す書き方、TypeScript での型付け、Vue 3 でなくなった keyCode 修飾子と .native の置き換えまで、すべて動かした結果を載せています。

確認環境:Ubuntu 24.04 / Node.js 22.22.2 / Vue 3.5.43 / Vitest 5.0.3 + jsdom 30.1.1 + @vue/test-utils 2.5.1 / vue-tsc 3.3.12(TypeScript 6.0.3)。クリックやキー入力は jsdom 上でイベントを発行して確かめています。

Vueイベント修飾子の早見表

種類 修飾子 対応するネイティブ処理 使いどころ
イベント .prevent event.preventDefault() フォーム送信やリンク遷移など、既定の動作を止める
イベント .stop event.stopPropagation() 親要素へのバブリングを止める
イベント .self event.target !== event.currentTarget なら何もしない 子要素のクリックを除き、その要素自身のときだけ動かす
イベント .once addEventListener(…, { once: true }) 1回だけ受け取る
イベント .capture addEventListener(…, { capture: true }) 子より先に親で受け取る
イベント .passive addEventListener(…, { passive: true }) スクロールやタッチで既定動作を待たせない
キー .enter .tab .esc .space event.key が Enter / Tab / Escape / 空白 入力欄の確定、閉じる操作
キー .delete event.key が Delete または Backspace 削除操作(2つのキーの両方に反応)
キー .up .down .left .right event.key が ArrowUp / ArrowDown / ArrowLeft / ArrowRight 候補リストの移動
キー .page-down など任意のキー名 event.key をケバブケースにした名前と比較 エイリアスのないキーを指定する
システム .ctrl .shift .alt .meta event.ctrlKey などが false なら何もしない Ctrl + クリック、Ctrl + Enter
システム .exact 指定していないシステムキーが押されていたら何もしない 「Ctrl だけ」「何も押さずに」を厳密に判定
マウス .left event.button !== 0 なら何もしない 主ボタンだけに反応
マウス .right contextmenu イベントに置き換え、event.button !== 2 なら何もしない 右クリックメニュー
マウス .middle mouseup イベントに置き換え、event.button !== 1 なら何もしない 中ボタン

「何もしない」は、ハンドラーを呼ばずに終わるという意味です。ネイティブ処理の列は、Vue 3.5.43 の runtime-dom に入っている実装と、後半に載せるコンパイル結果で確かめた内容です。

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

(記載例)出典:株式会社RJC「Vue @clickイベント修飾子一覧|.prevent .stop .once」

@clickの基本:ハンドラーの書き方は3通り

@click は v-on:click の省略記法です。値の書き方は次の3通りがあります。

書き方 例 受け取るもの
メソッド名だけ @click="save" 第1引数に DOM イベントが入る
インラインの式 @click="count++"、@click="remove(7)" 式がそのまま実行される。イベントは $event で参照
アロー関数 @click="(event) => remove(7, event)" 引数でイベントを受け取る

メソッド名だけを書くと、Vue がイベントオブジェクトを第1引数として渡します。 かっこを付けて remove(7) と書いた時点でインラインの式になり、イベントは自動では渡りません。必要なときは $event を自分で渡します(後述)。

イベント修飾子を1つずつ動かす

まず、この記事の検証に使ったコンポーネントの全文です。log という emit を通じて、どのハンドラーが呼ばれたかを外に知らせます。

<!-- BasicDemo.vue -->
<script setup lang="ts">
const emit = defineEmits<{ log: [msg: string] }>()
const log = (m: string) => emit('log', m)

function remove(id: number, label: string, event: MouseEvent) {
  const el = event.currentTarget as HTMLButtonElement
  log(`remove id=${id} label=${label} type=${event.type} tag=${el.tagName}`)
}
function onSubmit(event: Event) {
  log(`submit defaultPrevented=${event.defaultPrevented}`)
}
function onEnter(event: KeyboardEvent) {
  log(`keyup key=${event.key}`)
}
</script>

<template>
  <!-- 1. バブリング: 子 → 親 -->
  <div id="outer1" @click="log('親 div')">
    <button id="plain" @click="log('子 button')">そのまま</button>
  </div>
  <!-- 2. .stop -->
  <div id="outer2" @click="log('親 div')">
    <button id="stop" @click.stop="log('子 button (.stop)')">止める</button>
  </div>
  <!-- 3. .capture -->
  <div id="outer3" @click.capture="log('親 div (.capture)')">
    <button id="cap" @click="log('子 button')">キャプチャ</button>
  </div>
  <!-- 4. .once -->
  <button id="once" @click.once="log('.once')">1回だけ</button>
  <!-- 5. 引数と $event -->
  <button id="arg1" @click="remove(7, 'ノート', $event)">$event</button>
  <button id="arg2" @click="(e) => remove(8, 'ペン', e)">アロー関数</button>
  <!-- 6. 複数ハンドラー -->
  <button id="multi" @click="log('1つ目'), log('2つ目')">2つ呼ぶ</button>
  <!-- 7. フォーム -->
  <form id="form" @submit.prevent="onSubmit"><button type="submit">送信</button></form>
  <!-- 8. キー -->
  <input id="k1" @keyup.enter="onEnter" />
  <input id="k2" @keyup.page-down="onEnter" />
  <input id="k3" @keyup.ctrl.enter="onEnter" />
  <input id="k4" @keyup.esc="onEnter" />
  <input id="k5" @keyup.delete="onEnter" />
  <!-- 9. マウスボタン -->
  <div id="m1" @click.right.prevent="log('右ボタン')">右</div>
  <div id="m2" @click.middle="log('中ボタン')">中</div>
</template>

これを Vitest と jsdom でマウントし、イベントを発行してログを集めます。

// basic.test.ts
import { test } from 'vitest'
import { mount } from '@vue/test-utils'
import BasicDemo from './BasicDemo.vue'

test('基本の修飾子', async () => {
  const logs: string[] = []
  const w = mount(BasicDemo, { attachTo: document.body, props: { onLog: (m: string) => logs.push(m) } })
  const show = (label: string) => { console.log(`${label} → ${logs.join(' / ') || '(なし)'}`); logs.length = 0 }
  const click = (id: string) => w.find(id).element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))

  click('#plain'); show('修飾子なし')
  click('#stop'); show('.stop')
  click('#cap'); show('.capture')
  click('#once'); click('#once'); click('#once'); show('.once を3回クリック')
  click('#arg1'); show('引数 + $event')
  click('#arg2'); show('引数 + アロー関数')
  click('#multi'); show('カンマ区切り')
  w.find('#form').element.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); show('@submit.prevent')

  const key = (id: string, init: KeyboardEventInit) => w.find(id).element.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, ...init }))
  key('#k1', { key: 'Enter' }); show('.enter に Enter')
  key('#k1', { key: 'a' }); show('.enter に a')
  key('#k2', { key: 'PageDown' }); show('.page-down に PageDown')
  key('#k3', { key: 'Enter' }); show('.ctrl.enter に Enter だけ')
  key('#k3', { key: 'Enter', ctrlKey: true }); show('.ctrl.enter に Ctrl+Enter')
  key('#k4', { key: 'Escape' }); show('.esc に Escape')
  key('#k5', { key: 'Backspace' }); show('.delete に Backspace')
  key('#k5', { key: 'Delete' }); show('.delete に Delete')

  const cm = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })
  w.find('#m1').element.dispatchEvent(cm); show(`.right (contextmenu, defaultPrevented=${cm.defaultPrevented})`)
  w.find('#m2').element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, button: 1 })); show('.middle (mouseup button=1)')
  w.find('#m2').element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, button: 0 })); show('.middle (mouseup button=0)')
})

設定ファイルは次の4行です。

// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({ plugins: [vue()], test: { environment: 'jsdom' } })

実行コマンドは npx vitest run --silent=false --reporter=verbose です。出力されたログは次のとおりでした。

修飾子なし → 子 button / 親 div
.stop → 子 button (.stop)
.capture → 親 div (.capture) / 子 button
.once を3回クリック → .once
引数 + $event → remove id=7 label=ノート type=click tag=BUTTON
引数 + アロー関数 → remove id=8 label=ペン type=click tag=BUTTON
カンマ区切り → 1つ目 / 2つ目
@submit.prevent → submit defaultPrevented=true
.enter に Enter → keyup key=Enter
.enter に a → (なし)
.page-down に PageDown → keyup key=PageDown
.ctrl.enter に Enter だけ → (なし)
.ctrl.enter に Ctrl+Enter → keyup key=Enter
.esc に Escape → keyup key=Escape
.delete に Backspace → keyup key=Backspace
.delete に Delete → keyup key=Delete
.right (contextmenu, defaultPrevented=true) → 右ボタン
.middle (mouseup button=1) → 中ボタン
.middle (mouseup button=0) → (なし)

.stop:親へ伝えない

修飾子なしでは「子 button / 親 div」と2つ呼ばれ、.stop を付けると子だけになりました。クリックは子から親へ順に伝わる(バブリング)ので、親のハンドラーを動かしたくないときに .stop を付けます。 一覧の行全体にクリックを付け、行の中の削除ボタンだけ別の動きにしたい、という場面でよく使います。

.prevent:既定の動作を止める

@submit.prevent のログでは defaultPrevented=true になっています。ハンドラーが呼ばれた時点で、すでに preventDefault が済んでいるということです。フォームのページ再読み込みを止める @submit.prevent は、最もよく使う組み合わせです。

値を書かずに @submit.prevent だけでも使えます。この場合は「既定の動作を止めるだけ」のリスナーになります。

.capture と .once と .passive:リスナーのオプションになる3つ

.capture を付けた親は、子より先に呼ばれました(「親 div (.capture) / 子 button」)。.once を付けたボタンは、3回クリックしてもログは1回だけです。

この3つは他の修飾子と仕組みが異なり、addEventListener の第3引数(オプション)として登録されます。 .passive はスクロールやタッチ系のイベントで、ブラウザーに「preventDefault を呼ばない」と先に伝えるためのものです。Vue の公式ガイドには、.passive と .prevent を一緒に使わないこと、一緒に使うとブラウザーが警告を出す可能性があることが書かれています。

.self:その要素自身のクリックだけ

.self は「クリックされた要素(event.target)が、リスナーを付けた要素(event.currentTarget)と同じとき」だけハンドラーを呼びます。モーダルの背景に @click.self="open = false" と書くと、背景のクリックでは閉じ、中のパネルをクリックしても閉じない動きになります(この形でも動作を確かめました)。

修飾子の順序で結果が変わる:.prevent.self と .self.prevent

修飾子は複数つなげられます。このとき、書いた順番に処理されるので、順序によって結果が変わります。 公式ガイドにも「関連するコードが同じ順番で生成されるため、修飾子を使用するときには順番は重要です」とあります。

リンクの中に子要素を置いた、次のコンポーネントで確かめます。

<!-- OrderDemo.vue -->
<script setup lang="ts">
const emit = defineEmits<{ log: [msg: string] }>()
function onA() { emit('log', 'A: ハンドラー実行') }
function onB() { emit('log', 'B: ハンドラー実行') }
</script>

<template>
  <!-- A: prevent → self の順 -->
  <a id="a" href="/next" @click.prevent.self="onA">
    <span id="a-child">A の子要素</span>
  </a>
  <!-- B: self → prevent の順 -->
  <a id="b" href="/next" @click.self.prevent="onB">
    <span id="b-child">B の子要素</span>
  </a>
</template>

basic.test.ts と同じ形のテストで、#a・#a-child・#b・#b-child の順に click を発行し、そのつど event.defaultPrevented と呼ばれたハンドラーを出力しました。

クリックログは次のとおりです。

#a をクリック → defaultPrevented=true / A: ハンドラー実行
#a-child をクリック → defaultPrevented=true / ハンドラーは呼ばれない
#b をクリック → defaultPrevented=true / B: ハンドラー実行
#b-child をクリック → defaultPrevented=false / ハンドラーは呼ばれない
Not implemented: navigation to another Document

最後の1行は jsdom が出したメッセージです。jsdom はページ遷移を持たないため、リンクの既定動作(遷移)が始まったところでこの1行を表示します。つまり #b-child のクリックでは、リンクの遷移が止まっていません。

書き方 リンク自身をクリック 子要素をクリック
@click.prevent.self 遷移を止める+ハンドラー実行 遷移を止める(ハンドラーは呼ばれない)
@click.self.prevent 遷移を止める+ハンドラー実行 遷移する(ハンドラーは呼ばれない)

.prevent.self は「子要素も含めてすべてのクリックで遷移を止める」、.self.prevent は「要素自身のクリックだけ遷移を止める」です。

理由:修飾子は左から順にチェックされる

なぜこうなるかは、コンパイル結果と Vue 本体の実装を見るとはっきりします。テンプレートをコンパイルした結果は次のとおりです(@vue/compiler-dom の compile 関数で出力)。

@click.prevent.self="go"     → { onClick: _withModifiers(_ctx.go, ["prevent","self"]) }
@click.self.prevent="go"     → { onClick: _withModifiers(_ctx.go, ["self","prevent"]) }

修飾子が配列として、書いた順のまま渡されています。受け取る側の withModifiers は、Vue 3.5.43 の runtime-dom で次のように実装されています。

const systemModifiers = ["ctrl", "shift", "alt", "meta"];
const modifierGuards = {
  stop: (e) => e.stopPropagation(),
  prevent: (e) => e.preventDefault(),
  self: (e) => e.target !== e.currentTarget,
  ctrl: (e) => !e.ctrlKey,
  shift: (e) => !e.shiftKey,
  alt: (e) => !e.altKey,
  meta: (e) => !e.metaKey,
  left: (e) => "button" in e && e.button !== 0,
  middle: (e) => "button" in e && e.button !== 1,
  right: (e) => "button" in e && e.button !== 2,
  exact: (e, modifiers) => systemModifiers.some((m) => e[`${m}Key`] && !modifiers.includes(m))
};
const withModifiers = (fn, modifiers) => {
  if (!fn) return fn;
  const cache = fn._withMods || (fn._withMods = {});
  const cacheKey = modifiers.join(".");
  return cache[cacheKey] || (cache[cacheKey] = ((event, ...args) => {
    for (let i = 0; i < modifiers.length; i++) {
      const guard = modifierGuards[modifiers[i]];
      if (guard && guard(event, modifiers)) return;
    }
    return fn(event, ...args);
  }));
};

for 文で修飾子を左から1つずつ処理し、どれかが true を返した時点で return して終わります。 .self.prevent では、子要素のクリックのとき最初の self が true を返すので、次の prevent まで進みません。.prevent.self では先に preventDefault が済んでから self で止まります。

同じ考え方で、.stop と .self の順序も結果が変わります。@click.self.stop は「自身のクリックだけバブリングを止める」、@click.stop.self は「子のクリックも含めてバブリングを止める」です。迷ったら「左から順に実行され、条件に合わなければそこで終わる」と読み下すと判断できます。

.exactの有無で変わること:Ctrl+クリックの判定

システム修飾子(.ctrl .shift .alt .meta)は、「そのキーが押されていること」だけを見ます。 他のキーが一緒に押されていても反応します。「そのキーだけ」に限定したいときに付けるのが .exact です。

<!-- ExactDemo.vue -->
<script setup lang="ts">
const emit = defineEmits<{ log: [msg: string] }>()
</script>

<template>
  <button id="b1" @click="emit('log', '@click')">1</button>
  <button id="b2" @click.ctrl="emit('log', '@click.ctrl')">2</button>
  <button id="b3" @click.ctrl.exact="emit('log', '@click.ctrl.exact')">3</button>
  <button id="b4" @click.exact="emit('log', '@click.exact')">4</button>
  <button id="b5" @click.ctrl.shift="emit('log', '@click.ctrl.shift')">5</button>
</template>

5つのボタンすべてに、ctrlKey や shiftKey を変えた click を発行した結果です。

キーなし でクリック → @click , @click.exact
Ctrl でクリック → @click , @click.ctrl , @click.ctrl.exact
Ctrl+Shift でクリック → @click , @click.ctrl , @click.ctrl.shift
Shift でクリック → @click

表に直すと次のようになります。

書き方 キーなし Ctrl Ctrl+Shift Shift
@click 反応 反応 反応 反応
@click.ctrl - 反応 反応 -
@click.ctrl.exact - 反応 - -
@click.exact 反応 - - -
@click.ctrl.shift - - 反応 -
  • @click.ctrl は Ctrl+Shift でも反応します。 「Ctrl だけ」にしたいときは .ctrl.exact にします。
  • @click.exact は、システムキーを何も押していないクリックだけに反応します。 通常のクリックと Ctrl + クリックで動きを分けたいとき、通常側に .exact を付けると重なりません。
  • 修飾子なしの @click は、どのキーを押していても反応します。

公式ガイドによると、.meta は Mac のキーボードでは command キー、Windows のキーボードでは Windows キーに対応します。また、システム修飾子を keyup と組み合わせる場合、イベントが発行される時点でそのキーが押されている必要があります。つまり @keyup.ctrl は、Ctrl を押したまま別のキーを離したときに反応します。

キー修飾子:Enterや矢印キーを指定する

@keyup.enter のように、キーボードイベントにはキー名を修飾子として付けられます。よく使うキーには短いエイリアスが用意されています。

エイリアス 比較される event.key 実行ログでの確認
.enter Enter 「.enter に Enter」で反応、「.enter に a」は反応なし
.tab Tab -
.delete Delete / Backspace Backspace と Delete の両方で反応
.esc Escape 「.esc に Escape」で反応
.space 空白文字 -
.up .down .left .right ArrowUp / ArrowDown / ArrowLeft / ArrowRight -

エイリアスにないキーは、KeyboardEvent.key の値をケバブケース(小文字とハイフン)にした名前で指定します。PageDown なら .page-down です。先ほどのログでも「.page-down に PageDown」で反応しています。

システム修飾子と組み合わせると、ショートカットキーも書けます。@keyup.ctrl.enter は、Enter だけでは反応せず、Ctrl + Enter で反応しました。

日本語入力の変換確定で反応させない書き方

入力欄で「Enter で登録」を作るとき、日本語の変換を確定する Enter にも反応すると、変換途中の文字が登録されてしまいます。KeyboardEvent の isComposing(変換中かどうか)を見て、変換中は処理を見送ると落ち着いた動きになります。

<!-- ImeDemo.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const text = ref('')
const items = ref<string[]>([])

function add(event: KeyboardEvent) {
  if (event.isComposing) return // 変換を確定する Enter は見送る
  if (text.value.trim() === '') return
  items.value.push(text.value.trim())
  text.value = ''
}
</script>

<template>
  <input v-model="text" placeholder="タスクを入力して Enter" @keydown.enter="add" />
  <ul>
    <li v-for="item in items" :key="item">{{ item }}</li>
  </ul>
</template>
isComposing=true の Enter → 登録数 0
isComposing=false の Enter → 登録数 1

isComposing を true・false にした keydown を順に発行した結果です。isComposing が true の keydown では登録されず、false の keydown で1件登録されました。ここでは isComposing の値を指定したイベントを jsdom 上で発行して、分岐が働くことを確かめています。

マウスボタン修飾子:.left .right .middle

マウスボタン修飾子は、event.button の値でボタンを見分けます。コンパイル結果を見ると、.right と .middle は受け取るイベントそのものが置き換わることがわかります。

@click.left="go"             → { onClick: _withModifiers(_ctx.go, ["left"]) }
@click.right="go"            → { onContextmenu: _withModifiers(_ctx.go, ["right"]) }
@click.middle="go"           → { onMouseup: _withModifiers(_ctx.go, ["middle"]) }
@keyup.right="go"            → { onKeyup: _withKeys(_ctx.go, ["right"]) }
書き方 実際に登録されるイベント 判定
@click.left click button が 0
@click.right contextmenu button が 2
@click.middle mouseup button が 1
  • @click.right は contextmenu イベントに置き換わります。 ブラウザーの右クリックメニューを出したくないときは @click.right.prevent と書きます。実行ログでも defaultPrevented=true になりました。
  • @click.middle は mouseup に置き換わり、button が 1 のときだけ反応します(button が 0 の mouseup では反応なし)。
  • .left と .right は、キーボードイベントでは矢印キーの意味になります。 @keyup.right は右矢印キー、@click.right は右ボタンです。同じ名前でも、付けるイベントで意味が切り替わります。

公式ガイドには、.left .right .middle という名前は一般的な右利き用マウスの配置にもとづくもので、実際には「メイン」「セカンダリ」「補助」のポインティングデバイスのイベントを表す、と説明されています。

引数と$eventを同時に渡す

「どの行が押されたか」という ID と、イベントオブジェクトの両方を受け取りたい場面はよくあります。書き方は2つです。

<!-- ArgDemo.vue -->
<script setup lang="ts">
const items = [
  { id: 7, label: 'ノート' },
  { id: 8, label: 'ペン' },
]

function remove(id: number, label: string, event: MouseEvent) {
  const el = event.currentTarget as HTMLButtonElement
  console.log(`remove id=${id} label=${label} type=${event.type} tag=${el.tagName}`)
}
</script>

<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      {{ item.label }}
      <!-- 書き方1: 特別な変数 $event を渡す -->
      <button @click="remove(item.id, item.label, $event)">削除</button>
      <!-- 書き方2: アロー関数の引数で受け取る -->
      <button @click="(event) => remove(item.id, item.label, event)">削除</button>
    </li>
  </ul>
</template>

先ほどのログでは、どちらの書き方でも ID・ラベル・イベントの3つが届いていました。

引数 + $event → remove id=7 label=ノート type=click tag=BUTTON
引数 + アロー関数 → remove id=8 label=ペン type=click tag=BUTTON
やりたいこと 書き方
イベントだけ受け取る @click="onClick"(第1引数に入る)
引数だけ渡す @click="remove(item.id)"
引数とイベントの両方 @click="remove(item.id, $event)"
引数とイベントの両方(アロー関数) @click="(event) => remove(item.id, event)"
2つの処理を続けて呼ぶ @click="save(), close()"(カンマで区切る)
修飾子と引数を同時に使う @click.stop="remove(item.id, $event)"

$event はテンプレートの中だけで使える特別な変数です。 script 側で $event という名前を参照することはできないので、必ず引数として渡します。修飾子を付けても $event はそのまま使えます。

TypeScriptでイベントに型を付ける

script setup を TypeScript で書くとき、ハンドラーの引数に型を付けると、event.key や event.clientX の補完と型チェックが働きます。

型を付けた完成形

<!-- TypeOk.vue -->
<script setup lang="ts">
function onClick(event: MouseEvent) {
  const button = event.currentTarget as HTMLButtonElement
  console.log(button.textContent, event.clientX, event.ctrlKey)
}
function onKey(event: KeyboardEvent) {
  console.log(event.key, event.ctrlKey)
}
function onInput(event: Event) {
  const input = event.target as HTMLInputElement
  console.log(input.value)
}
function onSubmit(event: SubmitEvent) {
  const form = event.currentTarget as HTMLFormElement
  console.log(new FormData(form).get('title'))
}
function remove(id: number, event: MouseEvent) {
  console.log(id, event.clientX)
}
</script>

<template>
  <button @click="onClick">クリック</button>
  <input @keyup.enter="onKey" @input="onInput" />
  <form @submit.prevent="onSubmit"><input name="title" /></form>
  <button @click="remove(7, $event)">$event</button>
  <button @click="(event) => remove(8, event)">アロー関数</button>
</template>

このファイルは npx vue-tsc --noEmit でエラーなしでした。$event もアロー関数の引数も、イベント名に合った型が自動で付きます。 そのため remove(7, $event) の第2引数が MouseEvent であることまでチェックされます。

型が合わないときのエラー文

型を取り違えた例を、あえて vue-tsc に通してみます。

<!-- TypeNg.vue -->
<script setup lang="ts">
function onKey(event: KeyboardEvent) {
  console.log(event.key)
}
function onInput(event: Event) {
  console.log(event.target.value)
}
function remove(id: number, event: MouseEvent) {
  console.log(id, event.clientX)
}
</script>

<template>
  <button @click="onKey">A</button>
  <input @input="onInput" />
  <button @click="remove('7', $event)">B</button>
  <input @keyup.enter="remove(7, $event)" />
</template>
a1/TypeNg.vue(6,15): error TS18047: 'event.target' is possibly 'null'.
a1/TypeNg.vue(6,28): error TS2339: Property 'value' does not exist on type 'EventTarget'.
a1/TypeNg.vue(14,4): error TS2345: Argument of type '{ onClick: (event: KeyboardEvent) => void; }' is not assignable to parameter of type 'ButtonHTMLAttributes & ReservedProps & Record<string, unknown>'.
  Type '{ onClick: (event: KeyboardEvent) => void; }' is not assignable to type 'ButtonHTMLAttributes'.
    Types of property 'onClick' are incompatible.
      Type '(event: KeyboardEvent) => void' is not assignable to type '(payload: PointerEvent) => void'.
        Types of parameters 'event' and 'payload' are incompatible.
          Type 'PointerEvent' is missing the following properties from type 'KeyboardEvent': charCode, code, isComposing, key, and 8 more.
a1/TypeNg.vue(16,26): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
a1/TypeNg.vue(17,34): error TS2345: Argument of type 'KeyboardEvent' is not assignable to parameter of type 'MouseEvent'.
  Type 'KeyboardEvent' is missing the following properties from type 'MouseEvent': button, buttons, clientX, clientY, and 14 more.
エラー 原因 直し方
TS18047 / TS2339(event.target) event.target の型は EventTarget(null の可能性あり)で、value を持たない const input = event.target as HTMLInputElement と要素の型を伝える
TS2345(onClick に KeyboardEvent) click に渡るのは PointerEvent なので、KeyboardEvent 用の関数は合わない 引数を MouseEvent にする
TS2345('7' を number へ) テンプレートの引数も型チェックの対象 remove(7, $event) と数値で渡す
TS2345(keyup の $event を MouseEvent へ) keyup の $event は KeyboardEvent 受け取る関数の型を KeyboardEvent にする

イベントごとの型の対応表

Vue 3.5.43 の型定義で、各イベントのハンドラーに渡る型を vue-tsc のエラー文から読み取った結果です。

イベント 渡される型 引数に書ける型の例
@click @contextmenu @pointerdown PointerEvent MouseEvent でも受け取れる(PointerEvent は MouseEvent を継承)
@dblclick @mousedown @mouseup @mouseenter MouseEvent MouseEvent
@keydown @keyup KeyboardEvent KeyboardEvent
@input InputEvent Event でも受け取れる
@change @scroll Event Event
@focus @blur FocusEvent FocusEvent
@submit SubmitEvent SubmitEvent、Event
@paste ClipboardEvent ClipboardEvent

クリックは MouseEvent、キー入力は KeyboardEvent と覚えておけば、ほとんどの場面で足ります。 要素を取り出すときは、リスナーを付けた要素を指す event.currentTarget を as HTMLButtonElement のように絞り込むと、補完が効いて書きやすくなります。

Vue 3でなくなった修飾子と置き換え表

Vue 2 のコードを Vue 3 へ移すときに書き換えが必要な修飾子は、次の3つです。

Vue 2 の書き方 Vue 3 での扱い 置き換え
@keyup.13(数値の keyCode) サポート終了 @keyup.enter などキー名にする
Vue.config.keyCodes で独自エイリアス サポート終了 @keyup.page-down のようにケバブケースのキー名にする
@click.native(コンポーネントに DOM イベントを付ける) 修飾子が削除 .native を消す。子コンポーネントは emits に発行するイベントを宣言する

Vue 3 移行ガイドによると、句読点のキーはそのまま修飾子に書けます(カンマなら @keyup.,)。一方、引用符やスラッシュなど構文上書けない文字は、ハンドラーの中で event.key を調べます。

実行ログで確認する

親コンポーネントから、emits を宣言した子・宣言していない子の両方に @click を付け、あわせて @keyup.13 も試します。

<!-- Child.vue -->
<script setup lang="ts">
defineEmits<{ click: [id: number] }>()
</script>

<template>
  <button class="declared" @click="$emit('click', 42)">emits に click を宣言</button>
</template>
<!-- ChildPlain.vue -->
<template>
  <button class="plain">emits の宣言なし</button>
</template>
<!-- NativeDemo.vue -->
<script setup lang="ts">
import Child from './Child.vue'
import ChildPlain from './ChildPlain.vue'
const emit = defineEmits<{ log: [msg: string] }>()
function show(name: string, payload: unknown) {
  const kind = payload instanceof Event ? `DOMイベント(${payload.type})` : `emit の値(${payload})`
  emit('log', `${name}: ${kind}`)
}
</script>

<template>
  <Child @click="show('Child', $event)" />
  <ChildPlain @click="show('ChildPlain', $event)" />
  <input id="code" @keyup.13="emit('log', '@keyup.13')" />
  <input id="name" @keyup.enter="emit('log', '@keyup.enter')" />
  <input id="comma" @keyup.,="emit('log', '@keyup.,')" />
</template>

2つの子のボタンに click を、3つの入力欄に keyup(Enter とカンマ)を発行した結果です。

emits 宣言ありの子をクリック → Child: emit の値(42)
emits 宣言なしの子をクリック → ChildPlain: DOMイベント(click)
@keyup.13 に Enter → (なし)
@keyup.enter に Enter → @keyup.enter
@keyup., に , → @keyup.,
  • emits を宣言していない子では、@click がルート要素の DOM イベントとしてそのまま働きます。 Vue 2 で .native が担っていた役割を、Vue 3 では修飾子なしで実現できます。
  • emits に click を宣言した子では、子が emit した値(42)だけが届きます。 DOM のクリックと二重に届くことはありません。コンポーネントが発行するイベントを emits に書いておくと、この切り分けが働きます。
  • @keyup.13 は Enter を押しても反応しません。 エラーにも警告にもならず静かに無反応になるので、移行時は数値の修飾子を検索して置き換えると確実です。

なお、@click.native と書いたテンプレートを Vue 3.5.43 でコンパイルすると、次のように .native の部分は何も生成されませんでした。

@click.native="go"           → { onClick: _ctx.go }

修飾子がコンパイルされた結果の一覧

修飾子の仕組みは、コンパイル結果を並べると整理しやすくなります。@vue/compiler-dom の compile 関数に compile('<div @click.stop="go"></div>', { mode: 'module' }) の形で属性を1つずつ渡し、生成されたコードから props の部分を抜き出しました。

@click="go"                  → { onClick: _ctx.go }
@click.stop="go"             → { onClick: _withModifiers(_ctx.go, ["stop"]) }
@click.once="go"             → { onClickOnce: _ctx.go }
@click.capture="go"          → { onClickCapture: _ctx.go }
@scroll.passive="go"         → { onScrollPassive: _ctx.go }
@click.capture.once="go"     → { onClickCaptureOnce: _ctx.go }
@keyup.enter="go"            → { onKeyup: _withKeys(_ctx.go, ["enter"]) }
@keyup.page-down="go"        → { onKeyup: _withKeys(_ctx.go, ["page-down"]) }
@keyup.ctrl.enter="go"       → { onKeyup: _withKeys(_withModifiers(_ctx.go, ["ctrl"]), ["enter"]) }
@keyup.13="go"               → { onKeyup: _withKeys(_ctx.go, ["13"]) }
@click.ctrl.exact="go"       → { onClick: _withModifiers(_ctx.go, ["ctrl","exact"]) }
@click.exact="go"            → { onClick: _withModifiers(_ctx.go, ["exact"]) }
@click.prevent               → { onClick: _withModifiers(() => {}, ["prevent"]) }

この出力から、修飾子は3つのグループに分かれることが読み取れます。

グループ 修飾子 コンパイル後の形
リスナーのオプションになる .once .capture .passive プロパティ名の末尾に付く(onClickOnce、onClickCapture など)
ハンドラーの前でチェックする .stop .prevent .self .ctrl .shift .alt .meta .exact .left .right .middle withModifiers で包む
キー名を比較する .enter .page-down など withKeys で包む

@keyup.13 は withKeys に文字列の "13" が渡っています。Vue 3 は event.key(Enter のようなキーの名前)と比較するので、"13" とは一致せず、反応しなくなるというわけです。

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

起きること 理由 直し方
子要素をクリックするとリンクが遷移する .self.prevent の順だと、子のクリックでは prevent まで進まない .prevent.self の順にする
Ctrl+Shift+クリックでも Ctrl 用の処理が動く システム修飾子は「押されていること」だけを見る .ctrl.exact にする
通常クリックと Ctrl+クリックの両方が動く 修飾子なしの @click はどのキーでも反応する 通常側を @click.exact にする
remove(item.id) の中でイベントを使えない かっこを付けるとイベントは自動で渡らない remove(item.id, $event) と渡す
event.target.value が型エラーになる event.target は EventTarget 型 event.target as HTMLInputElement と絞り込む
@keyup.13 が反応しない Vue 3 はキー名で比較する @keyup.enter にする
変換確定の Enter で登録される keydown は変換中にも発行される event.isComposing が true のときは return する
右クリックでブラウザーのメニューも出る .right は contextmenu を受け取るだけ @click.right.prevent にする
コンポーネントに付けた @click に emit の値が届く 子が emits に click を宣言している DOM イベントが欲しい場合は、子の中の要素から必要な情報を emit する

よくある質問

Q. .prevent と .stop はどう違いますか? A. .prevent は「ブラウザーの既定の動作(フォーム送信、リンク遷移、右クリックメニューなど)を止める」、.stop は「親要素へイベントが伝わるのを止める」です。目的が別なので、両方必要なら @click.stop.prevent のように並べて書きます。

Q. 修飾子はいくつまでつなげられますか? A. 個数の上限は特にありません。大切なのは順序で、左から順に処理され、条件に合わなかった時点でそこで終わります。.prevent や .stop のように必ず実行したい処理は左に、.self や .ctrl のように条件を絞るものは右に置くと、意図どおりになりやすいです。

Q. @keyup と @keydown はどちらを使えばよいですか? A. キーを押した瞬間に反応させたい、または押しっぱなしで繰り返したいときは keydown、キーを離したときに1回だけ反応させたいときは keyup です。入力欄の Enter で登録する処理は、isComposing の判定と組み合わせて keydown に付けると、日本語入力でも落ち着いて動きます。

まとめ

  • イベント修飾子は6つ(.prevent .stop .self .once .capture .passive)。.prevent は preventDefault、.stop は stopPropagation に対応する
  • 修飾子は左から順に処理される。.prevent.self は子のクリックでも遷移を止め、.self.prevent は要素自身のクリックだけ止める
  • システム修飾子は「押されていること」だけを見る。そのキーだけに限定するなら .exact を付ける
  • @click.right は contextmenu、@click.middle は mouseup に置き換わる。.left .right はキーボードイベントでは矢印キーになる
  • 引数とイベントを両方渡すには $event かアロー関数。TypeScript ではクリックに MouseEvent、キー入力に KeyboardEvent を付ける
  • Vue 3 では @keyup.13 と .native がなくなった。キー名(.enter .page-down)と emits の宣言に置き換える

参考

  • Vue.js 公式ガイド「イベントハンドリング」 https://ja.vuejs.org/guide/essentials/event-handling
  • Vue 3 移行ガイド(keyCode 修飾子のページ) https://v3-migration.vuejs.org/ja/breaking-changes/keycode-modifiers
  • Vue 3 移行ガイド(v-on の .native 修飾子のページ) https://v3-migration.vuejs.org/ja/breaking-changes/v-on-native-modifier-removed

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

(記載例)出典:株式会社RJC「Vue @clickイベント修飾子一覧|.prevent .stop .once」

株式会社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で一緒に開発しながら、 成長を楽しみませんか?