Vue 的组件化开发中,父子组件之间如何传递数据是最基础、也最常被问到的问题。Vue 2 与 Vue 3 在这一点上既有共通之处,也有不少差异。本文把两代版本中常用的传值方式整理成一份对照速查,每种方式都给出可直接参考的代码。
阅读约定:下文代码中的「父组件」指数据的持有方或调用方,「子组件」指被引入、被渲染的一方。
目录
Props 传值(父 → 子)
事件传值(子 → 父)
双向绑定(v-model)
父组件访问子组件(ref)
provide / inject 依赖注入
slot 插槽
Vue 2 与 Vue 3 的差异对照
最佳实践
一、Props 传值(父 → 子)
Props 是单向数据流的基础:父组件通过属性把数据「送下去」,子组件只读不写。这是最常用、也是最推荐的方式。
1.1 父组件
<template>
<div>
<ChildComponent
:message="parentMessage"
:user="userInfo"
:count="123"
/>
</div>
</template>
<script>
import ChildComponent from './ChildComponent.vue'
export default {
components: { ChildComponent },
data() {
return {
parentMessage: 'Hello from Parent',
userInfo: { name: 'John', age: 25 }
}
}
}
</script>1.2 子组件
子组件必须显式声明接收哪些 props。声明时写清类型与默认值,既能让代码自解释,也能在传错类型时得到开发环境警告:
<template>
<div>
<p>Message: {{ message }}</p>
<p>User: {{ user.name }}</p>
<p>Count: {{ count }}</p>
</div>
</template>
<script>
export default {
props: {
message: {
type: String,
required: true
},
user: {
type: Object,
// 对象/数组类型的默认值必须用工厂函数返回
default: () => ({})
},
count: {
type: Number,
default: 0
}
}
}
</script>一个高频坑:对象和数组的 default 必须写成 () => ({}) 这样的工厂函数。如果直接写 default: {},所有子组件实例会共用同一个对象引用,一个实例改了,其他实例跟着变。
1.3 更完整的 Props 声明(Vue 3)
Vue 3 保留了对象式写法,同时可以借助 PropType 补上 TypeScript 类型推导:
<script>
import { PropType } from 'vue'
export default {
props: {
// 基础类型
message: String,
// 允许多种类型
value: [String, Number],
// 自定义对象类型
user: {
type: Object as PropType<{
name: string
age: number
}>,
required: true
},
// 函数类型
callback: {
type: Function as PropType<(id: number) => void>,
default: () => {}
},
// 自定义校验
age: {
type: Number,
validator: (value) => value >= 0 && value <= 150
}
}
}
</script>1.4 使用 <script setup> 的写法
Composition API 下用 defineProps 声明,运行时行为与上面的对象式写法完全等价:
<script setup>
const props = defineProps({
message: {
type: String,
required: true
},
count: {
type: Number,
default: 0
}
})
</script>
<template>
<p>{{ props.message }} - {{ props.count }}</p>
</template>这里有一个容易混淆的点:props 是只读的。如果子组件需要「修改」传入的值,正确做法是把它复制到本地状态,或通过事件让父组件去改,而不是直接赋值——直接改会在开发环境报错,且破坏了单向数据流的可预测性。
二、事件传值(子 → 父)
数据要往上传,靠的是自定义事件:子组件 $emit 抛出一个事件,父组件在模板上用 @事件名 监听。
2.1 子组件
<template>
<div>
<button @click="sendMessage">发送消息给父组件</button>
<button @click="sendUser">发送用户数据</button>
</div>
</template>
<script>
export default {
methods: {
sendMessage() {
this.$emit('message', 'Hello from Child')
},
sendUser() {
this.$emit('update:user', {
name: 'Child User',
age: 18
})
}
}
}
</script>2.2 父组件
<template>
<div>
<ChildComponent
@message="handleMessage"
@update:user="handleUserUpdate"
/>
<p>Received: {{ receivedMessage }}</p>
<p>User: {{ userInfo }}</p>
</div>
</template>
<script>
import ChildComponent from './ChildComponent.vue'
export default {
components: { ChildComponent },
data() {
return {
receivedMessage: '',
userInfo: {}
}
},
methods: {
handleMessage(msg) {
this.receivedMessage = msg
},
handleUserUpdate(user) {
this.userInfo = user
}
}
}
</script>2.3 Vue 3 中用 defineEmits 显式声明
Vue 3 推荐把组件会抛出的事件显式列出来。好处是事件会被记入组件契约,父组件拼错事件名时能有提示,同时 emits 里声明过的事件名不会再被当作原生 DOM 事件透传到根元素上:
<script setup>
const emit = defineEmits({
// 带校验的写法
submit: (payload) => {
if (!payload || !payload.id) {
console.warn('submit 事件需要携带 id')
return false
}
return true
},
// 只声明不校验
cancel: null
})
function onSubmit() {
emit('submit', { id: 1, name: 'demo' })
}
</script>
<template>
<button @click="onSubmit">提交</button>
<button @click="emit('cancel')">取消</button>
</template>三、双向绑定(v-model)
v-model 本质上是「props 传值 + 事件回传」的语法糖,把上面两节的内容合成了一个指令。但两代版本的默认约定不同,这是从 Vue 2 迁移到 Vue 3 时最容易踩的点。
3.1 Vue 2 的实现
Vue 2 的约定是固定的:prop 名必须是 value,事件名必须是 input,不能改。
<!-- 父组件 -->
<template>
<div>
<ChildComponent v-model="message" />
<p>父组件显示: {{ message }}</p>
</div>
</template><!-- 子组件 -->
<template>
<div>
<input
:value="value"
@input="$emit('input', $event.target.value)"
/>
</div>
</template>
<script>
export default {
props: ['value']
}
</script>3.2 Vue 3 的实现
Vue 3 把约定改成了 modelValue + update:modelValue,并且允许通过参数自定义名称,从而支持多个 v-model:
<!-- 父组件 -->
<template>
<ChildComponent v-model="message" />
<p>父组件显示: {{ message }}</p>
</template><!-- 子组件 -->
<template>
<div>
<input v-model="internalValue" />
</div>
</template>
<script>
import { ref, watch } from 'vue'
export default {
props: {
modelValue: {
type: String,
default: ''
}
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const internalValue = ref(props.modelValue)
// 内部变化 → 通知父组件
watch(internalValue, (newValue) => {
emit('update:modelValue', newValue)
})
// 外部变化 → 同步到内部
watch(() => props.modelValue, (newValue) => {
internalValue.value = newValue
})
return { internalValue }
}
}
</script>3.3 更简洁的写法:可写计算属性
上面两个 watch 来回同步显得啰嗦。实践中更常见的做法是用 computed 的 getter/setter 一步到位:
<script setup>
import { computed } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue'])
const internalValue = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
</script>
<template>
<input v-model="internalValue" />
</template>3.4 多个 v-model
<!-- 父组件:同时绑定标题与正文 -->
<template>
<ArticleEditor
v-model:title="pageTitle"
v-model:content="pageContent"
/>
</template>
<!-- 子组件:接收 title / content 两个 prop -->
<script setup>
defineProps({
title: { type: String, default: '' },
content: { type: String, default: '' }
})
const emit = defineEmits(['update:title', 'update:content'])
</script>四、父组件访问子组件(ref)
前面几种方式都是「数据」的流动。还有一种场景是父组件需要主动调用子组件的方法,比如让弹窗打开、让表单重置。这时用模板 ref 拿到子组件实例。
4.1 Vue 2 写法
<!-- 父组件 -->
<template>
<div>
<ChildComponent ref="child" />
<button @click="openChild">打开子组件</button>
</div>
</template>
<script>
import ChildComponent from './ChildComponent.vue'
export default {
components: { ChildComponent },
methods: {
openChild() {
// 直接调用子组件暴露的方法
this.$refs.child.open()
}
}
}
</script>4.2 Vue 3 写法
Vue 3 中必须先声明一个与 ref 同名的变量,实例才会被赋进去:
<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'
const childRef = ref(null)
function openChild() {
childRef.value.open()
}
</script>
<template>
<ChildComponent ref="childRef" />
<button @click="openChild">打开子组件</button>
</template>注意:<script setup> 的组件默认是封闭的,父组件通过 ref 拿不到任何东西。子组件需要显式用 defineExpose 声明哪些方法可以被外部调用。这个限制是有意为之——它让组件的公开接口变得明确,而不是把内部实现全部暴露出去。
<!-- 子组件:只暴露 open 方法 -->
<script setup>
function open() {
// ...
}
defineExpose({ open })
</script>五、provide / inject 依赖注入
当层级很深时(祖父 → 父 → 子 → 孙),用 props 一层层往下传会变成「prop 逐级透传」,中间那些组件明明不需要这个数据却被迫声明。provide / inject 用来解决这个问题:祖先组件提供数据,任意深度的后代组件直接注入使用。
5.1 基础用法
<!-- 祖先组件 -->
<script>
import ChildComponent from './ChildComponent.vue'
export default {
components: { ChildComponent },
data() {
return {
theme: 'dark',
user: { name: 'Admin', role: 'admin' }
}
},
provide() {
return {
theme: this.theme,
user: this.user,
updateUser: this.updateUser
}
},
methods: {
updateUser(name) {
this.user.name = name
}
}
}
</script><!-- 后代组件 -->
<template>
<div :class="theme">
<p>Theme: {{ theme }}</p>
<p>User: {{ user.name }}</p>
<button @click="changeName">改名</button>
</div>
</template>
<script>
export default {
inject: ['theme', 'user', 'updateUser'],
methods: {
changeName() {
// 注入的是一个函数引用,调用它会修改祖先组件的状态
this.updateUser('New Name')
}
}
}
</script>关键点:provide 是「一次性」的。provide() { return { theme: this.theme } } 传下去的是当时那个值,不是响应式的引用。祖先组件后来改了 theme,后代拿到的还是旧值。这是 provide/inject 最常见的理解偏差。
5.2 传递响应式数据(Vue 3)
要让注入的数据保持响应式,就要提供一个响应式对象本身,而不是它的当前值:
<!-- 祖先组件 -->
<script setup>
import { provide, ref, reactive } from 'vue'
const theme = ref('light')
const user = reactive({ name: 'John', age: 30 })
// 提供 ref / reactive 对象本身
provide('theme', theme)
provide('user', user)
function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
</script><!-- 后代组件 -->
<script setup>
import { inject, watch } from 'vue'
// 第二个参数是默认值,祖先没有 provide 时使用
const theme = inject('theme', ref('light'))
const user = inject('user', reactive({ name: 'Guest' }))
watch(theme, (newValue) => {
console.log('主题变了:', newValue)
})
</script>
<template>
<p>当前主题: {{ theme }}</p>
</template>5.3 用 Symbol 作为 key
provide/inject 的 key 是字符串,大项目里容易重名或拼错,而且拼错时不会报错,只是静默地拿到 undefined。用 Symbol 可以避免这个问题,代价是需要把 key 抽到一个共享文件里:
// keys.js
export const THEME_KEY = Symbol('theme')// 提供方
import { THEME_KEY } from './keys'
provide(THEME_KEY, theme)
// 注入方
import { THEME_KEY } from './keys'
const theme = inject(THEME_KEY)六、slot 插槽
插槽传递的不是数据,而是「内容」——父组件决定渲染什么,子组件决定渲染在哪里。它让组件具备了布局能力而不必知道具体内容。
6.1 默认插槽
<!-- 父组件 -->
<template>
<Card>
<h3>卡片标题</h3>
<p>这是卡片内容</p>
</Card>
</template><!-- 子组件 Card.vue -->
<template>
<div class="card">
<slot></slot>
</div>
</template>6.2 具名插槽
需要多个插入点时用 name 区分。注意写法在 Vue 2.6+ 与 Vue 3 中统一成了 v-slot / #,老教程里的 slot="header" 已废弃。
<!-- 父组件 -->
<template>
<Layout>
<template #header>
<h1>页面头部</h1>
</template>
<template #main>
<p>主要内容</p>
</template>
<template #footer>
<p>页脚</p>
</template>
</Layout>
</template><!-- 子组件 Layout.vue -->
<template>
<div class="layout">
<header class="header">
<slot name="header"></slot>
</header>
<main class="main">
<slot name="main"></slot>
</main>
<footer class="footer">
<slot name="footer"></slot>
</footer>
</div>
</template>6.3 作用域插槽:数据从子流向父
这是插槽里唯一带「传值」意味的形式,方向恰好和 props 相反:子组件把内部数据绑定到 <slot> 上,父组件通过 v-slot 的解构拿到它。典型用途是列表组件——组件负责遍历和逻辑,父组件负责决定每一项长什么样。
<!-- 父组件:拿到子组件内部的 item 和 index -->
<template>
<List :items="users">
<template #default="{ item, index }">
<div>
<strong>{{ item.name }}</strong>
<span>{{ index }}</span>
</div>
</template>
</List>
</template>
<script>
import List from './List.vue'
export default {
components: { List },
data() {
return {
users: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
]
}
}
}
</script><!-- 子组件 List.vue:把数据「抛」给插槽 -->
<template>
<div>
<div v-for="(item, index) in items" :key="item.id">
<slot :item="item" :index="index"></slot>
</div>
</div>
</template>
<script>
export default {
props: {
items: {
type: Array,
default: () => []
}
}
}
</script>七、Vue 2 与 Vue 3 的差异对照
传值方式 | 方向 | Vue 2 写法 | Vue 3 写法 |
|---|---|---|---|
Props | 父 → 子 |
|
|
自定义事件 | 子 → 父 |
|
|
v-model | 双向 | 固定 | 默认 |
多个 v-model | 双向 |
|
|
访问子组件实例 | 父 → 子 |
|
|
跨层级注入 | 祖先 → 后代 |
|
|
内容分发 | 父 → 子 |
| 统一 |
作用域插槽 | 子 → 父 |
|
|
两个需要特别留意的迁移点
v-model 的默认值变了。Vue 2 的
value在 Vue 3 中改为modelValue。如果子组件里还写着props: ['value'],父组件的v-model会静默失效——不报错,只是不生效。Vue 3 中
.sync被移除。原来的:title.sync="x"要改成v-model:title="x"。
八、最佳实践
8.1 先选对方式,再写代码
绝大多数「传值」问题其实在选型阶段就决定了成败。按这个顺序判断即可:
父子之间传数据 → props 向下,事件向上。这是默认选择,不要一上来就用 provide/inject 或全局状态。
需要「双向」的表单类交互 → v-model。它是 props + 事件的语法糖,语义清晰,代码也短。
父组件要触发子组件的动作 → ref + defineExpose。注意这属于「命令式」调用,能不用就不用,优先考虑用 props 驱动状态。
跨三四层以上传递,且中间层完全不关心 → provide/inject。
无关层级的组件之间要共享状态 → 都不合适,该上状态管理(Pinia / Vuex)。
8.2 props 设计:少而明确
// 好:每个 prop 都有独立语义,类型和默认值清楚
props: {
title: { type: String, required: true },
size: {
type: String,
default: 'medium',
validator: (value) => ['small', 'medium', 'large'].includes(value)
}
}
// 差:把所有配置塞进一个大对象
// props: { config: { type: Object } }用一个大对象承载所有配置,看起来省事,实际上让组件的接口变得不可发现——调用方必须翻源码才知道能传什么,也无法利用类型检查、默认值和校验器。
8.3 事件命名用 kebab-case
// 推荐:模板里可以用 @update-user 监听
this.$emit('update-user', userData)
this.$emit('item-selected', item)
// 不推荐:驼峰命名在模板中需要写成 @updateUser,
// 而 HTML 属性本身不区分大小写,容易在迁移或重构时出错
// this.$emit('updateUser', userData)8.4 不要直接修改 props
<script setup>
const props = defineProps({ count: Number })
// 错误:props 是只读的
// props.count++
// 正确做法一:需要独立的本地状态时,复制一份
import { ref, watch } from 'vue'
const localCount = ref(props.count)
watch(() => props.count, (value) => { localCount.value = value })
</script>如果子组件确实需要改这个值,说明这个状态的所有权应该在父组件——改成 v-model,或者抛一个事件让父组件去改。
8.5 列表渲染必须带 key
<template>
<!-- 推荐:用数据自身的唯一标识 -->
<div v-for="item in items" :key="item.id">{{ item.name }}</div>
<!-- 避免:用 index 作为 key -->
<!-- <div v-for="(item, index) in items" :key="index"> -->
</template>用 index 作 key 时,一旦列表发生插入、删除或排序,Vue 会复用错节点,导致输入框内容错位、组件状态串台等难以排查的问题。
九、小结
父传子用 props,子传父用事件,双向用 v-model。这三条覆盖了绝大部分场景。
v-model 在两代之间的约定不同:Vue 2 是
value+input,Vue 3 是modelValue+update:modelValue。迁移时这里最容易静默失效。provide/inject 传的是值不是引用。要让它保持响应式,必须 provide 响应式对象本身。
作用域插槽是唯一「子 → 父」的插槽形式,用于让父组件决定渲染内容、子组件提供数据。
Vue 3 的
<script setup>默认封闭,父组件想通过 ref 调用子组件方法,子组件必须defineExpose。props 永远只读。需要修改时,要么复制成本地状态,要么把所有权交还给父组件。
评论区