寫原生 JS 的時候,下面兩段應該都不陌生:
// 表單送出,不要讓頁面重新整理
form.addEventListener('submit', (e) => {
e.preventDefault()
save()
})
// 卡片裡的愛心按鈕,不要連卡片一起點開
likeBtn.addEventListener('click', (e) => {
e.stopPropagation()
like()
})
我們真正想做的事只有 save() 和 like()。那兩行 e.preventDefault()、e.stopPropagation() 算什麼?它們跟資料一點關係都沒有,只是在跟瀏覽器交代「這個事件要怎麼處理」。
Vue 的事件修飾符就是為了這幾行而存在的。今天從 v-on 開始,先補一下 JS 的事件流,再來看修飾符到底幫我們省了什麼。
修飾符把「跟資料無關的 DOM 細節」從 method 搬到 template。
<form @submit.prevent="save">
save 裡面不用再寫 e.preventDefault(),甚至連 e 都不用接。這帶來兩個好處:
<button v-on:click="count++">+1</button>
<button @click="count++">+1</button>
兩行完全一樣,實務上幾乎都用 @。
<script setup>
import { ref } from 'vue'
const count = ref(0)
function add(event) {
console.log(event.target.tagName) // BUTTON
count.value++
}
function addBy(n) {
count.value += n
}
</script>
<template>
<!-- method handler:只給函式名稱,Vue 會自動把 event 傳進去 -->
<button @click="add">+1</button>
<!-- inline handler:自己呼叫,event 不會自動傳進去 -->
<button @click="addBy(5)">+5</button>
</template>
Vue 在編譯時會判斷引號裡是「一個函式名稱」還是「一段運算式」:
($event) => { addBy(5) },event 被包在外層,你的函式拿不到<button @click="addBy(5, $event)">+5</button>
<button @click="(e) => addBy(5, e)">+5</button>
兩種都可以。不過看完今天的內容你會發現,很多時候之所以要拿 event,只是為了呼叫 preventDefault() 或 stopPropagation(),那其實交給修飾符就好,不用拿了。
<MyButton @click="..."> 監聽的是元件 emit 出來的事件(或是落到元件根元素上的原生事件,第七段會講到踩雷的情況),之後講元件事件那篇再展開。
修飾符大部分都在操作「事件流」。這段沒弄懂,.stop、.self、.capture 就只能死背。
點一下頁面上的某個按鈕,事件不是只發生在按鈕身上,而是走了一趟來回:
window 一路往下傳到按鈕windowaddEventListener 預設在冒泡階段觸發,所以我們平常感覺到的是「內層先、外層後」。這兩個常被混在一起講,但擋的東西完全不同:
| 擋的是什麼 | 例子 | |
|---|---|---|
preventDefault() |
瀏覽器的預設行為 | 表單送出會重新整理、<a> 會跳頁、checkbox 會打勾 |
stopPropagation() |
事件繼續傳遞 | 外層元素的 click 監聽收不到 |
呼叫 stopPropagation() 擋不住跳頁;呼叫 preventDefault(),外層還是照樣收到事件。
e.target:實際被點到的元素,可能是最裡面的 <span>
e.currentTarget:這個監聽器綁在誰身上.self 的時候,就是靠這兩個的比較。把這段丟到 SFC Playground:
<script setup>
import { ref } from 'vue'
const logs = ref([])
function log(name) {
logs.value.push(name)
}
</script>
<template>
<div class="box outer" @click="log('outer')">
outer
<div class="box middle" @click="log('middle')">
middle
<div class="box inner" @click="log('inner')">inner</div>
</div>
</div>
<button @click="logs = []">清空</button>
<p>{{ logs.join(' → ') }}</p>
</template>
<style>
.box { padding: 16px; margin: 8px; border: 1px solid #999; cursor: pointer; }
.outer { background: #fde68a; }
.middle { background: #bbf7d0; }
.inner { background: #bfdbfe; }
</style>
點 inner 會得到:
inner → middle → outer
這就是冒泡。接下來每介紹一個修飾符,都可以回來改這段,看順序怎麼變。
| 修飾符 | 等同原生 JS |
|---|---|
.prevent |
e.preventDefault() |
.stop |
e.stopPropagation() |
.self |
if (e.target !== e.currentTarget) return |
.once |
addEventListener(type, fn, { once: true }) |
.capture |
addEventListener(type, fn, { capture: true }) |
.passive |
addEventListener(type, fn, { passive: true }) |
<form @submit.prevent="save">...</form>
<a href="/detail" @click.prevent="openModal">看詳情</a>
也可以只寫修飾符、不給 handler,單純擋掉預設行為:
<form @submit.prevent>...</form>
回到開場的卡片:
<div class="card" @click="openDetail">
<img :src="product.img" />
<h3>{{ product.name }}</h3>
<button @click.stop="like">♥</button>
</div>
點愛心只會按讚,不會連卡片一起打開。
最經典的場景是 Modal 的遮罩:點遮罩關閉,點內容不關閉。
<div class="overlay" @click.self="close">
<div class="modal">
<h2>確認刪除?</h2>
<p>刪除後無法復原</p>
<button>確定</button>
</div>
</div>
只有 e.target 是 overlay 本身的時候才會觸發。點 modal 裡的文字,target 是 <p> 或 <h2>,不是 overlay,所以不會關。
這兩個都能做到「點裡面的時候,外面不要反應」,但方向剛好相反:
.stop 寫在內層:我把事件擋下來,不讓它往外傳.self 寫在外層:事件照樣傳上來,但不是我本人被點到,我就不理<!-- middle 改成 .stop -->
<div class="box middle" @click.stop="log('middle')">
// 結果:inner → middle(outer 收不到)
<!-- middle 改成 .self -->
<div class="box middle" @click.self="log('middle')">
// 結果:inner → outer(middle 跳過,但事件還是傳到 outer)
所以什麼時候只能用哪一個?
.stop:如果改成在卡片上寫 .self,點到卡片裡的圖片或標題時,target 是 <img>、<h3>,卡片反而打不開了.self:modal 裡的元素很多,不可能每個都加 .stop。就算只在 .modal 外框加一個 .stop,也會把事件擋死,更外層的監聽(例如綁在 document 上的)通通收不到<button @click.once="startMusic">開始播放</button>
觸發一次之後,監聽器就被移除。要注意它是「這個元素這輩子只觸發一次」,不是防連點。如果拿來防表單重複送出,送出失敗想再按一次時,按鈕已經沒反應了。防連點還是用 loading 狀態加上 :disabled 比較穩。
把三層 demo 的 outer 改成:
<div class="box outer" @click.capture="log('outer')">
點 inner 會得到:
outer → inner → middle
outer 在捕獲階段就先觸發了。實務上比較少用,常見於想在所有子元素處理之前先攔截,例如統一記錄使用者的點擊行為。
<div @scroll.passive="onScroll">...</div>
<div @touchmove.passive="onTouchMove">...</div>
意思是告訴瀏覽器:「這個監聽器保證不會呼叫 preventDefault()」。瀏覽器就不用等 handler 跑完才決定要不要捲動,捲動會比較順。適合用在 scroll、touchmove、wheel 這類觸發頻率很高的事件。
修飾符可以串在一起用:
<a href="/a" @click.stop.prevent="go">...</a>
大部分情況下順序沒差,但遇到 .self 就有差了,因為修飾符會照寫的順序一個一個檢查:
<!-- 先 prevent 再檢查 self:點自己或點子元素,預設行為都會被擋 -->
<a href="/a" @click.prevent.self="go">
<span>點我</span>
</a>
<!-- 先檢查 self:點到子元素就直接結束,prevent 根本沒被執行 -->
<a href="/a" @click.self.prevent="go">
<span>點我</span>
</a>
第二種寫法點到 <span> 時,.self 判斷「不是 a 本人」就直接結束,preventDefault() 沒被呼叫,頁面就跳走了。為什麼會這樣,第八段看編譯結果就懂了。
<input @keyup.enter="submit" />
<input @keyup.esc="clear" />
<input @keyup.page-down="onPageDown" />
Vue 比對的是 KeyboardEvent.key,任何合法的 key 名稱轉成 kebab-case 都能當修飾符(PageDown → .page-down)。常用的有內建別名:
.enter、.tab、.esc、.space
.delete(Delete 和 Backspace 都算).up、.down、.left、.right
.ctrl、.alt、.shift
.meta(Mac 是 ⌘,Windows 是 ⊞)<!-- Ctrl + Enter -->
<textarea @keydown.ctrl.enter="send"></textarea>
<!-- 只有 Ctrl,沒有同時按住其他系統鍵 -->
<button @click.ctrl.exact="onCtrlClick">A</button>
<!-- 完全沒按任何系統鍵 -->
<button @click.exact="onClick">B</button>
組合鍵建議用 keydown。用 keyup.ctrl 的話,要按住 Ctrl 的同時放開另一個鍵才會觸發,單獨放開 Ctrl 是不會觸發的。
來看一個很常見的需求:聊天輸入框,Enter 送出、Shift + Enter 換行。
<textarea
v-model="message"
@keydown.enter.exact.prevent="send"
></textarea>
.exact 通過 → .prevent 擋掉換行 → 呼叫 send
.exact 不通過,直接結束,瀏覽器照預設行為換行e.shiftKey、e.ctrlKey、e.altKey、e.metaKey 四個。.left、.right、.middle
<div @click.right.prevent="openContextMenu">按右鍵開選單</div>
有個小細節:瀏覽器按右鍵其實不會觸發 click,所以 Vue 編譯時會把 @click.right 轉成 contextmenu 事件,搭配 .prevent 就能擋掉瀏覽器原生的右鍵選單。
.passive 本身就是承諾「不會擋預設行為」,再加 .prevent 會被忽略,瀏覽器也可能在 console 丟警告。Vue 官方文件也明確說不要這樣寫。
很常見的「點外面關閉下拉選單」:
<script setup>
import { onMounted, onUnmounted } from 'vue'
function closeDropdown() {
// 關閉選單
}
onMounted(() => {
document.addEventListener('click', closeDropdown)
})
onUnmounted(() => {
document.removeEventListener('click', closeDropdown)
})
</script>
如果頁面上某個元件為了自己方便加了 @click.stop,點到它的時候事件永遠傳不到 document,下拉選單就關不起來。這種 bug 很難找,因為兩段程式碼可能相隔很遠。
原則:.stop 只用在「真的要阻止外層行為」的地方。如果只是「外層不要反應」,優先考慮在外層用 .self,或在外層的 handler 裡自己判斷 target。
用注音、倉頡選字時,會按 Enter 確認選字。在某些瀏覽器(Safari 最常見),這個 Enter 也會觸發 keydown.enter,結果字還沒打完,訊息就送出去了。
這時候就得拿 event 出來判斷:
function send(e) {
// 輸入法組字中按下的 Enter 不算
if (e.isComposing || e.keyCode === 229) return
// 送出訊息
}
keyCode 雖然已經被標為棄用,但各瀏覽器在組字時的行為不一致,目前這是很常見的相容寫法。這也說明修飾符不是萬能的,遇到修飾符表達不了的條件,還是回到 handler 裡處理。
如果你在網路上看到 @click.native,那是 Vue 2 的寫法,Vue 3 已經移除了。現在的規則是:
emits 宣告 click → @click 只監聽元件 emit 出來的事件<!-- MyButton.vue -->
<script setup>
defineEmits(['click'])
</script>
<template>
<button>按我</button>
</template>
<!-- 父元件 -->
<MyButton @click="hello" />
hello 永遠不會被呼叫。因為宣告了 click 是元件事件,Vue 就不會把它綁到 <button> 上,但元件裡又從來沒有 emit 過。
跟 Day 2 一樣,把這段丟到 SFC Playground,切到 JS 分頁看編譯結果:
<template>
<form @submit.prevent="save">
<input @keyup.enter="search" />
<button @click.once="start">開始</button>
</form>
</template>
簡化之後大致長這樣(實際輸出依版本略有不同):
_createElementVNode("form", {
onSubmit: _withModifiers(save, ["prevent"])
}, [
_createElementVNode("input", {
onKeyup: _withKeys(search, ["enter"])
}),
_createElementVNode("button", {
onClickOnce: start
}, "開始")
])
可以看出修飾符其實分成三類:
.prevent、.stop、.self、.exact、滑鼠鍵 → 用 withModifiers 包一層函式,在呼叫你的 handler 之前先做檢查withKeys 包一層,比對按下的是不是指定的鍵.once、.capture、.passive → 直接變成事件名稱的後綴(onClickOnce),最後當作 addEventListener 的 options 傳進去。因為原生本來就支援,不需要另外包函式withModifiers 的核心邏輯簡化後長這樣:const guards = {
stop: (e) => e.stopPropagation(),
prevent: (e) => e.preventDefault(),
self: (e) => e.target !== e.currentTarget,
// ...其他修飾符
}
function withModifiers(fn, modifiers) {
return (event, ...args) => {
for (const m of modifiers) {
const guard = guards[m]
// guard 回傳 true 就直接結束,不呼叫 fn
if (guard && guard(event)) return
}
return fn(event, ...args)
}
}
這就是一個高階函式:接收一個函式,回傳一個包裝過的新函式。
回頭看第五段的順序問題就很清楚了:
stop、prevent 做完事情後回傳 undefined,迴圈繼續往下走self 在「不是本人」時回傳 true,迴圈直接 return.self.prevent 點到子元素時,第一圈就 return 了,prevent 根本輪不到執行。今天的重點一句話:
method 只管資料,事件的細節交給 template 宣告。
@ 是 v-on: 的縮寫;給函式名稱會自動收到 event,寫運算式要用 $event 拿preventDefault 擋預設行為,stopPropagation 擋事件傳遞,是兩件不同的事.stop 寫在內層、擋住事件;.self 寫在外層、不理子元素傳上來的事件.once 不是防連點,.passive 不要跟 .prevent 一起用.self 要特別注意.stop 用太多會讓外層的監聽收不到withModifiers 包的一層高階函式