Vue3子组件向父组件传参:emit、v-model与defineExpose
写 Vue3 的日子,后台管理系统里的这种需求几乎天天撞上:列表页里嵌一个筛选区,用户选好关键词、日期范围、状态,点查询,外层组件要拿到这些条件去重新请求接口。条件数据在子组件里,页面逻辑在父组件里,怎么把数据"顶"上去,就是标题里说的 vue3 子组件向父组件传参。这篇笔记我把实际项目里踩过的坑和常用写法都整理了一遍,从最基础的 emit 到进阶的 v-model 再到 ref 直接拿子组件方法,适合刚接触 Vue3 的初学者,也适合写了一段时间但没系统梳理过组件通信细节的朋友。
1. 子传父的核心思路:组件通信的方向问题
1.1 单向数据流是怎么回事
Vue 的组件体系有一条核心规则:父组件可以通过 props 给子组件传数据,但反过来,子组件不能直接改 props。这个"单向数据流"的设计不是故意刁难人,而是为了保证数据的变化是可预期的——如果任何组件都能随意改别人的数据,两个组件同时改一个变量,处理起来很容易出大乱子。
所以子组件想把数据交给父组件,就得换个方式。Vue 的答案是"事件回传":子组件把数据塞进事件里,向上抛出;父组件提前在模板上监听这个事件,事件触发了就从回调参数里拿数据。这就是典型的"子组件发送信号,父组件接收信号"模式。
这个设计看起来绕了一圈,但好处很明显:父子之间的数据传递路径非常清晰,调试的时候你可以在父组件的监听函数里打断点,能看到事件触发时带了哪些参数,问题定位迅速,代码也不会因为组件一多变成一锅粥。
1.2 三种常见子传父方案对比
我在实际项目里用到的子传父方案,主要是下面这三种,使用场景各不一样:
| 方案 | 实现方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|
| emit 事件 | defineEmits 定义事件,emit 触发并携带数据 | 子组件主动通知父组件,如点击按钮、选择项变化、表单提交 | 语义清晰,数据流直观,最通用 | 事件多了以后命名需要规划 |
| v-model 的 update 事件 | v-model:xxx 绑定值,子组件 emit update:xxx | 子组件本质是在编辑/绑定一个值,父组件需要同步拿到最新值 | 写起来像表单元素,父组件只需 v-model 一行搞定 | 一个 v-model 一般对应一个值,数据多了要拆多个 |
| ref + defineExpose | 父组件通过 ref 获取子组件实例,调用子组件暴露的方法或访问暴露的变量 | 父组件需要主动"找子组件要东西",比如清空表单、获取当前状态 | 拿数据直接,不需要事件来回触发 | 打破了事件流,逻辑触发点在父组件,不适合频繁、实时的数据同步 |
这三种方案并不是互斥的,一个组件里完全可以同时用:用 v-model 绑定主数据,用 emit 上报关键动作,再用 defineExpose 暴露一个重置方法。关键是搞清楚每种方案适合什么场景,别拿着锤子看什么都是钉子。
2. 主力方案:defineEmits + emit 事件传参
2.1 最小示例:子组件点按钮通知父组件
现在 Vue3 写组件基本都是
<script setup>
,写起来比 Vue2 的 Options API 清爽不少。子组件里第一步是"声明自己有哪些事件",第二步是"在需要的地方触发事件"。
子组件 SearchInput.vue:
<template>
<div class="search-input">
<input v-model="keyword" placeholder="请输入关键词" />
<button @click="submitSearch">搜索</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
// 第一步:声明这个组件能触发哪些事件
const emit = defineEmits(['search'])
const keyword = ref('')
function submitSearch() {
// 第二步:触发事件,把数据挂在事件参数上
emit('search', keyword.value)
}
</script>
父组件 Parent.vue:
<template>
<div class="parent-page">
<!-- 监听 search 事件,用 handleSearch 接收数据 -->
<SearchInput @search="handleSearch" />
<p>父组件拿到的关键词:{{ searchWord }}</p>
</div>
</template>
<script setup>
import { ref } from 'vue'
import SearchInput from './components/SearchInput.vue'
const searchWord = ref('')
function handleSearch(keyword) {
// keyword 就是子组件 emit 出来的数据
searchWord.value = keyword
}
</script>
我记得第一次写这个的时候有个认识误区:总以为子组件 emit 完数据就"直接改"了父组件的某个变量。其实不是,emit 只是把数据抛出去,父组件接不接、接了之后怎么处理,都由父组件自己决定。这有点像对讲机——子组件喊了一句话,父组件那边要不要回应、听完做什么,主动权在父组件手里。
2.2 传对象、传多个参数的方法
实际开发的时候,光传一个字符串肯定不够。最常见的场景是把整个查询条件打包成一个对象传过去。比如搜索表单里有关键词、有状态、有日期范围,你可以这样写:
<script setup>
const emit = defineEmits(['search'])
const form = ref({
keyword: '',
status: 'all',
dateRange: []
})
function handleSubmit() {
emit('search', { ...form.value })
// 注意这里用了展开运算符,把对象拷贝一份而不是传引用
// 避免父组件后面改数据时反向影响子组件的表单
}
</script>
父组件接收的时候,参数直接就是整个对象:
<script setup>
function handleSearch(query) {
// query 里包含了 keyword、status、dateRange
console.log(query)
}
</script>
如果你想一次性传多个参数,也可以,emit 支持多个参数:
<script setup>
emit('update-status', status.value, page.value, size.value)
</script>
<script setup>
function handleUpdateStatus(status, page, size) {
// 按顺序接收
}
</script>
这里有个小提醒:不要到处传裸参数。项目一复杂,一个事件后面挂五六个参数,过两周自己回来看代码都想骂人。你不知道哪个参数在前哪个在后,也不容易看出每个参数代表什么。更稳的做法是传一个对象,对象字段名自己会说话。传参的代码是给别人看的,也是给未来的自己看的。
2.3 TypeScript 项目里怎么写带类型的 emit
如果你用的是 TS 项目,那么 defineEmits 可以传一个更详细的类型对象进去,让父组件在模板里的事件绑定也有类型提示。这是我在团队里推广 TS 之后最爽的体验之一。
<script setup lang="ts">
interface SearchForm {
keyword: string
status: 'all' | 'pending' | 'done'
dateRange: [string, string] | []
}
// 事件名 + 参数类型
const emit = defineEmits<{
(e: 'search', form: SearchForm): void
(e: 'reset'): void
}>()
const form = ref<SearchForm>({
keyword: '',
status: 'all',
dateRange: []
})
function handleSubmit() {
emit('search', { ...form.value })
}
function handleReset() {
emit('reset')
}
</script>
写成这样之后,父组件里:
<template>
<SearchForm @search="handleSearch" @reset="handleReset" />
</template>
<script setup lang="ts">
import type { SearchForm } from './components/SearchForm.vue'
function handleSearch(form: SearchForm) {
// 鼠标悬停在 form 上,编辑器能直接提示出对象的字段
}
</script>
类型贯穿父子组件之后,重构的时候成就感特别强。以前改一个字段名,得全局搜索所有用到的地方,生怕漏了;现在改完类型定义,编译器直接报警,哪里没改一目了然。这种体验是写 JS 的时候完全体会不到的。
3. v-model 的进阶用法:把传参做得像表单绑定
3.1 自定义 v-model 子传父
Vue3 的 v-model 跟 Vue2 的 .sync 有异曲同工之妙,但写起来更规整。它的原理其实就是"值绑定 + 事件更新"的语法糖。子组件里声明一个
update:xxx
事件,触发时抛出新值,父组件用
v-model:xxx
一行搞定。
看一个最常见的例子:封装一个自定义下拉组件,父组件用 v-model 绑定选中的值。
子组件 MySelect.vue:
<template>
<select :value="modelValue" @change="handleChange">
<option v-for="item in options" :key="item.value" :value="item.value">
{{ item.label }}
</option>
</select>
</template>
<script setup>
// 注意:v-model 默认绑定的 prop 叫 modelValue
const props = defineProps<{
modelValue: string
options: { label: string; value: string }[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
function handleChange(e: Event) {
const target = e.target as HTMLSelectElement
// 触发 update:modelValue 事件,把新值传回父组件
emit('update:modelValue', target.value)
}
</script>
父组件里只需要:
<template>
<MySelect v-model="selectedCity" :options="cityOptions" />
<p>当前城市:{{ selectedCity }}</p>
</template>
<script setup>
import { ref } from 'vue'
import MySelect from './components/MySelect.vue'
const selectedCity = ref('')
const cityOptions = [
{ label: '北京', value: 'beijing' },
{ label: '上海', value: 'shanghai' },
{ label: '广州', value: 'guangzhou' }
]
</script>
这里要理解一个关键点:子组件里的
modelValue
其实还是父组件传下来的 props,子组件不能直接改。子组件能做的事情是触发
update:modelValue
事件,把新值抛回去,父组件的 v-model 会自动把这个值更新到绑定的变量上。所以这一整套机制的核心依然是"事件回传",只是 v-model 帮你把接收和赋值的过程封装了。
3.2 多个 v-model 一起用
我们项目里有个日期范围选择组件,同时要绑开始日期和结束日期,这时候一个 modelValue 就不够用了。Vue3 支持在 v-model 后面带上参数,这样可以在一个组件上绑多个值。
子组件 DateRangePicker.vue:
<template>
<div class="date-range-picker">
<input type="date" :value="startDate" @input="handleStartChange" />
<span>至</span>
<input type="date" :value="endDate" @input="handleEndChange" />
</div>
</template>
<script setup>
const props = defineProps<{
startDate: string
endDate: string
}>()
const emit = defineEmits<{
(e: 'update:startDate', value: string): void
(e: 'update:endDate', value: string): void
}>()
function handleStartChange(e: Event) {
const target = e.target as HTMLInputElement
emit('update:startDate', target.value)
}
function handleEndChange(e: Event) {
const target = e.target as HTMLInputElement
emit('update:endDate', target.value)
}
</script>
父组件里这样用:
<template>
<DateRangePicker
v-model:start-date="filter.startDate"
v-model:end-date="filter.endDate"
/>
</template>
注意,这里在模板里用
v-model:start-date
这种带连字符的写法,对应到子组件内部的 prop 名字是
startDate
,事件名是
update:startDate
。Vue 在模板里会自动做 kebab-case 和 camelCase 的转换,但在子组件
<script setup>
里定义事件时,还是要用 camelCase 写,这一点容易踩坑,后面问题排查章节我细说。
3.3 v-model 与 defineEmits 的关系
很多初学者分不清 v-model 和 defineEmits 到底是什么关系,其实 v-model 的本质就是基于 defineEmits 的语法糖。你手动写一行
emit('update:modelValue', newValue)
,加上父组件的一行
@update:modelValue="handleChange"
,效果跟用 v-model 一模一样。
所以要我说,学习顺序应该是:先把 defineEmits 搞明白,再去看 v-model,理解起来会轻松很多。v-model 绝对不是"另一种传参方式",而是 emit 的封装形式。项目里如果遇到"这个组件不支持 v-model"的情况,底层就是在暴露事件或者访问属性的时候没有按 v-model 的协议写。
4. 直接拿孩子的方法:defineExpose + ref 方案
4.1 什么场景适合用暴露方法
在项目里有一种需求,用事件和 v-model 都不太合适,那就是父组件需要"主动"调用子组件的方法。举一个我碰到的真实例子:订单列表页点"新增"按钮,会弹出一个弹窗,弹窗里面嵌着商品选择组件;用户选完商品点确定,外层要把选中的商品列表回填到表单里。这时候列表页(父组件)需要在弹窗打开的时候,主动让商品组件"返回当前选中的数据",而不是等商品组件自己去触发什么事件。
这种场景的最佳方案就是:父组件通过
ref
拿到子组件实例,子组件用
defineExpose
把方法或属性暴露出来。
4.2 代码实现与注意点
子组件 ProductSelector.vue:
<template>
<div class="product-selector">
<label v-for="product in productList" :key="product.id">
<input type="checkbox" :value="product.id" v-model="selectedIds" />
{{ product.name }}
</label>
</div>
</template>
<script setup>
import { ref } from 'vue'
const productList = ref([
{ id: 1, name: '商品A' },
{ id: 2, name: '商品B' },
{ id: 3, name: '商品C' }
])
const selectedIds = ref<number[]>([])
function getSelectedProducts() {
return productList.value.filter((item) => selectedIds.value.includes(item.id))
}
function resetSelection() {
selectedIds.value = []
}
// 关键:显式暴露给父组件
defineExpose({
getSelectedProducts,
resetSelection
})
</script>
父组件 Parent.vue:
<template>
<div>
<button @click="openDialog">打开选择器</button>
<ProductSelector ref="productRef" />
<button @click="confirmSelect">确定选择</button>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue'
import ProductSelector from './components/ProductSelector.vue'
const productRef = ref()
function openDialog() {
// 可以调用子组件暴露的方法,比如重置状态
productRef.value?.resetSelection()
}
function confirmSelect() {
const selected = productRef.value?.getSelectedProducts()
console.log('选中的商品:', selected)
}
</script>
这里有几个非常容易踩的坑:
第一,Vue3 里
<script setup>
组件默认是"关闭"的,也就是说父组件不能直接访问子组件内部的任何数据或方法,必须通过在子组件里显式调用
defineExpose
来暴露。我见过部分从 Vue2 转过来的同事,在 Vue3 里直接
childRef.value.someMethod()
报 undefined,搞了半天才反应过来是
defineExpose
没写。
第二,
productRef.value
的访问时机要注意。如果你在
onMounted
里去调用子组件方法,没问题,因为组件已经挂载了;但如果你在模板渲染初期、组件还没挂载完毕时去访问,可能拿到的就是 undefined。我们在项目里为了稳一点,经常配合
nextTick
使用:
<script setup>
import { ref, nextTick } from 'vue'
const childRef = ref()
async function callChildMethod() {
await nextTick()
childRef.value?.doSomething()
}
</script>
第三,
defineExpose
暴露的方法名如果和子组件内部方法名一样,父组件拿到的是同一个引用,this 指向不用操心,但注意暴露的方法如果在子组件内部依赖了组件状态,你必须保证它能在正确的上下文中被调用。好在
<script setup>
里顶层声明的函数天然就绑定到了组件实例上下文,基本不会出问题。
5. 完整实例:后台管理里最常见的"搜索条件回传"
5.1 页面整体结构
下面我拿一个典型的后台管理系统页面来讲,把前面说到的几种方案整合到一起,方便大家抄作业。
业务场景是:一个订单列表页,顶部是搜索条件区,下面是订单表格。搜索区作为一个独立的子组件,包含关键词输入、订单状态下拉、时间范围选择。父组件负责管理订单列表数据,子组件的状态变化需要同步给父组件,点击"重置"时需要父组件重新拉取默认列表。
页面结构大概是:
</template>
不好意思,上面这行是手滑了,下面的才是完整结构。
5.2 子组件写什么
OrderFilter.vue 完整代码:
<template>
<div class="order-filter">
<input
v-model="form.keyword"
placeholder="订单号/客户姓名"
@input="handleInput"
/>
<select v-model="form.status" @change="handleStatusChange">
<option value="">全部状态</option>
<option value="pending">待付款</option>
<option value="paid">已付款</option>
<option value="done">已完成</option>
</select>
<input type="date" v-model="form.startDate" @change="handleDateChange" />
<input type="date" v-model="form.endDate" @change="handleDateChange" />
<button @click="handleSearch">查询</button>
<button @click="handleReset">重置</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const emit = defineEmits<{
(e: 'search', form: { keyword: string; status: string; startDate: string; endDate: string }): void
(e: 'reset'): void
}>()
const form = ref({
keyword: '',
status: '',
startDate: '',
endDate: ''
})
// 输入关键字实时通知,也可以不做,等用户点查询再一起通知
function handleInput() {
// 这里是举个例子,实时上报频率太高时可以不用这么做
}
function handleSearch() {
emit('search', { ...form.value })
}
function handleStatusChange() {
// 也可以在下拉变化时就上报,看产品需求
}
function handleDateChange() {
// 同上
}
function handleReset() {
form.value = {
keyword: '',
status: '',
startDate: '',
endDate: ''
}
emit('reset')
}
defineExpose({
handleReset
})
</script>
在这个组件里,我用
defineEmits
声明了两个事件:
search
(携带查询条件)和
reset
(通知父组件重置)。同时用
defineExpose
暴露了
handleReset
,这是给父组件在某些场景下直接调用的,比如点击页面上方的"清除筛选"快捷按钮。
5.3 父组件写什么
OrderList.vue 完整代码:
<template>
<div class="order-list">
<OrderFilter
ref="filterRef"
@search="handleSearch"
@reset="handleReset"
/>
<button class="quick-clear" @click="clearFilter">快捷清除</button>
<el-table :data="tableData">
<el-table-column prop="orderNo" label="订单号" />
<el-table-column prop="customer" label="客户" />
<el-table-column prop="amount" label="金额" />
<el-table-column prop="status" label="状态" />
<!-- 省略其他列 -->
</el-table>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import OrderFilter from './components/OrderFilter.vue'
const filterRef = ref()
const tableData = ref([])
async function fetchList(params = {}) {
// 真实项目里这里一般是请求接口
console.log('拉取订单列表,参数是:', params)
// const res = await api.getOrderList(params)
// tableData.value = res.data.list
}
function handleSearch(form) {
fetchList(form)
}
function handleReset() {
fetchList({})
// 同时也把表格页码重置了
}
function clearFilter() {
// 直接调用子组件暴露的 reset 方法
filterRef.value?.handleReset()
}
onMounted(() => {
fetchList({})
})
</script>
5.4 跑通之后的整体感受
这个例子跑通之后,你会感受到几个明显的好处:
第一,搜索条件的来源和去向非常清楚。用户怎么选、选了哪些条件、最后到父组件的哪个函数里,一路看下来没有黑盒。
第二,数据同步的时机可控。子组件通过"查询"按钮统一上报,而不是每次输入都上报,父组件不会因为频繁收到事件而去反复请求接口。
第三,遇到"快捷清除"这种父组件要主动介入的场景,用 defineExpose 暴露的 reset 方法非常顺手,而且比"emit 一个 clear 事件给子组件、再由子组件决定要不要重置"这种反向流程要直观很多。
当然,这套代码如果项目里只有一个页面用,完全够用;如果订单筛选这种需求出现在十几个页面里,建议把筛选组件做成全局通用组件,那就要在 props 和事件定义上多花心思了。
6. 常见问题与排查技巧实录
6.1 事件触发了但父组件没接到
这个问题的第一排查路径,就是检查事件名是否一致。模板里你写的是
@search="handleSearch"
,子组件里 emit 的必须是
emit('search')
,一字不差。如果你在子组件里写
emit('Search')
,或者在模板里写
@Search="..."
,Vue 都不会帮你匹配上,因为事件名和 props 名一样,是区分大小写的。
第二,检查是否漏了
defineEmits
。在
<script setup>
里,如果你定义了
const emit = defineEmits(['search'])
但事件名写错了,控制台通常不会报错,只会静默失效,所以排查起来特别容易漏。我的习惯是先在模板事件的回调函数里打个断点或者 console.log,确认父组件有没有进到回调,再往子组件方向排查。
第三,确认事件是绑定在子组件根元素上还是子组件内部元素上。如果模板里写
<Child @search="handleSearch" />
,而
<Child>
内部没有在根元素上继承属性或没有触发对应事件,那自然接不到。Vue3 默认的
$attrs
透传特性,可以让父组件的事件自动绑定到子组件根元素上,但如果子组件里有多个根节点,透传行为就不一样了,这一点也容易被忽略。
6.2 数据传过去但页面没更新
有时候你会发现,父组件确实接收到了数据,console 里打印也没问题,但页面上死活不更新。这种问题九成是出在"响应式丢失"上。
举一个最典型的例子:
<script setup>
let searchWord = '' // 错误:没有用 ref 包裹
function handleSearch(keyword) {
searchWord = keyword // 页面不会更新
}
</script>
在
<script setup>
里,直接声明的普通变量不是响应式的,Vue 不会跟踪它的变化。必须用
ref
或
reactive
声明,并且在模板里访问的是
.value
对应的值。
另外一个常见场景是给 reactive 对象添加新属性:
<script setup>
import { reactive } from 'vue'
const form = reactive({})
form.name = 'xxx' // 这种方式加的属性是响应式的,因为 reactive 是 Proxy
</script>
但如果你用 ref 包了一个对象,又直接给对象的属性赋值,问题就来了:
<script setup>
import { ref } from 'vue'
const form = ref({})
form.value = { name: 'xxx' } // 正确,整体替换
form.value.name = 'xxx' // 也可以,ref 内部对象会被转成响应式
</script>
其实 ref 包对象时,Vue 内部还是会用 reactive 把对象包一层,所以直接改属性也没问题。真正出问题的是基本类型不用 ref、或者在错误的地方访问
.value
。排查这类问题,建议把 Vue Devtools 打开,直接看响应式树里的值有没有变化。
6.3 事件名连字符和驼峰踩坑
Vue3 官方文档明确说:模板里监听事件推荐用 kebab-case,而
defineEmits
声明用 camelCase。官方会做自动转换,但我个人建议保持统一,全用 camelCase,避免自己给自己埋坑。
说一下我踩过的坑。子组件里
emit('changeStatus')
,模板里
@change-status="handleChange"
,这种写法 Vue3 能匹配上。但如果子组件里写成
emit('change-status')
,模板里写
@change-status="..."
,也能跑通,只是
defineEmits
的类型推导和编辑器提示会弱一些。再极端一点,子组件里
emit('changeStatus')
,模板里写
@changeStatus="..."
,也可能能用,但在编辑器里没有提示,团队合作时容易误解。
所以我的规则很简单:在
<script setup>
里统一用 camelCase 声明和触发事件,在模板里统一用 kebab-case 去监听。这样写出来的代码,不管是查文档还是 IDE 提示,都比较稳。
6.4 最终方案怎么选
如果已经在项目里写了一段时间,我建议按下面的顺序判断:
- 子传父是"用户主动做某个动作产生的数据上报",首选 emit。比如按钮点击、表单提交、选项切换。
- 子传父是"编辑一个值,父组件要同步这个值",优先 v-model。比如输入框、下拉框、日期选择、开关这类"表单型"组件。
- 父组件需要"主动找子组件要些东西",或者要触发子组件内部状态重置,用 ref + defineExpose。
- 如果组件层级特别深,中间隔着很多层组件,再考虑 provide/inject 配合响应式数据,或者方案更彻底的 centralized store(状态管理库)。但注意,provide/inject 不适合做"子传父"的直接替代品,它解决的跨层传值问题,不是方向问题。
再补充一句,有时候你写出一个子组件,发现又要 emit 又要 v-model 又要 ref 暴露,先别急,这说明这个子组件本身就承担了比较重的状态管理职责。与其在一个组件里堆满通信方式,不如拆分成更小的原子组件,让每个子组件只专注一件事。我在前两年写代码就容易把所有功能都塞进一个大组件里,结果一个组件五种回调事件,改起来真的很痛苦。后来慢慢学会拆分,每个子组件的通信方式变得很单一,代码的可读性和可维护性都上了一个台阶。
最后再分享一点实际体会:Vue3 的组件通信,本质上就是"父组件信任子组件,子组件通过事件回传数据"这套约定。把它理解透了,再往后学 provide/inject、Pinia 这些方案,就会觉得触类旁通。如果你在做后台管理系统,日常业务逻辑一多,建议把常用的子传父模式封装成几个模板,比如表单筛选组件、弹窗表单组件、表格行操作组件,直接复用。一是省事,二是有统一约定,项目里的新人上手也快。
更多推荐



所有评论(0)