Vue 事件處理與 Emit

在 Vue 中,元件可以透過事件(Events)與父元件進行通訊。子元件發送事件,父元件監聽並處理這些事件,這是實現子傳父通訊的主要方式。

監聽事件

使用 v-on 指令(簡寫 @)來監聯 DOM 事件:

<script setup>
import { ref } from 'vue'

const count = ref(0)

function increment() {
  count.value++
}
</script>

<template>
  <!-- 完整語法 -->
  <button v-on:click="increment">+1</button>

  <!-- 簡寫語法(推薦) -->
  <button @click="increment">+1</button>

  <!-- 內聯表達式 -->
  <button @click="count++">+1</button>

  <p>計數:{{ count }}</p>
</template>

方法處理器 vs 內聯處理器

方法處理器

當事件處理邏輯較複雜時,建議使用方法處理器:

<script setup>
function handleClick(event) {
  // event 是原生 DOM 事件
  console.log(event.target.tagName)
}
</script>

<template>
  <button @click="handleClick">點擊</button>
</template>

內聯處理器

簡單的操作可以直接寫在模板中:

<script setup>
import { ref } from 'vue'

const count = ref(0)

function say(message) {
  alert(message)
}
</script>

<template>
  <button @click="count++">+1</button>
  <button @click="say('Hello')">打招呼</button>
</template>

在內聯處理器中存取事件物件

<script setup>
function handleClick(message, event) {
  console.log(message)
  console.log(event.target)
}
</script>

<template>
  <!-- 使用特殊變數 $event -->
  <button @click="handleClick('Hello', $event)">點擊</button>

  <!-- 使用箭頭函式 -->
  <button @click="(event) => handleClick('Hello', event)">點擊</button>
</template>

定義元件事件

子元件使用 defineEmits() 來定義可以發送的事件:

陣列語法

<!-- ChildComponent.vue -->
<script setup>
// 定義可發送的事件
const emit = defineEmits(['submit', 'cancel'])

function handleSubmit() {
  emit('submit')
}

function handleCancel() {
  emit('cancel')
}
</script>

<template>
  <button @click="handleSubmit">送出</button>
  <button @click="handleCancel">取消</button>
</template>

物件語法(帶驗證)

<script setup>
const emit = defineEmits({
  // 沒有驗證
  click: null,

  // 有驗證函式
  submit: (payload) => {
    if (payload.email && payload.password) {
      return true
    }
    console.warn('Invalid submit event payload!')
    return false
  }
})

function handleSubmit() {
  emit('submit', { email: 'test@example.com', password: '123456' })
}
</script>

監聽元件事件

父元件使用 @ 來監聽子元件發送的事件:

<!-- ParentComponent.vue -->
<script setup>
import ChildComponent from './ChildComponent.vue'

function onSubmit() {
  console.log('子元件發送了 submit 事件')
}

function onCancel() {
  console.log('子元件發送了 cancel 事件')
}
</script>

<template>
  <ChildComponent
    @submit="onSubmit"
    @cancel="onCancel"
  />
</template>

事件傳遞參數

子元件可以在發送事件時傳遞資料:

<!-- ChildComponent.vue -->
<script setup>
import { ref } from 'vue'

const emit = defineEmits(['update'])

const inputValue = ref('')

function handleUpdate() {
  // 第二個參數起是傳遞的資料
  emit('update', inputValue.value)
}
</script>

<template>
  <input v-model="inputValue">
  <button @click="handleUpdate">更新</button>
</template>
<!-- ParentComponent.vue -->
<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'

const message = ref('')

function handleUpdate(value) {
  // value 是子元件傳來的資料
  message.value = value
}
</script>

<template>
  <ChildComponent @update="handleUpdate" />
  <p>接收到的值:{{ message }}</p>

  <!-- 也可以直接內聯 -->
  <ChildComponent @update="(val) => message = val" />
</template>

傳遞多個參數

<!-- 子元件 -->
<script setup>
const emit = defineEmits(['submit'])

function handleSubmit() {
  emit('submit', 'John', 'john@example.com', { role: 'admin' })
}
</script>

<!-- 父元件 -->
<script setup>
function onSubmit(name, email, meta) {
  console.log(name, email, meta)
}
</script>

<template>
  <ChildComponent @submit="onSubmit" />
</template>

事件命名慣例

事件名稱推薦使用 kebab-case

<!-- 子元件 -->
<script setup>
const emit = defineEmits(['update-user', 'delete-user'])
</script>

<!-- 父元件 -->
<template>
  <UserCard
    @update-user="handleUpdate"
    @delete-user="handleDelete"
  />
</template>

使用 TypeScript 定義事件

<script setup lang="ts">
// 方式一:使用泛型
const emit = defineEmits<{
  (e: 'update', value: string): void
  (e: 'submit', email: string, password: string): void
}>()

