跳至主要內容
Skip to content

Vue 元件通訊方式有哪些及原理

Vue 元件通訊是面試高頻題。很多人會背出 Props、Emit、Provide/Inject、Vuex 或 Pinia,但面試官真正想知道的是:你能不能根據元件關係、資料歸屬與維護成本,選擇合適的通訊方式。

一句話回答:Vue 元件通訊的核心是資料流設計。父子關係優先用 Props / Emit,跨層共享可用 Provide / Inject,全域業務狀態交給 Pinia,內容組合用 Slots,必要時才用 Refs 或 Event Bus 這類耦合更高的方式。


一、 必考觀念

1. 元件通訊先看關係

選通訊方式前,先判斷元件之間的關係:

元件關係常見方式
父傳子Props
子傳父Emit
父子雙向同步v-model
父呼叫子方法Template Refs
祖先傳深層後代Provide / Inject
兄弟或遠距元件提升狀態、Pinia、Event Bus
跨頁或可分享狀態Pinia、URL、後端資料
父元件提供結構,子元件提供內容Slots / Scoped Slots

不要一看到「跨元件」就上全域 store。很多時候,把狀態提升到共同父元件,再透過 Props / Emit 傳遞,會比引入全域狀態更清楚。

2. 父傳子:Props

Props 是父元件把資料傳給子元件的主要方式。

vue
<!-- Parent.vue -->
<template>
  <UserCard :user="user" />
</template>
vue
<!-- UserCard.vue -->
<script setup lang="ts">
defineProps<{
  user: {
    id: number
    name: string
  }
}>()
</script>

Props 的原理可以理解成:父元件 render 時,會把資料作為子元件 VNode 的 props 傳入;父元件資料更新後,子元件會收到新的 props 並重新更新。

重點是:Props 是單向的。子元件不應該直接修改父元件傳下來的 Props。

3. 子傳父:Emit

子元件不能直接改父元件狀態,所以要透過事件通知父元件。

vue
<!-- Child.vue -->
<template>
  <button @click="emit('select', user.id)">
    選擇
  </button>
</template>

<script setup lang="ts">
const emit = defineEmits<{
  select: [id: number]
}>()

defineProps<{
  user: {
    id: number
    name: string
  }
}>()
</script>
vue
<!-- Parent.vue -->
<template>
  <UserCard
    :user="user"
    @select="selectedId = $event"
  />
</template>

Emit 的本質不是 DOM 事件冒泡,而是 Vue 元件事件。子元件發出事件,父元件在使用子元件的位置監聽。

4. 父子雙向同步:v-model

當父元件需要傳值給子元件,子元件也需要回報更新時,可以用 v-model

vue
<BaseInput v-model="title" />

在 Vue 3 中,它會展開成:

vue
<BaseInput
  :model-value="title"
  @update:model-value="title = $event"
/>

所以 v-model 不是魔法,而是 Props + Emit 的語法糖。

如果你想完整理解它,可以接著讀:v-model 雙向綁定的原理

5. 祖先傳後代:Provide / Inject

當資料需要從祖先元件傳到深層後代,如果一路 Props drilling 會很吵,可以用 Provide / Inject。

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

const theme = ref('dark')

provide('theme', theme)
</script>
vue
<!-- DeepChild.vue -->
<script setup lang="ts">
import { inject } from 'vue'
import type { Ref } from 'vue'

const theme = inject<Ref<string>>('theme')
</script>

它的原理是:祖先元件把值提供到元件實例的 provides 鏈上,後代元件沿著父鏈往上查找對應 key。

適合場景:

  • 主題設定。
  • 表單上下文。
  • Tabs、Menu、Tree 這類複合元件內部協作。
  • 依賴注入,例如 service、config。

不適合把所有業務資料都丟進 Provide / Inject。因為它不像 Props 那樣一眼能看出資料從哪裡來,過度使用會降低可追蹤性。

6. 父元件呼叫子元件:Template Refs

有時候父元件不是要傳資料,而是要命令子元件做事,例如 focus、reset、scrollTo。

vue
<template>
  <BaseInput ref="inputRef" />
  <button @click="focusInput">Focus</button>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import BaseInput from './BaseInput.vue'

const inputRef = ref<InstanceType<typeof BaseInput> | null>(null)

function focusInput() {
  inputRef.value?.focus()
}
</script>

子元件需要透過 defineExpose 暴露方法:

vue
<script setup lang="ts">
function focus() {
  // focus input
}

defineExpose({
  focus,
})
</script>

