iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
Modern Web

重新認識Vue 走過路過不要錯過系列 第 12 篇

Vue 走過路過不要錯過 Day12 -v-on 與事件修飾符:.prevent、.stop 幫你省下的那幾行

  • 分享至 

  • xImage
  •  

寫原生 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 都不用接。這帶來兩個好處:

  • method 只剩資料邏輯,讀起來就是「存檔」這件事
  • method 不依賴 event 物件,可以在別的地方直接呼叫,也比較好測試
    所以省下的不只是一行字,而是讓 method 跟 DOM 脫鉤。

二、v-on 基本用法

縮寫

<button v-on:click="count++">+1</button>
<button @click="count++">+1</button>

兩行完全一樣,實務上幾乎都用 @。

兩種寫法:method handler 與 inline handler

<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 在編譯時會判斷引號裡是「一個函式名稱」還是「一段運算式」:

  • 函式名稱 → 直接當作 handler,事件觸發時會收到 event
  • 運算式 → 包成 ($event) => { addBy(5) },event 被包在外層,你的函式拿不到

又要傳參數、又要 event

<button @click="addBy(5, $event)">+5</button>
<button @click="(e) => addBy(5, e)">+5</button>

兩種都可以。不過看完今天的內容你會發現,很多時候之所以要拿 event,只是為了呼叫 preventDefault() 或 stopPropagation(),那其實交給修飾符就好,不用拿了。

綁在元件上

<MyButton @click="..."> 監聽的是元件 emit 出來的事件(或是落到元件根元素上的原生事件,第七段會講到踩雷的情況),之後講元件事件那篇再展開。


三、先補 JS 基礎:事件流

修飾符大部分都在操作「事件流」。這段沒弄懂,.stop、.self、.capture 就只能死背。

事件的三個階段

點一下頁面上的某個按鈕,事件不是只發生在按鈕身上,而是走了一趟來回:

  1. 捕獲(capture):從 window 一路往下傳到按鈕
  2. 目標(target):抵達按鈕本身
  3. 冒泡(bubble):從按鈕一路往上傳回 window
    addEventListener 預設在冒泡階段觸發,所以我們平常感覺到的是「內層先、外層後」。

preventDefault 和 stopPropagation 是兩件事

這兩個常被混在一起講,但擋的東西完全不同:

擋的是什麼 例子
preventDefault() 瀏覽器的預設行為 表單送出會重新整理、<a> 會跳頁、checkbox 會打勾
stopPropagation() 事件繼續傳遞 外層元素的 click 監聽收不到

呼叫 stopPropagation() 擋不住跳頁;呼叫 preventDefault(),外層還是照樣收到事件。

target 與 currentTarget

  • 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 })

.prevent

<form @submit.prevent="save">...</form>
 
<a href="/detail" @click.prevent="openModal">看詳情</a>

也可以只寫修飾符、不給 handler,單純擋掉預設行為:

<form @submit.prevent>...</form>

.stop

回到開場的卡片:

<div class="card" @click="openDetail">
  <img :src="product.img" />
  <h3>{{ product.name }}</h3>
  <button @click.stop="like">♥</button>
</div>

點愛心只會按讚,不會連卡片一起打開。

.self

最經典的場景是 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,所以不會關。

.self 和 .stop 差在哪?

這兩個都能做到「點裡面的時候,外面不要反應」,但方向剛好相反:

  • .stop 寫在內層:我把事件擋下來,不讓它往外傳
  • .self 寫在外層:事件照樣傳上來,但不是我本人被點到,我就不理
    用三層 demo 改 middle 來對照,同樣點 inner:
<!-- 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>,卡片反而打不開了
  • Modal 遮罩適合用 .self:modal 裡的元素很多,不可能每個都加 .stop。就算只在 .modal 外框加一個 .stop,也會把事件擋死,更外層的監聽(例如綁在 document 上的)通通收不到

.once

<button @click.once="startMusic">開始播放</button>

觸發一次之後,監聽器就被移除。要注意它是「這個元素這輩子只觸發一次」,不是防連點。如果拿來防表單重複送出,送出失敗想再按一次時,按鈕已經沒反應了。防連點還是用 loading 狀態加上 :disabled 比較穩。

.capture

把三層 demo 的 outer 改成:

<div class="box outer" @click.capture="log('outer')">

點 inner 會得到:

outer → inner → middle

outer 在捕獲階段就先觸發了。實務上比較少用,常見於想在所有子元素處理之前先攔截,例如統一記錄使用者的點擊行為。

.passive

<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

系統鍵與 .exact

  • .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>
  • 只按 Enter:.exact 通過 → .prevent 擋掉換行 → 呼叫 send
  • 按 Shift + Enter:.exact 不通過,直接結束,瀏覽器照預設行為換行
    一行就搞定,原生寫法要自己判斷 e.shiftKey、e.ctrlKey、e.altKey、e.metaKey 四個。

滑鼠修飾符

  • .left、.right、.middle
<div @click.right.prevent="openContextMenu">按右鍵開選單</div>

有個小細節:瀏覽器按右鍵其實不會觸發 click,所以 Vue 編譯時會把 @click.right 轉成 contextmenu 事件,搭配 .prevent 就能擋掉瀏覽器原生的右鍵選單。


七、踩雷區

1. .passive 和 .prevent 不要一起用

.passive 本身就是承諾「不會擋預設行為」,再加 .prevent 會被忽略,瀏覽器也可能在 console 丟警告。Vue 官方文件也明確說不要這樣寫。

2. .stop 用太兇,外層監聽收不到

很常見的「點外面關閉下拉選單」:

<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。

3. 中文輸入法的 Enter

用注音、倉頡選字時,會按 Enter 確認選字。在某些瀏覽器(Safari 最常見),這個 Enter 也會觸發 keydown.enter,結果字還沒打完,訊息就送出去了。

這時候就得拿 event 出來判斷:

function send(e) {
  // 輸入法組字中按下的 Enter 不算
  if (e.isComposing || e.keyCode === 229) return
  // 送出訊息
}

keyCode 雖然已經被標為棄用,但各瀏覽器在組字時的行為不一致,目前這是很常見的相容寫法。這也說明修飾符不是萬能的,遇到修飾符表達不了的條件,還是回到 handler 裡處理。

4. 舊文章裡的 .native

如果你在網路上看到 @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 過。


八、Vue 在背後做了什麼

跟 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
  }, "開始")
])

可以看出修飾符其實分成三類:

  1. .prevent、.stop、.self、.exact、滑鼠鍵 → 用 withModifiers 包一層函式,在呼叫你的 handler 之前先做檢查
  2. 按鍵修飾符 → 用 withKeys 包一層,比對按下的是不是指定的鍵
  3. .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 包的一層高階函式
    明天見!

上一篇
Vue 走過路過不要錯過 Day11 - v-for 與 key:為什麼不要用 index 當 key
下一篇
Vue 走過路過不要錯過 Day13 -v-model 表單綁定:input、checkbox、select 與修飾符
系列文
重新認識Vue 走過路過不要錯過 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言