// 方式二:Vue 3.3+ 更簡潔的語法
const emit = defineEmits<{
  update: [value: string]
  submit: [email: string, password: string]
}>()
</script>

實際範例

計數器元件

<!-- Counter.vue -->
<script setup>
defineProps({
  count: {
    type: Number,
    default: 0
  }
})

const emit = defineEmits(['increment', 'decrement', 'reset'])
</script>

<template>
  <div class="counter">
    <button @click="emit('decrement')">-</button>
    <span>{{ count }}</span>
    <button @click="emit('increment')">+</button>
    <button @click="emit('reset')">重置</button>
  </div>
</template>
<!-- App.vue -->
<script setup>
import { ref } from 'vue'
import Counter from './Counter.vue'

const count = ref(0)
</script>

<template>
  <Counter
    :count="count"
    @increment="count++"
    @decrement="count--"
    @reset="count = 0"
  />
</template>

搜尋框元件

<!-- SearchBox.vue -->
<script setup>
import { ref } from 'vue'

const emit = defineEmits(['search', 'clear'])

const query = ref('')

function handleSearch() {
  if (query.value.trim()) {
    emit('search', query.value.trim())
  }
}

function handleClear() {
  query.value = ''
  emit('clear')
}
</script>

<template>
  <div class="search-box">
    <input
      v-model="query"
      type="text"
      placeholder="請輸入搜尋關鍵字"
      @keyup.enter="handleSearch"
    >
    <button @click="handleSearch">搜尋</button>
    <button @click="handleClear" v-if="query">清除</button>
  </div>
</template>

<style scoped>
.search-box {
  display: flex;
  gap: 10px;
}

input {
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  flex: 1;
}

button {
  padding: 8px 16px;
  background-color: #42b883;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

button:hover {
  background-color: #3aa876;
}
</style>
<!-- App.vue -->
<script setup>
import { ref } from 'vue'
import SearchBox from './SearchBox.vue'

const searchResults = ref([])

async function handleSearch(query) {
  console.log('搜尋關鍵字:', query)
  // 執行搜尋邏輯...
}

function handleClear() {
  searchResults.value = []
}
</script>

<template>
  <SearchBox @search="handleSearch" @clear="handleClear" />
</template>

確認對話框元件

<!-- ConfirmDialog.vue -->
<script setup>
defineProps({
  title: {
    type: String,
    default: '確認'
  },
  message: {
    type: String,
    required: true
  },
  visible: {
    type: Boolean,
    default: false
  }
})

const emit = defineEmits(['confirm', 'cancel', 'update:visible'])

function handleConfirm() {
  emit('confirm')
  emit('update:visible', false)
}

function handleCancel() {
  emit('cancel')
  emit('update:visible', false)
}
</script>

<template>
  <div v-if="visible" class="dialog-overlay" @click.self="handleCancel">
    <div class="dialog">
      <h3>{{ title }}</h3>
      <p>{{ message }}</p>
      <div class="actions">
        <button class="cancel" @click="handleCancel">取消</button>
        <button class="confirm" @click="handleConfirm">確認</button>
      </div>
    </div>
  </div>
</template>

<style scoped>
.dialog-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
}

.dialog {
  background: white;
  padding: 20px;
  border-radius: 8px;
  min-width: 300px;
}

.actions {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
  margin-top: 20px;
}

button {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.cancel {
  background-color: #ddd;
}

.confirm {
  background-color: #42b883;
  color: white;
}
</style>
<!-- App.vue -->
<script setup>
import { ref } from 'vue'
import ConfirmDialog from './ConfirmDialog.vue'

const showDialog = ref(false)

function handleConfirm() {
  console.log('使用者確認了操作')
  // 執行刪除等操作...
}

function handleCancel() {
  console.log('使用者取消了操作')
}
</script>

<template>
  <button @click="showDialog = true">刪除項目</button>

  <ConfirmDialog
    v-model:visible="showDialog"
    title="刪除確認"
    message="確定要刪除這個項目嗎?此操作無法復原。"
    @confirm="handleConfirm"
    @cancel="handleCancel"
  />
</template>

原生事件 vs 元件事件

在元件上使用 @click 等原生事件名稱時,預設監聽的是元件發出的自訂事件,而不是原生 DOM 事件。

如果想要監聽原生事件,需要在元件內部自己處理,或使用 v-on.native 修飾符(Vue 3 已移除此修飾符)。

在 Vue 3 中,如果元件有單一根元素,未被 defineEmits 聲明的事件會自動「穿透」到根元素:

<!-- MyButton.vue -->
<script setup>
// 沒有聲明 click 事件
</script>

<template>
  <button><slot /></button>
</template>

<!-- 父元件 -->
<template>
  <!-- 這個 @click 會自動綁定到 button 元素上 -->
  <MyButton @click="handleClick">點我</MyButton>
</template>

如果不想要這種行為,可以使用 defineOptions 禁用繼承:

<script setup>
defineOptions({
  inheritAttrs: false
})
</script>