Refs 是命令式通訊,耦合度比 Props / Emit 高。適合用在 DOM 操作或明確命令,不適合拿來做一般資料同步。

7. 兄弟元件通訊:提升狀態

兄弟元件沒有直接父子關係時,最常見的做法是把狀態提升到共同父元件。

text
Parent
├─ SearchInput
└─ ResultList

SearchInput 不應該直接通知 ResultList。比較好的做法是:

  1. SearchInput emit keyword 給 Parent。
  2. Parent 更新 keyword。
  3. Parent 把 keyword 或查詢結果透過 props 傳給 ResultList

這樣資料來源清楚,兩個兄弟元件也不需要互相知道對方存在。

8. 遠距或全域狀態:Pinia

如果狀態被很多頁面或元件共享,或者需要跨路由保留,就可以考慮 Pinia。

typescript
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    profile: null as null | {
      id: number
      name: string
    },
  }),
  actions: {
    setProfile(profile: { id: number; name: string }) {
      this.profile = profile
    },
  },
})

Pinia 的原理可以理解成:把共享狀態抽到元件樹之外,用 Vue 的響應式系統管理;任何使用該 store 的元件,都會追蹤它讀取到的狀態,狀態改變後對應元件更新。

適合放 Pinia 的資料:

  • 登入使用者。
  • 權限資訊。
  • 全站設定。
  • 多頁共享的購物車、草稿、通知。

不適合放 Pinia 的資料:

  • 純 UI 暫存狀態。
  • 單一表單內部欄位。
  • 只被父子兩層使用的小狀態。

9. Event Bus

Event Bus 是透過一個共同事件中心,讓 A 元件發事件,B 元件監聽事件。

typescript
import mitt from 'mitt'

export const bus = mitt<{
  refresh: void
}>()
typescript
bus.emit('refresh')
bus.on('refresh', () => {
  // refresh something
})

它的問題是資料流不透明。事件從哪裡發、誰在聽、什麼時候取消訂閱,都容易變得難追蹤。

現在實務上,Event Bus 通常只適合:

  • 很小範圍的非核心事件。
  • 舊專案維護。
  • 快速接第三方事件。

如果是業務狀態,通常 Pinia 或明確的父子資料流更好。

10. Slots:不是傳資料,而是傳內容結構

Slots 常被放進元件通訊題,因為它讓父元件把「內容」交給子元件渲染。

vue
<BaseCard>
  <template #header>
    使用者資料
  </template>

  <UserProfile :user="user" />
</BaseCard>

Scoped Slot 則是子元件把資料暴露給父元件提供的插槽內容:

vue
<DataProvider v-slot="{ data, loading }">
  <UserList
    v-if="data"
    :users="data"
  />
  <LoadingView v-else-if="loading" />
</DataProvider>

Slots 的原理可以理解成:父元件傳入一段渲染函式,子元件在合適的位置呼叫它。它更像是「結構組合」而不是一般資料同步。


二、 通訊方式對比

方式方向適用場景風險
Props父到子展示資料、配置層級太深會 props drilling
Emit子到父回報互動、請求更新事件命名不清會難維護
v-model父子同步表單、受控元件容易誤以為子元件可直接改父狀態
Provide / Inject祖先到後代上下文、複合元件來源不如 Props 明顯
Refs父呼叫子focus、reset、scroll命令式耦合高
Pinia多處共享使用者、權限、購物車濫用會讓局部狀態全域化
Event Bus任意元件非核心事件、舊專案資料流不透明
Slots父提供內容佈局、Headless 元件API 設計差會難讀

三、 追問題庫

Q1:Props 和 Provide / Inject 怎麼選?

如果只是父子一兩層,優先 Props。它最直觀,也最容易追蹤。

如果是祖先要提供上下文給深層後代,例如表單、Tabs、Menu、Tree,可以用 Provide / Inject。

簡單判斷:

  • 資料是子元件明確輸入:用 Props。
  • 資料是整組元件共享上下文:用 Provide / Inject。

Q2:兄弟元件通訊要用 Event Bus 嗎?

通常不優先。

兄弟元件最常見做法是提升狀態到共同父層。如果狀態跨越很遠或多處共享,再考慮 Pinia。Event Bus 雖然快,但事件來源和接收方容易散落,長期維護成本高。

Q3:什麼時候該用 Pinia?

當狀態具備以下特徵時,可以考慮 Pinia:

  • 多個頁面或遠距元件都需要。
  • 需要跨路由保存。
  • 有明確業務語意。
  • 需要集中 action、權限或快取策略。

