☰
Vue3组件通信指南:props、emit、defineModel、provide/inject一次讲清
2026/10/10 20:12:46 网站建设 项目流程

我入行这些年,被问得最多的问题里绝对有这一条:“Vue3里父子组件到底怎么传数据?”尤其是从Vue2切过来、或者刚接触组合式API的开发者,遇到defineProps、defineEmits、defineExpose、defineModel这一堆“define”开头的新东西,确实容易懵。

其实父子通信在Vue3里并不是变复杂了,而是官方终于把“数据从哪来、到哪去”这件事定义得更清楚了。以前Vue2里this.$emit满天飞,组件之间一不小心就变成你传我我传你,代码一多根本不知道数据源头在哪。Vue3把通信方式收敛成了几条明确的路:props往下传、emit往上抛、v-model做双向绑定、defineExpose让父组件直接调子组件方法、provide/inject跨层级共享,还有defineModel这种新语法糖。每条路都有自己的适用场景和边界。

这篇文章我按自己的实际使用频率,从最基础的讲起,把各种方式的使用姿势、适用场景、以及我踩过的坑都整理出来。不搞花活,全是实操,适合刚学Vue3的人系统过一遍,也适合写了一阵子但没仔细梳理过通信方式的同学查漏补缺。

1. props + emit:最正统也最常用的父传子与子传父组合

不管Vue怎么升级,props和emit永远是父子通信的基石。它们解决一个最朴素的问题:父组件把自己的数据交给子组件,子组件遇到事情时把消息告诉父组件。你可以理解为“家长给孩子零花钱,孩子花完了向家长汇报”。

1.1 所有的数据传递都要从props开始

在Vue3的组合式API里,子组件接收父组件数据的方式是defineProps,它在<script setup>里直接调用,不需要额外import:

<!-- Child.vue --> <script setup> const props = defineProps({ title: { type: String, required: true }, count: { type: Number, default: 0 }, userInfo: { type: Object, default: () => ({}) } }) </script> <template> <div> <h3>{{ title }}</h3> <p>当前数量:{{ count }}</p> <p>用户:{{ userInfo.name }}</p> </div> </template>

这里有几个细节值得注意。首先是对象类型的default必须写成函数返回形式,default: () => ({}),这是为了避免多个组件实例共享同一个引用对象。其次是defineProps接收一个对象,对象里的type不仅做类型校验,还承担着运行时的类型检查职责,虽然我们在工程里通常配合TypeScript使用,但运行时校验在纯JavaScript项目里依然能救你一命——尤其是你引用第三方组件、传错类型时,控制台会直接给出警告。

父组件那边用起来很简单:

<!-- Parent.vue --> <script setup> import { ref } from 'vue' import Child from './Child.vue' const title = ref('订单详情') const count = ref(1) const user = ref({ name: '张三' }) </script> <template> <Child :title="title" :count="count" :user-info="user" /> </template>

注意:user-info这个写法。在模板里,驼峰命名的props要转成短横线分隔,因为HTML标签属性是不区分大小写的。如果你用userInfo,某些场景下可能匹配不到。虽然Vue内部做了兼容处理,但我在团队规范里一直要求模板里统一用短横线命名。

1.2 为什么Vue要强制单向数据流

很多新手刚接触props时,都会产生一个疑问:“我在子组件里改props会怎样?”答案是:页面能改,但控制台会报警告,而且父组件的状态不会真正改变,容易造成界面和数据不一致。

Vue强制单向数据流的根本原因是:如果子组件能修改父级的数据,那多个子组件同时依赖同一份数据时,数据流就失控了,你根本不知道是谁改了数据,排查问题会变成灾难。这也符合我们日常的开发直觉:一个函数的参数是只读的,如果你想改变外界状态,应该通过返回值或者回调函数来完成,而不是直接改参数。

所以子组件里收到props后,如果要用它做局部状态,正确做法是复制一份:

