
做前端这几年组件传参是我认为 Vue 里最核心也最绕不开的一块内容。不管是 Vue2 还是 Vue3只要你写过组件化页面就一定会遇到父子组件数据共享、跨层组件通信、插槽内容分发这些场景。尤其是从 Vue2 的项目往 Vue3 迁移的时候很多老写法直接不生效了比如this.$emit倒是还能用但v-model的默认绑定从value变成了modelValue$listeners被并进了$attrsEventBus 也不能像以前那样new Vue()就开干了。这篇文章我想把自己在 Vue2 和 Vue3 两套环境下做组件传参的经验完整整理一遍。先说清楚每一类通信方式的底层逻辑再给你对照代码、踩坑记录和可以照抄的写法。不管你是在维护 Vue2 老项目、做 Vue3 新项目还是在准备面试这篇内容应该都能让你对“Vue 里数据到底怎么在组件间流动”这件事有一个系统性的认知。1. 先捋清楚Vue 组件传参到底有哪几类通道1.1 四类传参通道父子、子父、跨层、插槽/ref很多人在一开始学 Vue 的时候最困惑的就是“到底该用哪种方式传参”。其实你只要把场景分成几类选择就变得很自然。我这里习惯把组件通信分成四个大类通信场景Vue2 常用手段Vue3 常用手段适用情况父传子propsprops / defineProps最基础、最显式适合所有单向数据流场景子传父$emitemit / defineEmits子组件通知父组件适合事件回调跨层级传provide/inject、EventBusprovide/inject、Pinia祖先给后代传数据适合主题、配置、全局状态结构分发与实例访问插槽 slot、$refs插槽 slot、ref defineExpose需要自定义渲染结构、直接调用子组件方法这个表格我建议你当成一个索引来用。平时写代码的时候先问自己一个问题这两个组件之间是父子关系还是隔着好几层的祖先后代关系如果是父子优先用 props 加 emit如果隔了多层优先用 provide/inject实在不行再上全局状态管理如果你需要让父组件决定子组件内部某一段怎么渲染用插槽如果需要直接操作子组件实例上的方法用 ref。1.2 Vue3 动了哪些老规矩思路是什么很多从 Vue2 转 Vue3 的人第一反应是“怎么这也不行那也不行了”。其实 Vue3 对通信方式的大改背后有几个非常明确的设计思路。第一是数据流更显式。Vue2 中$listeners、$attrs、EventBus 这些东西用起来确实方便但时间久了会出现“事件满天飞、数据来源不清晰”的情况。Vue3 把事件监听器并入了$attrs把跨层通信的方式收敛到 provide/inject 和状态库目的就是让数据从哪来、到哪去变得可追踪。第二是响应式系统重写之后带来的能力变化。Vue3 的ref、reactive可以自由地在组件之间传递provide 一个响应式引用后代组件拿到的就是响应式的这在 Vue2 里是不太容易做到的。Vue2 的 provide/inject 本身不具备响应性很多人踩过这个坑。第三是类型友好。Vue3 的defineProps、defineEmits可以配合 TypeScript 泛型做非常完整的类型推导props 能自动推导出类型emit 的事件名也能约束。这些在 Vue2 里要实现得非常费劲Vue3 在这块是天生优势。你理解了这三条后面看具体差异就不容易迷路。2. 父子组件传参完整拆解props、emit 与 v-model 的前世今生2.1 props 的定义、命名与默认值陷阱props 是父传子的核心通道。Vue2 和 Vue3 在 props 本身的用法上其实差别不大核心还是那三条声明、传递、接收。声明方式上Vue2 是选项式// Vue2 子组件 export default { name: ChildComponent, props: { title: { type: String, required: true }, list: { type: Array, default: () [] }, config: { type: Object, default: () ({}) } } }Vue3 的选项式写法基本一样但如果你用的是 setup 语法糖那就变成这样!-- Vue3 子组件 -- script setup defineProps({ title: { type: String, required: true }, list: { type: Array, default: () [] } }) /script如果你用 TypeScript还可以用纯类型声明script setup langts interface Props { title: string list?: string[] config?: Recordstring, unknown } const props withDefaults(definePropsProps(), { list: () [], config: () ({}) }) /script这里有一个新手特别容易踩的坑数组和对象类型的 props默认值必须用工厂函数返回新实例。你要是写成default: []在 Vue3 里会直接报一个警告在 Vue2 里虽然不报错但多个组件实例会共享同一个数组引用。你在组件 A 里 push 了一条数据组件 B 的数据也会跟着变这种 bug 查起来非常隐蔽。props 命名上也要注意。推荐在模板里用短横线在 JavaScript 和 props 声明里用驼峰!-- 父组件模板 -- ChildComponent :my-messagemsg /// 子组件声明 props: { myMessage: String // 模板中的 my-message 会自动映射过来 }因为 HTML 标签上的属性名是不区分大小写的你如果在模板里写:myMessage实际上会遇到各种边界问题统一用短横线最保险。还有一个小细节Vue3 中通过withDefaults处理类型声明时的默认值依然要走工厂函数那套逻辑函数类型的默认值也同理不要图省事直接赋一个固定对象。2.2 子传父$emit 与 emits 声明子组件要通知父组件靠的是事件。Vue2 里就是裸的this.$emit// Vue2 子组件 export default { methods: { handleClick() { this.$emit(select, this.item) } } }父组件用select监听template ChildComponent selecthandleSelect / /template script export default { methods: { handleSelect(item) { console.log(item) } } } /scriptVue3 选项式写法基本一致但如果你用 setup 语法糖就需要用defineEmitsscript setup const emit defineEmits([select, update:title]) function handleClick() { emit(select, props.item) } /scriptVue3 里有一个非常重要的新要求所有子组件向外派发的事件都应该在emits选项里显式声明。这不仅仅是规范问题而是不声明的话事件会被当作原生 DOM 事件透传到根节点上可能导致同一次交互触发两次回调。我举一个实际场景。你在子组件内部给根 div 绑了一个click然后子组件又向外 emit 了一个也叫click的事件父组件用click接收。如果没在emits里声明Vue3 会把这个监听器放在$attrs上然后自动继承到根元素等于根 div 上既绑了内部方法又绑了父级监听器点一下就执行了两遍。script setup defineEmits([click]) // 一定要声明不然会透传到根元素 /script template div clickinnerHandle.../div /template另外Vue3 中defineEmits还可以带上校验函数script setup const emit defineEmits({ submit(payload) { return payload payload.id } }) /script校验返回false时Vue 会在控制台打出警告提示事件参数不合法。这个对开发大型项目很有帮助能在联调阶段提前发现传参格式对不上的问题。2.3 v-model从 value/input 到 modelValue/update:modelValuev-model的本质就是语法糖这一点在 Vue2 和 Vue3 里都一样但糖的“口味”变了这是迁移时最容易出问题的地方。在 Vue2 里v-model默认绑定的是value属性和input事件!-- Vue2 父组件 -- ChildComponent v-modelkeyword /等价于ChildComponent :valuekeyword inputval keyword val /所以子组件要这样写// Vue2 子组件 export default { props: [value], methods: { updateValue(newVal) { this.$emit(input, newVal) } } }到了 Vue3默认绑定名换成了modelValue和update:modelValue!-- Vue3 父组件 -- ChildComponent v-modelkeyword /等价于ChildComponent :modelValuekeyword update:modelValueval keyword val /子组件这样写script setup const props defineProps([modelValue]) const emit defineEmits([update:modelValue]) function updateValue(newVal) { emit(update:modelValue, newVal) } /scriptVue3 更爽的一点是支持多个v-model可以一个组件同时绑多个值ChildComponent v-model:titletitle v-model:contentcontent /子组件对应script setup const props defineProps([title, content]) const emit defineEmits([update:title, update:content]) /script这一个特性在 Vue2 里需要用.sync修饰符实现ChildComponent :title.synctitle :content.synccontent /所以你在迁移的时候基本上可以做一个机械替换Vue2 的:xxx.syncxxx直接改成 Vue3 的v-model:xxxxxx语义是高度一致的。顺便提一句Vue2.7 也开始支持modelValue的写法了但 Vue2 老生态里大量组件还是按旧的value input来写的升级到 Vue3 后这些自定义组件都要手动改一遍。还有一个 Vue3 的进阶玩法自定义v-model修饰符。比如你想做一个自动大写输入的组件script setup const props defineProps({ modelValue: String, modelModifiers: { default: () ({}) } }) const emit defineEmits([update:modelValue]) function handleInput(e) { let value e.target.value if (props.modelModifiers.uppercase) { value value.toUpperCase() } emit(update:modelValue, value) } /script父组件就可以这么用ChildComponent v-model.uppercasekeyword /这点是 Vue2 做起来很别扭、Vue3 特别顺手的场景。2.4 $attrs 与 inheritAttrs非 props 属性的透传还有一个容易被忽略的传参通道是$attrs。它承载的是那些“父组件传下来了但子组件没有声明为 props”的属性。Vue2 中$attrs只包含非 props、非 class/style 的属性事件监听器单独放在$listeners里。子组件可以通过inheritAttrs: false阻止这些属性自动挂在根元素上然后再手动绑到内部元素。Vue3 把这个机制统一了$listeners被合并进$attrsclass 和 style 也会出现在$attrs里。如果你用了 setup 语法糖可以用useAttrs()获取script setup import { useAttrs } from vue const attrs useAttrs() console.log(attrs) // 包含事件监听器、class、style、普通属性 /script这个特性在做组件二次封装时特别有用。比如你封装一个基于原生 input 的业务输入组件希望把父组件传进来的 placeholder、disabled、事件监听器等通通透传给内部的 input就完全不需要一个一个声明 props直接v-bind$attrs一把梭template input v-bind$attrs / /template需要注意一个点Vue3 的多根节点组件不会自动继承$attrs你需要手动指定挂载目标。另外如果某个属性既被声明为 props 又出现在$attrs里以 props 为准不会重复透传。3. 跨层组件通信provide/inject 与全局事件总线3.1 Vue2 的 EventBus 很顺手为什么到 Vue3 失灵了在 Vue2 时代很多人跨层通信的第一选择就是 EventBus。写法非常简单// bus.js import Vue from vue export const bus new Vue()组件 A 里发送bus.$emit(refresh, { id: 1 })组件 B 里接收created() { bus.$on(refresh, this.handleRefresh) }, beforeDestroy() { bus.$off(refresh, this.handleRefresh) }这套方案的问题在大型项目里会逐渐暴露事件名没有统一管理不同页面之间靠字符串通信时间一长根本不知道谁在监听谁在发出而且如果不在beforeDestroy里手动$off组件销毁之后监听器还留在内存里极容易造成内存泄漏和重复触发。我见过一个线上问题页面切换了十几次之后刷新事件被触发了十几次原因就是旧组件没销毁干净。Vue3 直接从 Vue 实例上移除了$on、$off、$once这几个方法想继续用事件总线只能借助第三方库比如mittimport mitt from mitt export const bus mitt() // 组件 A bus.emit(refresh, { id: 1 }) // 组件 B bus.on(refresh, handler) bus.off(refresh, handler)但我的建议是除非你维护的是一个非常小的工具型项目否则别依赖 EventBus 做核心业务通信。Vue3 里更好的替代是provide/inject或者直接上 Pinia。3.2 组合式下的 provide/inject 玩法provide/inject解决的是祖先组件向后代任意层级组件传参的问题中间隔多少层都无所谓。这在做全局配置、主题切换、用户信息下发的时候非常合适。Vue2 里的用法是选项式// 祖先组件 export default { data() { return { appName: My App, theme: light } }, provide() { return { appName: this.appName, theme: this.theme } } }后代组件export default { inject: [appName, theme] }Vue3 组合式写法更直接// 祖先组件 import { provide, ref } from vue const theme ref(light) provide(theme, theme)后代组件import { inject } from vue const theme inject(theme)你发现没有Vue3 里 provide/inject 的定位变成了一个可以在任意层级共享响应式数据的工具。注入的对象本身是响应式引用后代组件修改了theme.value所有注入方都会自动更新这在 Vue2 里是实现不了的。还可以直接把方法传下去。比如做一个全局的抽屉控制器祖先提供openDrawer函数任何后代组件都能调用比一层层 $emit 转发清爽太多// 祖先组件 const openDrawer (payload) { drawerState.value payload } provide(openDrawer, openDrawer) // 后代组件 const openDrawer inject(openDrawer) openDrawer({ type: user, id: 123 })3.3 响应式传递Vue2 传 this 与 Vue3 传 ref 的对比Vue2 的 provide/inject 有个经典陷阱直接传一个普通值后代组件拿到后是不会跟随改变的。// 错误示范Vue2 provide() { return { theme: this.theme // 这是普通字符串不是响应式引用 } }我第一次用 Vue2 provide/inject 的时候就栽在这上面父组件 theme 明明已经从 light 切到 dark 了子组件里的 theme 还是 light。后来查了源码才发现Vue2 的 provide/inject 根本不具备响应式能力你 provide 的是“那一刻的值”。解决办法是直接把整个实例传下去// Vue2 可行方案 provide() { return { app: this } }后代组件inject: [app], computed: { theme() { return this.app.theme } }这样拿到的 theme 就是响应式的了因为this.app是同一个组件实例访问它的响应式数据自然能被追踪。但这么写很快会被业务代码里的各种this.app.xxx搞疯而且把整个实例暴露给所有后代封装性也差。到了 Vue3这个痛点被彻底解决了。你直接const theme ref(light) provide(theme, theme)或者const state reactive({ theme: light }) provide(state, state)后代组件注入后天然具备响应性。这是我个人在迁移过程中觉得体验提升最明显的一块值得你重点记住。4. 插槽传参与 ref 取实例容易被忽略的通信入口4.1 具名插槽、作用域插槽在 Vue2 和 Vue3 的写法和差异插槽严格来说不属于数据传参但它解决的是“父组件往子组件内部塞自定义内容”的需求在组件通信体系里也非常重要。Vue2.6 之前写具名插槽和插槽 props 比较“原始”!-- Vue2.6 之前 -- ChildComponent template slotheader标题/template template slotitem slot-scope{ item }{{ item.name }}/template /ChildComponentVue2.6 开始官方推荐v-slot语法!-- Vue2.6 -- ChildComponent template v-slot:header标题/template template v-slot:item{ item }{{ item.name }}/template /ChildComponent到了 Vue3slotxxx和slot-scope直接移除了只保留v-slot。你如果维护老代码二维迁移时遇到模板不渲染第一反应就该查是不是还留着老语法。子组件端定义插槽的差异其实很小。Vue2 和 Vue3 都类似template div slot nameheader/slot slot nameitem :dataitem/slot slot/slot /div /template父组件在 Vue3 里更喜欢用简写#ChildComponent template #header标题/template template #item{ data }{{ data.name }}/template template #default默认内容/template /ChildComponent有一个容易混淆的点作用域插槽的{ data }解构拿到的是子组件slot标签上绑定的所有属性。属性名以slot绑定时的名字为准不是父组件自己定义的变量名。Vue3 的 setup 里还有一个useSlots()适合做高阶组件或者二次封装时使用script setup import { useSlots } from vue const slots useSlots() console.log(slots.default) // 默认插槽的渲染函数 console.log(slots.header) // 具名插槽渲染函数 /script虽然日常写业务用不到但写基础库或者做组件二次封装时useSlots()是绕不开的工具。4.2 ref 取子组件实例this.$refs 到 defineExposeref是父组件直接拿到子组件实例、调用内部方法的通道在表单组件、复杂的 UI 控制组件里非常常用。Vue2 里的写法template ChildComponent refchildRef / /template script export default { mounted() { this.$refs.childRef.reset() } } /scriptVue3 选项式写法其实还能用this.$refs.childRef但如果你用了 setup 语法糖情况就变了。模板里用refchildRef定义一个模板引用script setup import { ref, onMounted } from vue const childRef ref(null) onMounted(() { childRef.value.reset() }) /script template ChildComponent refchildRef / /template到这里还没完。在 Vue3 setup 语法糖里子组件内部定义的变量和方法默认是“私有的”父组件拿不到。必须用defineExpose显式暴露script setup import { ref } from vue const count ref(0) function reset() { count.value 0 } defineExpose({ count, reset }) /script如果你忘了defineExpose父组件拿到的childRef.value就是个空对象调用reset直接会报“不是函数”。这种报错出现的时候排查方向非常明确第一看 ref 是否绑定到了正确的子组件上第二看子组件是否用 defineExpose 暴露了对应方法。如果你用的是普通setup()函数而不是语法糖return 出去的内容会自动暴露到组件实例上父组件是能访问到的// 普通 setup 写法 export default { setup() { const count ref(0) function reset() { count.value 0 } return { count, reset } // 自动暴露 } }这点也是 Vue3 两套 setup 写法的差异之一迁移时容易产生困惑。5. 高频踩坑实录与排查技巧5.1 这些报错和“奇怪现象”的排查思路我把这几年实际遇到过的组件传参问题整理成一张速查表你排查的时候可以直接对照现象可能原因解决方案子组件修改 props 后父组件数据也变了props 传入的是引用类型且子组件内直接改了对象/数组子组件内深拷贝或重新赋值不要直接操作 propsVue3 中 v-model 不生效子组件没有定义update:modelValue事件补上emit(update:modelValue, value)事件触发两次子组件未在emits声明事件导致透传到根元素在子组件defineEmits或emits选项里显式声明组件销毁后事件仍然触发EventBus 的监听器未在beforeDestroy/onUnmounted中移除统一在生命周期函数里$off/bus.offprovide 的数据不变Vue2 中 provide 传了普通值不是响应式引用传this实例或用 Vue3 的ref/reactive插槽内容不渲染使用了被移除的slotxxx/slot-scope语法改为v-slot/#xxx$listeners is not a function代码从 Vue2 迁移到 Vue3$listeners已移除使用$attrs或useAttrs()父组件拿不到子组件方法setup 语法糖下未使用defineExpose在子组件内defineExpose({ methodName })props 默认值报警告数组/对象默认值写成了固定值改为default: () []或default: () ({})这里面我想重点展开两个。第一个是“事件触发两次”。这个问题在 Vue2 里也可能出现但 Vue3 里因为emits声明的机制出现的频率更高。排查步骤我建议是这样先打开 Vue Devtools选中出问题的组件看它的$attrs里是不是还挂着事件监听器如果挂着说明该事件没被声明为组件事件被当成普通属性透传了。然后在子组件的emits选项或defineEmits里补上声明即可。第二个是“子组件修改 props”。Vue2 和 Vue3 都禁止直接修改 props但不是所有人都理解为什么。props 是父组件单向数据流下来的快照子组件直接修改它父组件的状态不会同步变化但子组件内部的数据却变了两边就产生了分歧。正确做法是要用这个值做初始状态就复制一份到 data 或 ref 里要根据 props 派生新值就写 computed。// 正确做法子组件复制 props 为内部状态 export default { props: [initialCount], data() { return { count: this.initialCount } } }Vue3 同理script setup import { ref } from vue const props defineProps([initialCount]) const count ref(props.initialCount) /script5.2 传参设计自查清单除了解 bug我更想给你一份“写新代码之前先过一遍”的清单这是我做组件库时沉淀下来的习惯通信关系先分类。父子组件优先 props emit不要为了省事直接把数据挂到全局跨层组件优先 provide/inject全局共享状态交给 Pinia别造“局部全局变量”。props 命名统一用驼峰声明、短横线传递对象和数组默认值必须走工厂函数。所有组件派发的事件一律在emits/defineEmits里声明。事件名建议用 kebab-case比如update:user-info可读性好也符合 HTML 的天然风格。v-model 绑定多个值时统一使用v-model:xxx形式子组件按update:xxx发射事件不要混用各种修饰符。provide 的数据尽量用 ref 或 reactive 包装确保响应式。provide 一个普通常量不是不行但你要清楚它更新不了。子组件通过 ref 暴露给父组件的方法和属性控制在最小范围不要毫无保留地把整个内部状态都defineExpose出去否则长期维护时很难分清哪些是公共接口、哪些是内部实现。事件总线如果可以不用就不用。项目规模一大事件名散落各处带来的心智负担远比引入一个状态管理工具大得多。最后再分享一个我实际的体会Vue2 往 Vue3 迁移别急着把所有代码一次性重写。你先挑一个业务组件把它的 props、emits、v-model、插槽、ref 这几条通道全都对照着新写法理一遍踩几个坑之后再批量铺开速度反而更快。组件传参这件事说难不难说简单也不简单核心就是数据流向清楚、接口边界明确。你把上面这几条通道理顺Vue2 和 Vue3 的差异点记牢日常开发里组件通信这块基本不会再有能卡住你超过十分钟的问题。