如果只是某個 Modal 開關、某個表單欄位,就不一定需要全域 store。

Q4:v-model 和 Emit 有什麼差別?

v-model 本質上就是特定命名規則的 Props + Emit。

vue
<BaseInput v-model="title" />

等價於:

vue
<BaseInput
  :model-value="title"
  @update:model-value="title = $event"
/>

差別在於 v-model 適合描述「這個元件有一個主要值,而且父層要同步它」。

Q5:Refs 能不能用來傳資料?

技術上可以,但不建議作為一般資料流。

Refs 適合命令式操作,例如 focus、scroll、reset。資料同步應該優先用 Props / Emit / Store,因為它們更符合 Vue 的資料流模型,也更容易測試。

Q6:Slots 算元件通訊嗎?

算,但它不是一般意義上的「資料同步」。

Slots 更像是父元件把一段內容或渲染邏輯交給子元件安放。Scoped Slots 則是子元件把局部資料提供給父元件的插槽內容使用。


四、 實作題

題目:設計一組 SearchInput 與 ResultList 通訊

需求:

  • SearchInput 負責輸入關鍵字。
  • ResultList 負責顯示結果。
  • 兩者是兄弟元件。
  • 輸入關鍵字後更新列表。

不要讓 SearchInput 直接呼叫 ResultList。比較好的設計是把狀態放在共同父元件:

vue
<!-- SearchPage.vue -->
<template>
  <SearchInput v-model="keyword" />
  <ResultList :keyword="keyword" />
</template>

<script setup lang="ts">
import { ref } from 'vue'
import SearchInput from './SearchInput.vue'
import ResultList from './ResultList.vue'

const keyword = ref('')
</script>
vue
<!-- SearchInput.vue -->
<template>
  <input v-model="model" />
</template>

<script setup lang="ts">
const model = defineModel<string>({ default: '' })
</script>
vue
<!-- ResultList.vue -->
<script setup lang="ts">
defineProps<{
  keyword: string
}>()
</script>

這個設計的好處是:

  • SearchInput 不知道 ResultList 存在。
  • ResultList 只依賴輸入資料,不依賴事件來源。
  • 父元件是狀態歸屬點,資料流清楚。

如果未來搜尋結果需要被多個頁面共享,才考慮把 keyword 或查詢結果抽到 Pinia。


五、 資深視角

1. 通訊方式其實是在決定耦合程度

Props / Emit 的耦合是明確的:父子關係、資料往下、事件往上。

Provide / Inject 的耦合比較隱性:後代依賴祖先提供某個 key。

Pinia 的耦合是全域狀態耦合:任何地方都能讀寫,方便但也需要規範。

Refs 的耦合最命令式:父元件知道子元件暴露了哪些方法。

資深工程師要能說清楚:我不是只知道有哪些 API,而是知道每種 API 會把系統耦合成什麼樣子。

2. 狀態歸屬比通訊技巧更重要

很多通訊問題,本質上是狀態放錯地方。

可以用這幾個問題判斷:

  • 這個狀態是誰擁有?
  • 誰需要讀它?
  • 誰可以改它?
  • 它需要跨頁保存嗎?
  • 它是業務狀態,還是純 UI 狀態?

狀態歸屬清楚後,通訊方式通常自然會浮現。

3. 元件 API 要讓資料流可預測

好的元件 API 會讓使用者一眼看懂:

  • 哪些資料由父層控制。
  • 哪些事件會被回報。
  • 哪些內容由 slot 插入。
  • 哪些方法需要 ref 才能呼叫。

壞的元件 API 則會混在一起:一部分用 props,一部分偷改 inject,一部分又用 event bus 通知。這會讓問題很難追。


總結

問題面試回答重點
父傳子Props
子傳父Emit
父子雙向同步v-model,本質是 Props + Emit
跨層傳遞Provide / Inject
父呼叫子方法Template Refs + defineExpose
兄弟元件提升狀態到共同父層
遠距共享Pinia 或 URL 狀態
傳內容結構Slots / Scoped Slots

一句完整的面試回答可以是:

Vue 元件通訊方式要看元件關係和狀態歸屬。父傳子用 Props,子傳父用 Emit,需要父子同步時用 v-model,它本質是 Props 加 update:* 事件。跨層上下文可以用 Provide / Inject,遠距或多頁共享的業務狀態用 Pinia,父元件要命令式呼叫子元件時用 Refs。Slots 則是用來傳內容或渲染結構。實務上我會優先保持資料流清楚,避免為了方便把局部狀態過早全域化。

延伸閱讀