<script setup> import { ref, watch } from 'vue' const props = defineProps({ visible: Boolean }) // 复制props到本地状态 const localVisible = ref(props.visible) // 或者用计算属性做派生状态 </script>

如果你连复制都嫌麻烦,只想在模板里转一下格式,直接用computed包一层也行,文档里管这个叫“派生状态”。我在封装表格组件时经常这么做,比如母组件传进来原始时间戳,子组件内部计算成型后的展示格式,本地状态完全由子组件自己控制。

1.3 emit的本质是子组件抛事件,父组件接事件

子组件向父组件通信,靠的是defineEmits。这也是Vue3相对Vue2变化较大的地方——以前你可以直接this.$emit('change'),现在必须先声明这个事件:

<!-- Child.vue --> <script setup> const emit = defineEmits(['change', 'submit']) function handleClick() { emit('change', '来自子组件的数据', 123) } function handleSubmit() { emit('submit', { name: '张三', age: 18 }) } </script> <template> <button @click="handleClick">触发change事件</button> <button @click="handleSubmit">触发submit事件</button> </template>

父组件里用@事件名监听:

<!-- Parent.vue --> <script setup> import Child from './Child.vue' function onChange(data, num) { console.log('子组件传来的数据:', data, num) } function onSubmit(data) { console.log('submit数据:', data) } </script> <template> <Child @change="onChange" @submit="onSubmit" /> </template>

这里有两点容易踩坑。第一,defineEmits支持对象写法,可以加校验:

const emit = defineEmits({ change: (data) => { return typeof data === 'string' }, submit: null })

返回false时Vue会在控制台警告,表示事件校验未通过。我实际使用中觉得校验逻辑价值有限,因为事件本身不常作为数据入口,但做公共组件库时这招很管用,能约束使用方不要乱传数据。

第二是事件命名。Vue3不再强制emit的事件名用小写或短横线,但官方推荐用短横线格式,比如emit('update:model-value')。如果你在子组件里写emit('myEvent'),父组件里就得写@my-event才会被匹配到。为了避免这种混乱,团队规范里统一用短横线最稳妥。

1.4 props与emit配合的经典场景

单独说props和emit容易被理解成两个独立的功能,其实它们通常是配合出现的。最简单的计数器组件就是一发一收的完整闭环:

<!-- NumberStepper.vue --> <script setup> const props = defineProps({ modelValue: { type: Number, default: 0 } }) const emit = defineEmits(['update:modelValue']) function increase() { emit('update:modelValue', props.modelValue + 1) } function decrease() { emit('update:modelValue', props.modelValue - 1) } </script> <template> <button @click="decrease">-</button> <span>{{ modelValue }}</span> <button @click="increase">+</button> </template>

父组件里配合v-model使用:

<NumberStepper v-model="count" />

这就是Vue3里v-model能直接用在自定义组件上的原理。本质上v-model是下面写法的语法糖:

<NumberStepper :model-value="count" @update:model-value="count = $event" />

所以你会发现,前面的increase和decrease里的事件名不是乱写的,update:modelValue正是v-model绑定的内部契约。理解了这一层,后面再讲defineModel就轻松多了。

2. 子组件要改父组件的数据也能很优雅:v-model通信的进阶用法

可能你已经看出来了,v-model这一节和第一节里的NumberStepper有重叠。但其实v-model在Vue3里做了不少升级,值得单独说一说。

2.1 Vue3中v-model的核心变化

Vue2里的v-model只能绑定一个值,自定义组件时如果你还想再绑第二个值,只能用.sync修饰符,写起来比较别扭。Vue3里v-model变成了可以多次使用的指令,一个组件可以拥有多个双向绑定的值:

<!-- 父组件 --> <Child v-model:title="title" v-model:content="content" />

子组件对应的接收方式:

<script setup> const props = defineProps({ title: String, content: String }) const emit = defineEmits([ 'update:title', 'update:content' ]) </script>

