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 是父元件把資料傳給子元件的主要方式。
<!-- Parent.vue -->
<template>
<UserCard :user="user" />
</template><!-- UserCard.vue -->
<script setup lang="ts">
defineProps<{
user: {
id: number
name: string
}
}>()
</script>Props 的原理可以理解成:父元件 render 時,會把資料作為子元件 VNode 的 props 傳入;父元件資料更新後,子元件會收到新的 props 並重新更新。
重點是:Props 是單向的。子元件不應該直接修改父元件傳下來的 Props。
3. 子傳父:Emit
子元件不能直接改父元件狀態,所以要透過事件通知父元件。
<!-- 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><!-- Parent.vue -->
<template>
<UserCard
:user="user"
@select="selectedId = $event"
/>
</template>Emit 的本質不是 DOM 事件冒泡,而是 Vue 元件事件。子元件發出事件,父元件在使用子元件的位置監聽。
4. 父子雙向同步:v-model
當父元件需要傳值給子元件,子元件也需要回報更新時,可以用 v-model。
<BaseInput v-model="title" />在 Vue 3 中,它會展開成:
<BaseInput
:model-value="title"
@update:model-value="title = $event"
/>所以 v-model 不是魔法,而是 Props + Emit 的語法糖。
如果你想完整理解它,可以接著讀:v-model 雙向綁定的原理。
5. 祖先傳後代:Provide / Inject
當資料需要從祖先元件傳到深層後代,如果一路 Props drilling 會很吵,可以用 Provide / Inject。
<!-- AppShell.vue -->
<script setup lang="ts">
import { provide, ref } from 'vue'
const theme = ref('dark')
provide('theme', theme)
</script><!-- 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。
<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 暴露方法:
<script setup lang="ts">
function focus() {
// focus input
}
defineExpose({
focus,
})
</script>Refs 是命令式通訊,耦合度比 Props / Emit 高。適合用在 DOM 操作或明確命令,不適合拿來做一般資料同步。
7. 兄弟元件通訊:提升狀態
兄弟元件沒有直接父子關係時,最常見的做法是把狀態提升到共同父元件。
Parent
├─ SearchInput
└─ ResultListSearchInput 不應該直接通知 ResultList。比較好的做法是:
SearchInputemit keyword 給 Parent。- Parent 更新 keyword。
- Parent 把 keyword 或查詢結果透過 props 傳給
ResultList。
這樣資料來源清楚,兩個兄弟元件也不需要互相知道對方存在。
8. 遠距或全域狀態:Pinia
如果狀態被很多頁面或元件共享,或者需要跨路由保留,就可以考慮 Pinia。
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 元件監聽事件。
import mitt from 'mitt'
export const bus = mitt<{
refresh: void
}>()bus.emit('refresh')
bus.on('refresh', () => {
// refresh something
})它的問題是資料流不透明。事件從哪裡發、誰在聽、什麼時候取消訂閱,都容易變得難追蹤。
現在實務上,Event Bus 通常只適合:
- 很小範圍的非核心事件。
- 舊專案維護。
- 快速接第三方事件。
如果是業務狀態,通常 Pinia 或明確的父子資料流更好。
10. Slots:不是傳資料,而是傳內容結構
Slots 常被放進元件通訊題,因為它讓父元件把「內容」交給子元件渲染。
<BaseCard>
<template #header>
使用者資料
</template>
<UserProfile :user="user" />
</BaseCard>Scoped Slot 則是子元件把資料暴露給父元件提供的插槽內容:
<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。
<BaseInput v-model="title" />等價於:
<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。比較好的設計是把狀態放在共同父元件:
<!-- 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><!-- SearchInput.vue -->
<template>
<input v-model="model" />
</template>
<script setup lang="ts">
const model = defineModel<string>({ default: '' })
</script><!-- 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 則是用來傳內容或渲染結構。實務上我會優先保持資料流清楚,避免為了方便把局部狀態過早全域化。
延伸閱讀
- 前置補充:Vue 核心觀念:MVVM、宣告式渲染與單向資料流
- 前置補充:Vue 組件的 data 為什麼必須是函數
- 前置補充:v-model 雙向綁定的原理
- 前置補充:v-if 與 v-show 的區別
- 前置補充:watch 與 computed 的區別
- 前置補充:Vue 中 ref 的作用
- 前置補充:nextTick 的作用與實現原理
- 前一篇:Composition API 實戰與 Composable 設計
- 下一篇:Vue 狀態管理:Pinia、Vuex 與狀態歸屬
- 相關專題:Vue 插槽設計:Slot、Scoped Slot 與 Headless Component
- 相關專題:Vue 路由實現:Hash 路由與 History 路由原理
- 進階補充:Vue 2.x 與 Vue 3.x 渲染器 Diff 算法