單向資料流(One-way Data Flow)
Vue 元件之間傳資料,有一條核心原則:
- 資料從父元件往下流到子元件
- 子元件不能直接改父元件的資料,只能發事件通知父元件去改
- 這樣資料的來源和流向都看得清楚,debug 起來輕鬆很多
props — 父傳子
父元件把資料當成屬性,往下傳給子元件。
<!-- 父元件 -->
<script setup>
import { ref } from 'vue'
import UserCard from './UserCard.vue'
const userName = ref('Jeremy')
</script>
<template>
<UserCard :name="userName" />
</template>
<!-- 子元件 UserCard.vue -->
<script setup>
const props = defineProps({
name: {
type: String,
required: true
}
})
</script>
<template>
<div class="card">
<p>名字:{{ name }}</p>
</div>
</template>
defineProps 寫法
Runtime 宣告:
const props = defineProps({
name: String,
age: {
type: Number,
default: 18
},
items: {
type: Array,
default: () => [] // 物件/陣列的預設值要用函式
}
})
TypeScript 寫法:
const props = defineProps<{
name: string
age?: number
items?: string[]
}>()
TS 寫法要設預設值的話,搭配 withDefaults:
const props = withDefaults(defineProps<{
name: string
age?: number
}>(), {
age: 18
})
props 是唯讀的
子元件不能直接修改父元件傳下來的 props:
// ❌ 這樣會噴警告
props.name = 'Aira'
真的要改,有兩條路:
- 用
emit通知父元件去改(推薦) - 複製一份到本地
ref(特定情境才用)
const localName = ref(props.name)
// 注意:之後 props.name 再變,localName 不會跟著變
如果想要本地可以改、又要跟著 props 更新,就再搭一個 watch 同步:
import { ref, watch } from 'vue'
const localName = ref(props.name)
watch(() => props.name, (v) => {
localName.value = v
})
emit — 子傳父
子元件想跟父元件溝通,就「發射事件」上去,怎麼處理由父元件決定。
<!-- 子元件 Counter.vue -->
<script setup>
const emit = defineEmits(['increment', 'decrement'])
function handleClick() {
emit('increment', 1) // 第二個參數是要帶給父元件的資料
}
</script>
<template>
<button @click="handleClick">+1</button>
<button @click="emit('decrement', 1)">-1</button>
</template>
<!-- 父元件 -->
<script setup>
import { ref } from 'vue'
import Counter from './Counter.vue'
const count = ref(0)
function handleIncrement(n) {
count.value += n
}
function handleDecrement(n) {
count.value -= n
}
</script>
<template>
<p>目前數量:{{ count }}</p>
<Counter @increment="handleIncrement" @decrement="handleDecrement" />
</template>
TypeScript 寫法
const emit = defineEmits<{
increment: [amount: number]
decrement: [amount: number]
}>()
provide / inject — 跨層級傳資料
props / emit 在父子之間用起來很順,但元件巢狀一深就麻煩了:
App
└── Layout
└── Sidebar
└── UserInfo
└── Avatar ← 它需要 App 層的 user 資料
如果每一層都用 props 接力傳下去:
App (user) → Layout (:user) → Sidebar (:user) → UserInfo (:user) → Avatar (:user)
這就是 props drilling——中間那幾層根本用不到這份資料,卻被迫當傳聲筒,維護起來很累。
provide / inject 就是來解這個的:祖先元件用 provide 把資料提供出去,後代元件不管隔幾層,直接用 inject 拿,中間層完全不用管。
<!-- 祖先元件 App.vue -->
<script setup>
import { ref, provide } from 'vue'
const user = ref({ name: 'Jeremy', role: 'admin' })
provide('user', user) // key, value
</script>
<!-- 後代元件 Avatar.vue(不管隔了幾層) -->
<script setup>
import { inject } from 'vue'
const user = inject('user')
// user 是響應式的,因為提供的就是 ref
</script>
<template>
<img :alt="user?.name" />
<span>{{ user?.name }}</span>
</template>
注意事項
- 只能往下:inject 只拿得到祖先 provide 的資料,不能反過來。
- 預設值:找不到對應的 provide 時,可以給個預設值:
const user = inject('user', { name: 'Guest' }) - 唯讀:不想讓子孫亂改,就包一層
readonly:provide('user', readonly(user)) - 用 Symbol 當 key:字串 key 可能撞名,大型專案建議改用 Symbol:
// keys.js export const USER_KEY = Symbol('user') // 提供 provide(USER_KEY, user) // 注入 const user = inject(USER_KEY)
適用場景
- 要跨很多層的資料(theme、locale、user 資訊)
- 一組元件共享狀態(像
<Form>和它底下所有<Input>共享驗證邏輯) - 想跨元件共享,但還用不到全域狀態管理(Pinia)的時候
至於完全不相關的元件要共享(像 Header 和 Footer),或狀態邏輯已經很複雜,那就直接上 Pinia。
v-model — 雙向綁定的語法糖
最常見的用法是表單:
<input v-model="name" />
但 v-model 其實就是 props + emit 的語法糖!上面那行等同於:
<input :value="name" @input="name = $event.target.value" />
在自訂元件上用 v-model
這是實務上很常用的技巧。當你封裝一個自訂的 Input 元件時:
<!-- 父元件 -->
<CustomInput v-model="searchText" />
<!-- CustomInput.vue -->
<script setup>
const model = defineModel()
</script>
<template>
<input :value="model" @input="model = $event.target.value" />
</template>
Vue 3.4 引入了 defineModel,讓這件事變得超級簡單。在此之前你得自己定義 props 和 emit,現在一行搞定。
多個 v-model
一個元件可以有多個 v-model:
<!-- 父元件 -->
<UserForm
v-model:firstName="first"
v-model:lastName="last"
/>
<!-- UserForm.vue -->
<script setup>
const firstName = defineModel('firstName')
const lastName = defineModel('lastName')
</script>
<template>
<input v-model="firstName" placeholder="First" />
<input v-model="lastName" placeholder="Last" />
</template>
v-model 修飾符
Vue 內建了幾個好用的修飾符:
<!-- .lazy:從 input 事件改成 change 事件(失去焦點才更新) -->
<input v-model.lazy="msg" />
<!-- .number:自動轉成數字 -->
<input v-model.number="age" type="number" />
<!-- .trim:自動去除頭尾空白 -->
<input v-model.trim="name" />
什麼場景用什麼方式?
| 場景 | 方式 | 說明 |
|---|---|---|
| 父 → 子傳資料 | props | 最基本,單向往下 |
| 子 → 父通知事件 | emit | 子元件不能改 props,只能發事件 |
| 跨多層傳資料 | provide / inject | 避免 props drilling |
| 表單雙向綁定 | v-model | props + emit 的語法糖 |
| 全域狀態 | Pinia | 不相關元件間共享,邏輯複雜時用 |
決策流程
- 只是父子之間傳?→ props + emit
- 需要雙向綁定?→ v-model
- 跨好幾層但只限某棵子樹?→ provide / inject
- 整個 app 都要用到?→ Pinia(後面的文章會講)
其實資料流的核心觀念真的就是「單向資料流」。理解了這個,其他的方式都只是在不同情境下的工具選擇而已。不管用哪種方式,資料的「源頭」都應該要清楚可追蹤,這樣 debug 的時候才不會一臉茫然。
Latest Updates
- 2026.06.11 Content updated