这种多v-model的定义方式非常适合封装表单类组件,比如一个既需要“标题”又需要“内容”的弹窗组件,两个字段分开绑定,各管各的,互不干扰。

2.2 在自定义组件里正确实现v-model

如果你想让自己的组件能像原生输入框一样使用v-model,标准写法是接收modelValue,然后在合适的时机抛出update:modelValue事件。前面NumberStepper就是一个例子。

这里我想额外说一个v-model的实现细节:事件抛出时,不要直接拿props的值改完再传出去,尽量在组件内部用中间副本处理。比如一个拥有防抖逻辑的搜索框:

<!-- DebouncedInput.vue --> <script setup> import { ref, watch } from 'vue' const props = defineProps({ modelValue: String }) const emit = defineEmits(['update:modelValue']) const innerValue = ref(props.modelValue) watch(innerValue, (val) => { // 模拟防抖 setTimeout(() => { emit('update:modelValue', val) }, 300) }) watch(() => props.modelValue, (val) => { innerValue.value = val }) </script> <template> <input v-model="innerValue" /> </template>

注意这里两个watch的方向:一个是从内部往外传,一个是从外部往内部同步。写这种双向组件最怕的就是只处理了一个方向,结果父子状态不同步。这也是我常和团队强调的:双向绑定的本质是两个方向的单向数据流,不理解这一点,组件封装得越多越容易出问题。

2.3 v-model的修饰符、参数与适用边界

v-model最容易被忽略的是修饰符。原生input上的.trim、.number在自定义组件里也能使用,但需要在子组件里通过defineModel或者手动解析modelModifiers来处理。在组合式API中,如果你写的是传统写法,可以用defineProps里的属性名加上ModelModifiers后缀来拿到修饰符对象。

这个知识点比较细,适合有需要时再翻文档,日常开发很少用到。我更想提醒大家的是v-model的边界——它适合表达“一个值在父子之间同步”的场景,不适合表达“一系列事件触发”的场景。比如某个子组件里同时有“选中变化”“滚动到底部”“删除某项”等多个不同的交互,你用v-model硬绑一个对象,反而会让数据流变得模糊。这时候老老实实用props加不同的emit事件,可读性更高。

3. 父组件想直接调用子组件方法:defineExpose + ref的真实使用场景

props和emit解决的是数据传递,但有些场景下,父组件想要直接操作子组件内部的某个方法。典型的例子:父组件点击“保存”按钮,需要让子组件里的表单执行一次校验;或者父组件要通知子组件“弹窗即将关闭,先保存草稿”。这种命令式的场景,派上用场的就是defineExpose加模板引用。

3.1 为什么Vue3里用了defineExpose

Vue2里,你只要给子组件加一个ref,父组件就能通过this.$refs.child拿到子组件实例,调用它的任意方法、访问任意数据。这种方式很方便,但也非常危险——子组件的内部实现完全暴露给父组件,想藏都藏不住,长期下去组件之间的耦合度只会越来越高。

Vue3在组合式API里做了一个收紧:<script setup>中的一切默认都是私有的,想暴露什么必须自己声明。这个设计我一开始觉得烦,用久了反而觉得安心。它强迫你去思考:“子组件有哪些能力是允许外部调用的?”透明化反而更可控。

3.2 完整示例:父组件驱动子组件执行校验

下面是一个搜索表单组件,父组件点击按钮时,直接取子组件实例,调用它内部的“校验并提交”方法:

<!-- SearchForm.vue --> <script setup> import { ref } from 'vue' const keyword = ref('') const errorMessage = ref('') function validate() { if (!keyword.value.trim()) { errorMessage.value = '搜索关键词不能为空' return false } errorMessage.value = '' return true } function submit() { const isValid = validate() if (!isValid) return // 在这里统一提交 console.log('开始搜索:', keyword.value) } defineExpose({ validate, submit }) </script> <template> <div> <input v-model="keyword" placeholder="请输入关键词" /> <p v-if="errorMessage" class="error">{{ errorMessage }}</p> </div> </template>

父组件:

<!-- Parent.vue --> <script setup> import { ref } from 'vue' import SearchForm from './SearchForm.vue' const searchFormRef = ref(null) function handleSearch() { // 直接调用子组件暴露出来的方法 searchFormRef.value.validate() searchFormRef.value.submit() } </script> <template> <SearchForm ref="searchFormRef" /> <button @click="handleSearch">搜索</button> </template>

这里有个容易误解的细节:ref拿到的是组件的公开实例,而不是整个组件实例。所谓“公开实例”就是defineExpose暴露的内容,外加Vue内置的处理。如果你不写defineExpose,父组件只能拿到组件的DOM元素引用(实际上是组件的根元素proxy),拿不到任何内部方法。

3.3 什么时候应该用defineExpose,什么时候不应该

defineExpose不是银弹,用多了组件只能退化成“披着组件外衣的类”。我自己的使用标准是:

  • 需要被外部触发的动作才暴露,比如表单的validate、reset、弹窗的open、close,这类方法天然是命令式的。
  • 内部状态一律不暴露。父组件想读子组件的内部数据时,正常的做法应该是通过emit把数据传出来,而不是直接ref.value.xxx去访问。虽然技术上可以暴露一个ref,但一旦你这么做了,子组件内部任何改动都可能波及父组件逻辑。

还有一个很隐蔽的坑:defineExpose暴露的ref或者reactive数据,在父组件里直接被读取时是自动解包的,也就是说searchFormRef.value.keyword能拿到字符串;但如果你暴露的是方法,调用时要加括号,像searchFormRef.value.submit(),这个不难,容易错的是忘记判空——尤其在子组件还没挂载完成时,searchFormRef.value可能是null。所以我写这种逻辑时习惯先加个判空:

function handleSearch() { const form = searchFormRef.value if (!form) return form.validate() form.submit() }

4. 不想层层传参:provide/inject这样用才能避开响应式陷阱

props和emit解决父子之间的直接通信,但如果组件层级很深,比如祖父组件要传数据给曾孙组件,中间隔了两三层组件,你还用props一层层往下传,代码会变得非常啰嗦,中间那些组件本来不关心数据,还要被迫接收再转发。Vue3的provide和inject就是为这种场景设计的。

4.1 基本用法和命名注意

provide在祖父组件里提供数据,inject在任何层级的后代组件里接收数据:

<!-- 祖父组件 --> <script setup> import { provide, ref } from 'vue' const theme = ref('dark') const userInfo = ref({ name: '张三', role: 'admin' }) provide('theme', theme) provide('userInfo', userInfo) </script>
<!-- 曾孙组件 --> <script setup> import { inject } from 'vue' const theme = inject('theme') const userInfo = inject('userInfo') </script> <template> <div>当前主题:{{ theme }},用户:{{ userInfo.name }}</div> </template>

关于key的命名,我的建议是:在项目里定义一份常量文件,统一管理provide的key,避免字符串key随意起、容易冲突。尤其在大型项目里,你给a模块提供了'config',b模块也提供了'config',后注入的组件很可能拿到错误数据还不自知。

4.2 非响应式陷阱:这是provide/inject最大的坑

第一次用provide/inject时,我犯过一个错:直接传了一个普通变量,后代组件里改了这个变量,界面纹丝不动。排查很久才发现问题——provide/inject本身不保证响应式,想要响应式必须显式传ref或reactive对象。

看下面这个反例:

<script setup> import { provide } from 'vue' let count = 0 provide('count', count) function increase() { count++ } </script>

后代组件里虽然通过inject('count')拿到了值,但count只是一个普通数字,Vue无法感知变化,页面不更新。改法很简单,用ref包一层:

<script setup> import { provide, ref } from 'vue' const count = ref(0) provide('count', count) function increase() { count.value++ } </script>

为什么ref就能响应式?因为ref返回的是一个对象,Vue在访问.value时触发了依赖收集和响应更新。inject拿到的也是这个对象本身,所以对象内部的更新依然能被追踪。

4.3 inject的默认值和类型限定

inject支持第二个参数作为默认值,当祖先组件没提供key时,后代组件拿到默认值而不是undefined:

const theme = inject('theme', 'light')

也可以传工厂函数,避免创建没必要的数据:

const config = inject('appConfig', () => ({ theme: 'light' }))

如果项目用了TypeScript,inject的类型推断默认是比较弱的,你可以通过泛型声明接收类型:

const theme = inject<string>('theme', 'light')

需要注意的是,inject如果没找到key且没给默认值,会在控制台提示警告。这在生产环境其实不算致命,但容易误导旁边的人以为数据丢了。所以我每次写inject都会习惯性带上默认值,哪怕默认值是null,至少能明确表示“这里可能没有数据”。

4.4 provide/inject与props的取舍

很多新手学完provide/inject后,开始全线用它,连父子两层都直接用provide。这么做从结果上能跑通,但从可维护性角度看很差。provide/inject的问题在于“数据来源不直观”——一个组件里拿到一个数据,你不知道它是哪个祖先提供的,中间断了哪层也不知道。

我的经验是:只在组件层级明显超过两层、且中间层完全不关心这份数据时,才考虑provide/inject。父子直接通信,永远是props+emit最清晰;跨三层以上且是全局性质的配置(用户信息、主题、权限标记),才轮到provide/inject上场。官方推荐用它来替代Vue2里的$parent和事件总线,就是这个道理。

5. 组合式API时代的新宠:defineModel把双向绑定简化到极致

前面讲v-model时,我说了手动实现一个自定义组件的双向绑定需要同时写props和emit,两个东西必须结构对得上才能跑通。Vue 3.4之后推出了defineModel,这个宏把两个声明合并成了一个,代码量大幅减少,也少了“事件名写错半天查不出来”的尴尬。

5.1 defineModel的基本用法

我们先看最典型的输入框组件:

<!-- MyInput.vue --> <script setup> const model = defineModel({ type: String, default: '' }) </script> <template> <input v-model="model" /> </template>

父组件使用:

<MyInput v-model="searchText" />

你没看错,子组件里不再需要手动写defineProps和defineEmits,defineModel自动完成了以下几件事:

  • 声明了一个名为modelValue的prop
  • 声明了一个名为update:modelValue的事件
  • 同时定义了一个本地ref对象model,读写自动同步到父组件

所以你在模板里写v-model="model",这个model是从父组件来的prop的代理,你改它时,它自动帮你emit出去,父组件的状态跟着变。

5.2 多个defineModel与带参数的写法

一个组件里可以定义多个defineModel,对应父组件多v-model的场景:

<!-- 子组件 --> <script setup> const title = defineModel('title') const content = defineModel('content') </script>
<!-- 父组件 --> <Child v-model:title="title" v-model:content="content" />

这里第一个参数是v-model的参数名,也就是v-model:title里冒号后面的部分。如果省略参数,默认走v-model和modelValue。

5.3 defineModel实现v-model修饰符的处理

原生input上经常用到的.trim修饰符,defineModel也直接支持。想拿到修饰符,可以给defineModel传第二个参数:

<script setup> const model = defineModel('searchText', { type: String, // 通过get/set拦截值的读取和写入 get: (value) => value, set: (value) => value.trim() }) </script>

当你给组件传v-model.trim时,set会被修饰符影响,Vue内部会先调用修饰符处理函数,再走到你的set。如果你需要自定义其他修饰符,Vue 3.4还提供了一种更底层的方式:从defineModel返回的对象的.modifiers字段获取修饰符对象。这部分用得少,我就不展开代码了,了解有这回事就行。

5.4 defineModel的实现原理与注意事项

了解原理有助于避坑。defineModel本质上还是props加emit的语法糖,展开来看约等于:

const props = defineProps(['modelValue']) const emit = defineEmits(['update:modelValue']) const model = computed({ get: () => props.modelValue, set: (val) => emit('update:modelValue', val) })

正因为它是计算属性,你不能对model直接赋值一个新值,比如model = 'abc'会报错,必须是model.value = 'abc'。这一点和普通ref变量不同,新手容易在这里栽跟头。

另外,defineModel要求父组件传了v-model才有效。如果你在子组件里定义了defineModel,但父组件没绑定v-model,那么model就是一个孤立的状态,改动它不会同步给任何人。这种情况不算报错,但会让人困惑。所以用defineModel之前先确认父组件一定有用到v-model。

5.5 实际项目中我把defineModel用在哪些地方

实践中,defineModel最省心的场景是:

  • 封装表单输入组件(输入框、下拉框、日期选择器)
  • 封装开关类组件(开关状态双向绑定)
  • 封装弹窗组件的visible属性

这些组件的共同特点是:组件对外只有一个核心状态,而这个状态需要父子双向同步。业务逻辑非常清晰,用defineModel能做到“整组件的对外接口只有一行”,维护起来很舒服。

6. 不走寻常路的两种方案:事件总线与getCurrentInstance

前面介绍的通信方案都有明确的官方支持。但实际开发中,你还会遇到一些不那么常规的场景:两个组件没有直接的父子关系、甚至隔着很多层级,只靠props一层层传实在太累,或者你只是想在某个页面里快速获取上下文实例。这些情况下,有人会用mitt做事件总线,也有人会直接撸getCurrentInstance()。

6.1 mitt事件总线:跨组件通信的轻量方案

Vue3官方从核心库中移除了$on、$off,所以以前Vue2里常见的new Vue()当事件总线的玩法在Vue3里行不通了。社区常用的替代方案是mitt,一个只有200多字节的迷你事件库。

基本用法:

npm install mitt

在任意一个模块里创建bus实例:

// utils/bus.js import mitt from 'mitt' const bus = mitt() export default bus

组件A发送事件:

<script setup> import bus from '@/utils/bus' function sendData() { bus.emit('refreshList', { id: 1 }) } </script>

组件B监听事件:

<script setup> import { onMounted, onUnmounted } from 'vue' import bus from '@/utils/bus' function handleRefresh(data) { console.log('收到刷新通知:', data) } onMounted(() => { bus.on('refreshList', handleRefresh) }) onUnmounted(() => { bus.off('refreshList', handleRefresh) }) </script>

这里有个必须养成的习惯:监听事件后,在onUnmounted里一定要记得off掉。不清理的话,组件销毁后事件回调还在,容易造成内存泄漏和数据错乱——比如你反复进入同一个页面,回调被注册了好几次,每次事件触发都会执行多遍,产生重复请求。

事件总线这类方案,我的定位是“解决跨层级的非核心数据同步,且频率低”。比如列表页详情页之间的“刷新数据”通知,用事件总线非常方便。但如果是高频状态(用户信息、主题配置),还是优先交给provide/inject或者Pinia这类状态管理库,因为事件总线既不显式也不可控,出问题时排查效率很低。

6.2 getCurrentInstance:不建议直接用,但要懂它

有时你会看到有人这样获取组件通信能力:

<script setup> import { getCurrentInstance } from 'vue' const instance = getCurrentInstance() console.log(instance.proxy) </script>

getCurrentInstance返回的是组件实例的内部信息,包括proxy、ctx、attrs等等。看起来功能很强大,但官方文档明确说:它主要面向库作者,应用层代码不建议直接用。原因很直接:实例内部结构不是公开API,Vue升级时可能变动,你在业务代码里依赖它等于给自己埋雷。

我见过有人用instance.proxy.$parent去拿父组件数据,也见过用instance.proxy.$children遍历子组件。Vue3里$children和$parent都被移除了,这样写基本行不通。正确的做法还是回到本章前面那些更显式的通信方式。

如果非要用getCurrentInstance,请局限在“调试打印”场景,比如临时看看组件上挂了哪些属性,不要进入生产代码。

7. 面试必问与实战选型:不同场景该选哪种通信方式

文章标题既然提到了“总结”,那最后这块必然要落到一个实际问题:项目里遇到某个具体需求时,我该选哪种方案?

7.1 场景对照表

我把日常开发中常见的场景和推荐方案整理成一张表,方便你快速定位:

场景推荐方案说明
父组件传展示数据给子组件props单向数据流最清晰
子组件通知父组件执行操作emit事件模型天然适合
父子双向同步一个值v-model / defineModel语法糖,代码最少
父组件命令子组件执行动作ref + defineExpose命令式调用,不破坏单向数据流
跨多层组件传递数据provide / inject避免中间层转发
页面间/非直接父子组件通信事件总线(mitt)或状态管理库低频用mitt,高频状态用Pinia
全局共享的状态(登录信息、主题、权限)Pinia等状态管理库不建议用组件通信方案硬扛

这张表是我在实践中不断总结出来的。可能有人会问:Pinia不是状态管理吗,怎么也算通信方案?从结果上看,Pinia管理全局状态后,各个组件读取同一份Store数据,本质上也实现了组件间通信。而且它比事件总线更可靠——状态是响应式的,组件挂载和销毁不用手动清理监听。

7.2 从Vue2迁移到Vue3最容易踩的通信坑

如果你是从Vue2项目切过来的,以下几点你有极高的概率踩中:

  • $children和$parent没了。以前this.$parent随便用,Vue3里必须用provide/inject或Pinia替代,否则只能依赖DOM结构硬找,非常脆弱。
  • 事件总线不能new Vue了。我见过有人把Vue2的Vue.prototype.$bus = new Vue()直接搬到Vue3,组件里this.$bus.$emit直接报错。正确做法是引入mitt,或者干脆换成Pinia。
  • v-model行为变了。Vue2里你可以对一个组件用多次v-model吗?不能,默认只有一个value和input事件。Vue3里v-model:xxx让多绑定成为常态,所以需要把老业务里的.sync改成v-model:propName。如果组件内部还用value做prop,在Vue3里必须改成modelValue。
  • functional组件写法彻底变了。如果你以前封装过很多函数式组件,Vue3里需要重写为普通组件,通信方式也随之变化,props和emit在函数式组件里的处理方式不再兼容。

7.3 我自己的选型心路与建议

说句实话,前几年接手过一个老项目,里面充斥着各种$parent、$children、$root的调用,数据流完全是一锅粥。我修一个弹窗关闭后刷新列表的bug,翻了三层组件才发现数据是通过$root传到最顶层再下来的。后来我把整个模块重构,统一改成props+emit为主、事件总线为辅的通信结构,代码读起来清爽太多。

所以我的核心建议其实很简单:

  • 父子直连,首选props+emit,它是Vue的“主干道”,任何时候都不会被淘汰。
  • 双向绑定的表单类组件,直接用defineModel,代码最少、最不容易错。
  • 跨多层但不跨越页面级全局状态,用provide/inject。
  • 全局状态,直接上Pinia,别拿组件通信方案硬扛。
  • 事件总线留作低频通知的补充手段,但一定要做好监听清理。

最后提醒一个易错细节:无论用哪种通信方式,都要养成“数据明来明去”的习惯。每一条数据最好只有一个明确来源,不要同一个值这条线用props、那条线用事件总线、还有一条线用全局Store,最后数据一多,谁改的都不知道,排查起来真的能把人逼疯。

这次做Vue3组件通信梳理,本身也算是一份给自己的备忘。技术迭代很快,但通信的本质没变:谁的数据谁负责,谁要数据谁声明。理解了这个原则,不管以后Vue4、Vue5怎么变,你都能很快找到对应的API。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询