vue props emit provide inject v-model data flow

[Vue] 資料流

單向資料流(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'

真的要改,有兩條路:

  1. emit 通知父元件去改(推薦)
  2. 複製一份到本地 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>

注意事項

  1. 只能往下:inject 只拿得到祖先 provide 的資料,不能反過來。
  2. 預設值:找不到對應的 provide 時,可以給個預設值:
    const user = inject('user', { name: 'Guest' })
  3. 唯讀:不想讓子孫亂改,就包一層 readonly
    provide('user', readonly(user))
  4. 用 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-modelprops + emit 的語法糖
全域狀態Pinia不相關元件間共享,邏輯複雜時用

決策流程

  1. 只是父子之間傳?→ props + emit
  2. 需要雙向綁定?→ v-model
  3. 跨好幾層但只限某棵子樹?→ provide / inject
  4. 整個 app 都要用到?→ Pinia(後面的文章會講)

其實資料流的核心觀念真的就是「單向資料流」。理解了這個,其他的方式都只是在不同情境下的工具選擇而已。不管用哪種方式,資料的「源頭」都應該要清楚可追蹤,這樣 debug 的時候才不會一臉茫然。

Latest Updates

  • 2026.06.11 Content updated