写 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 这些方案,就会觉得触类旁通。如果你在做后台管理系统,日常业务逻辑一多,建议把常用的子传父模式封装成几个模板,比如表单筛选组件、弹窗表单组件、表格行操作组件,直接复用。一是省事,二是有统一约定,项目里的新人上手也快。

更多推荐