ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue2子组件传参父组件:从$emit到.sync的完整实战指南

Vue2子组件传参父组件:从$emit到.sync的完整实战指南 Vue2子组件传参给父组件的完整实战Vue2的组件通信里子组件向父组件传参应该算是最基础的必修课了。网上讲这个主题的文章非常多但大部分停留在“用$emit触发事件”这一步实际项目里遇到的命名规范、参数传递边界、事件触发时机、.sync修饰符等细节反而没人系统梳理过。这篇文章我打算把这些年写Vue2项目时踩过的坑和沉淀下来的思路完整讲一遍从最基础的$emit用法一直讲到复杂业务场景下的组件通信方案无论你是刚接触Vue2的初学者还是写了一阵子但没系统梳理过这块的同学都能找到对应的参考。1. 先搞清楚Vue2组件通信的完整版图1.1 Vue2组件通信的几条主要通道Vue2的组件通信方式其实不止一种除了子组件通过事件传递数据给父组件之外还有props父传子、ref直接调用子组件方法、$parent和$children访问组件实例、EventBus全局事件总线、Vuex状态管理等。这些方式各有适用场景所以先要把版图看清楚才能明白为什么“子传父”这一场景里事件传递是最常用的也是优先级最高的选择。props是父传子的标准通道子组件通过props声明自己需要的数据父组件在模板里绑定属性传进去。这种方式数据流是单向的子组件不能直接修改props保证了数据的可追溯性。ref则是父组件直接拿到子组件的实例调用子组件暴露的方法或者读取子组件的数据这种方式比较直接适合父组件主动触发子组件行为的时候使用。$parent和$parent和$children则提供了组件实例之间的直接访问用起来方便但耦合度高官方文档也提醒这两个属性只在特殊场景下使用。EventBus是创建一个空的Vue实例作为事件中转站任意组件都可以在上面$emit和$on适合没有嵌套关系的组件之间通信。Vuex则是把共享状态抽离到全局store里适合复杂的大型应用。如果你问我这些方式怎么选我的经验是优先考虑props和事件传递这种基于组件树结构的通信方式因为它们遵循了Vue的单向数据流设计代码出问题的时候排查路径清晰。1.2 为什么说“子传父”首选事件传递子传父这个场景里事件传递之所以是首选方案核心原因在于它和props天然构成了一个闭环。父组件通过props把数据传给子组件子组件产生新的数据之后通过$emit触发自定义事件把数据作为参数传回给父组件父组件在事件处理函数里接收并更新自己的数据。这个数据流是完整、单向、可预测的符合Vue官方的推荐设计模式。事件传递另外一个明显优势是它的解耦性。子组件不需要知道父组件的存在它只是在自己内部触发一个事件并携带数据至于这个事件被谁监听、监听之后做什么处理子组件完全不关心。这种设计让子组件的复用性大大增强同一个子组件可以被不同父组件使用父组件各自监听事件做各自的业务处理。我做过一个比较典型的项目一个订单列表组件被用在了订单管理页和售后处理页两个地方。订单列表组件内部有一个“状态变更”的操作它只需要$emit一个状态变更事件同时把订单ID和新状态传出去。订单管理页监听这个事件后刷新订单数据售后处理页监听同一个事件后弹出售后确认弹窗。同一个子组件两个父组件各取所需这就是事件传递解耦性的直接体现。1.3 事件机制背后的Vue2响应式原理理解$emit事件机制之前有必要先看看Vue2的事件系统是怎么实现的。Vue2的事件机制本质上是一个发布订阅模式。每个Vue实例内部都维护了一个事件中心这个事件中心在源码里对应的是vm._events对象它是一个以事件名为键、以回调函数数组为值的对象。当父组件在模板里用custom-eventhandleCustomEvent监听事件时Vue在编译模板的过程中会调用vm.$on方法把handleCustomEvent这个回调注册到子组件实例的_events对象里。这里有一个很多初学者容易搞混的点$emit和$on虽然看起来是“事件”但它们和浏览器的原生自定义事件CustomEvent并不是一回事。Vue的事件系统是纯JavaScript层面的实现不经过原生DOM事件机制。所以在Vue组件里$emit触发的事件不会冒泡也不会被父组件模板之外的DOM节点捕获。这意味着父组件模板里必须直接使用子组件标签并在标签上绑定事件监听才能接收到子组件$emit出来的事件。Vue2响应式系统和事件系统是两条独立但又常常配合使用的链路。响应式系统负责数据变化时通知视图更新事件系统负责组件之间的通信和交互。子组件$emit传参给父组件后父组件在事件回调里修改自己的data数据这个修改动作才会触发父组件的响应式更新进而重新渲染视图。2. 核心实操$emit事件传参的完整写法2.1 最基础的三步走先从一个最简单的案例开始。子组件是一个计数器点击按钮把当前的计数值传给父组件父组件接收后在页面上展示。子组件Counter.vue的代码template div classcounter p当前计数{{ count }}/p button clickhandleAdd加 1/button /div /template script export default { name: Counter, data() { return { count: 0 }; }, methods: { handleAdd() { this.count; this.$emit(count-change, this.count); } } }; /script父组件App.vue的代码template div classapp h3父组件接收到的计数{{ receivedCount }}/h3 Counter count-changehandleCountChange / /div /template script import Counter from ./components/Counter.vue; export default { name: App, components: { Counter }, data() { return { receivedCount: 0 }; }, methods: { handleCountChange(val) { this.receivedCount val; } } }; /script子组件内部维护了一个count数据每次点击按钮count自增自增之后立即调用this.$emit(count-change, this.count)把最新的count值作为事件参数传出去。父组件在模板里使用 标签的位置绑定了count-changehandleCountChange这个监听器会在子组件触发count-change事件时被调用接收到的参数就是子组件传出来的count值赋值给父组件的receivedCount数据视图随之更新。这个例子虽然简单但完整展示了“子组件内部状态变化 - 通过事件通知父组件 - 父组件更新自己的数据 - 父组件视图刷新”的完整链路掌握了这个基础流程之后再叠加各种细节和最佳实践就会容易得多。2.2 传多个参数的写法与推荐做法实际情况里子组件往往不止传一个参数。继续上面的计数器例子如果父组件希望同时知道“当前的计数”和“这次操作的类型”子组件可以这样写script export default { name: Counter, data() { return { count: 0 }; }, methods: { handleAdd() { this.count; this.$emit(count-change, this.count, add); }, handleReset() { this.count 0; this.$emit(count-change, this.count, reset); } } }; /script父组件接收script export default { name: App, methods: { handleCountChange(val, type) { this.receivedCount val; this.changeType type; } } }; /script这种传多个参数的方式在Vue2里是完全支持的$emit方法的第一个参数是事件名从第二个参数开始都是要传给回调的参数。但这里我的建议是如果参数超过两个最好把它们合并成一个对象来传。原因有两点第一是回调函数里的参数顺序容易出错尤其在多人协作的时候新增一个参数可能导致所有监听方的回调顺序都要调整第二是对象的形式语义更清晰数据之间的关系一目了然后续要扩展字段只需要在对象里加属性监听方不关心新增的字段就完全不用改代码。推荐写法是子组件这样script export default { methods: { handleUpdate(data) { this.$emit(update-data, { count: this.count, type: data.type, timestamp: Date.now() }); } } }; /script父组件这样script export default { methods: { handleUpdateData(payload) { this.receivedCount payload.count; this.changeType payload.type; } } }; /script用对象作为事件参数之后回调函数只需要接收一个payload对象内部按需取字段参数顺序带来的问题就不存在了代码的可维护性也会好很多。2.3 传事件对象、传复杂嵌套数据的注意点有一种容易混淆的情况是子组件模板里通过clickhandleClick($event)来把原生事件的event对象传给方法然后在handleClick里再手动$emit出去给父组件。这种情况下原生事件对象和自定义事件参数很容易在写法上搞混。以给子组件根元素绑定原生click事件、并把原生事件对象转交给父组件为例template div classwrapper clickhandleWrapperClick slot/slot /div /template script export default { name: ClickableWrapper, methods: { handleWrapperClick(e) { this.$emit(wrapper-click, e); } } }; /script父组件使用template ClickableWrapper wrapper-clickhandleWrapperClick p点击这个区域/p /ClickableWrapper /template script export default { methods: { handleWrapperClick(e) { console.log(原生事件对象, e); console.log(点击坐标, e.clientX, e.clientY); } } }; /script这里要注意的一点是在子组件模板里click绑定的处理函数是子组件的方法方法内部通过$emit去触发自定义事件最终父组件监听的是自定义事件wrapper-click而不是原生click事件。这个链路容易出错的地方在于有人在父组件里直接写clickhandleWrapperClick然后发现触发不了原因就是原生click事件并不会自动穿透组件边界Vue2里只有组件根元素的原生事件在绑定时不加.native修饰符不会生效这个细节后面在常见问题部分还会展开聊。传复杂嵌套数据时还有一个容易踩的坑是直接传递引用类型数据。如果子组件把一个对象传给父组件父组件在回调里修改了对象的属性因为对象是引用类型子组件内部对应的数据也会跟着变。这种“副作用”有时候是期望的有时候不是。如果希望父组件的修改不反向影响子组件建议在$emit之前用JSON.parse(JSON.stringify(obj))做一次深拷贝或者用展开运算符做浅拷贝具体取决于数据结构的深浅。3. 进阶玩法事件名规范与.sync修饰符3.1 事件命名为什么要用kebab-case很多初学者在给自定义事件命名时比较随意组件内部$emit(updateCount)父组件监听updateCount处理看起来也能正常工作因为JavaScript方法名和对象键名的确支持驼峰写法。但一旦涉及模板编译情况就不同了。Vue2官方推荐在模板里监听事件时使用kebab-case短横线命名也就是update-count而非updateCount。原因在于HTML标签的属性名在解析时不区分大小写浏览器会把属性名统一转成小写。虽然Vue的模板编译器做了很多兼容处理但在某些边缘情况下尤其是直接使用DOM模板而非字符串模板时驼峰命名的事件名会出现监听不到的问题。我的习惯是项目里统一事件命名规范全部使用kebab-case。子组件里$emit(update-count, data)父组件里update-counthandleUpdateCount。这样视觉上更清晰也能避免编辑器解析和模板编译环节的潜在问题同时符合大多数团队的代码规范约定。3.2 v-model在组件上的实现原理在讲.sync之前先看一个更常用的东西。Vue2的v-model指令用在表单元素上时它其实是:value和input两个绑定的语法糖。当v-model用在自定义组件上时默认会被解析成:valuesomeProp和inputsomeProp $event两个指令。换句话说在自定义组件上使用v-model等价于父组件做了两件事通过props把value传给子组件监听子组件的input事件并把事件参数赋回给value。子组件这边要接收props里的value在值需要更新时$emit(input, newValue)这样父组件绑定的数据就能自动更新。完整的自定义v-model组件案例子组件InputWrapper.vuetemplate div input :valuevalue inputhandleInput classform-input / /div /template script export default { name: InputWrapper, props: { value: { type: String, default: } }, methods: { handleInput(e) { const newValue e.target.value; this.$emit(input, newValue); } } }; /script父组件使用template div InputWrapper v-modelusername / p输入的账号{{ username }}/p /div /template这个模式解决了“一个数据既是父组件的又需要由子组件来修改”的典型场景。比如表单项组件、弹窗组件、日期选择器组件等都可以通过自定义v-model对外提供简洁的接口。当然这里也有一个默认规则自定义组件上使用v-model子组件内部不能修改props里的value只能在交互时通过$emit(input, newValue)把新值传给父组件由父组件完成数据更新。3.3 .sync修饰符的场景和局限.sync修饰符是Vue2提供的一个语法糖它解决的核心问题和自定义v-model类似也是“子组件想修改父组件传进来的数据”但它更通用一些。v-model默认只绑定一个value属性和一个input事件而.sync允许你绑定任意命名的prop。父组件写法template ChildComponent :visible.syncdialogVisible / /template这一行代码等价于template ChildComponent :visibledialogVisible update:visibleval (dialogVisible val) / /template所以子组件内部的写法也对应地变成script export default { name: ChildComponent, props: { visible: { type: Boolean, default: false } }, methods: { closeDialog() { this.$emit(update:visible, false); } } }; /script子组件在需要关闭弹窗的时候不直接修改props里的visible而是通过$emit(update:visible, false)通知父组件要关闭弹窗了父组件监听了update:visible事件后更新dialogVisible数据。这样以来父子组件共同维护一个数据的状态变化并且变化路径清晰没有破坏单向数据流的原则。.sync在项目里的典型应用是弹窗组件的控制。我之前做的后台管理系统里几乎所有Dialog弹窗的visible状态都是通过.sync来控制的。父组件统一管理所有弹窗开关的状态弹窗组件内部关闭按钮点击时$emit(update:visible, false)关掉自己的同时父组件里的状态也同步更新。不过.sync也有局限一个组件使用.sync时一次只能绑定一个属性的更新事件。如果需要同时同步多个状态就得写多个.sync或者在父组件里做状态聚合把一个对象作为prop传进去然后子组件$emit(update:formData, newFormData)整体替换。实际项目里我的经验是优先用对象整体同步的方式保持状态集中、变更可控。4. 常见坑与排查实录4.1 事件没触发的排查路径子组件$emit了事件父组件却没有任何反应这个问题的排查路径其实是有迹可循的。我整理成一个标准排查顺序遇到这类问题基本能快速定位。第一步检查事件名是否完全一致。子组件的this.$emit(count-change)和父组件的count-changehandleCountChange两者的事件名必须字符串完全相等。注意kebab-case、camelCase、PascalCase之间的差异Vue2模板里虽然对camelCase有部分兼容但并不能完全依赖这个兼容性。第二步检查事件是否绑定在正确的组件标签上。事件监听必须写在使用了子组件的标签上比如Counter count-changehandleCountChange /。如果写在父组件的根元素上或者写在子组件内部的某个普通元素上都不会生效。第三步检查$emit的调用时机。如果$emit是在created或者mounted生命周期里同步执行的此时父组件的事件监听可能还没有注册完成就会出现“事件触发了但父组件没收到”的竞态现象。这种情况通常需要改用$nextTick延迟触发或者把逻辑改成由父组件主动通过ref调用子组件方法。第四步检查是否被.native修饰符影响了。在Vue2里如果子组件是一个自定义组件父组件监听它的原生事件比如click时必须加上.native修饰符写成click.nativehandleClick。但加上.native之后父组件监听的就是“子组件根元素的原生click事件”而不是子组件内部$emit触发的事件。这两个事件渠道不同如果混淆了就会出现“事件没触发”的错觉。4.2 生命周期和$emit时机引发的竞态问题前面提到了$emit触发时机的问题这里展开多讲一点。Vue2组件实例化的过程里父组件的created钩子和子组件的created钩子执行顺序是父组件created - 子组件created - 子组件mounted - 父组件mounted。也就是说在父组件created阶段子组件还没有创建完成更谈不上在子组件里$emit了。实际项目中我遇到过这样一个场景一个筛选条件组件在mounted里根据接口返回的默认值主动$emit了一个查询事件希望父组件拿到默认值后去拉列表数据。但调试时发现父组件偶尔能收到、偶尔收不到。排查下来原因是子组件mounted触发时父组件的mounted还没触发父组件自己在mounted里面做的一些初始化逻辑还没执行完导致子组件emit的查询事件被父组件的某个条件拦截了。这种“事件比父组件初始化先到”的竞态问题解决方案通常有三种思路。第一种是在父组件里用$nextTick在mounted后再监听事件但这个方法不够优雅。第二种是父组件不在mounted里做初始化而是用watch监听接收到的子组件数据配合immediate属性在数据变化时做响应。第三种是彻底改变通信方式由父组件在某个确认的时机通过ref调用子组件的getDefaultFilter方法主动获取默认值。我的推荐是第三种把“子组件主动上报”改成“父组件主动拉取”在初始化流程里可控性更强。4.3 事件名冲突与命名空间管理一个项目里组件多了之后事件名很容易出现重复。比如很多子组件都会$emit(change)父组件同时引用了两个子组件事件监听就可能互相干扰。我之前维护过一个老项目一个比较大的页面里用了三个下拉选择组件三个组件内部都把状态变化事件命名为change父组件模板里三个下拉都写了changehandleChange结果任何一个下拉变化都会触发同一个handleChange方法数据串得乱七八糟。后来统一整改把事件名改为含有组件语义的命名比如type-change、status-change、city-change问题立刻解决。这里我的建议是组件内部自定义事件的命名尽量带上领域前缀类似前端事件里的命名空间概念。比如订单组件的事件可以用order-status-change、order-cancel等避免只写一个change或者update这种过于通用的名字。同时建议在组件文档里统一记录对外暴露的事件列表包括事件名、参数、触发时机方便团队其他成员使用组件时快速了解接口。4.4 双向绑定和对象引用导致的数据串改前面提到子组件通过事件传对象给父组件时如果传的是引用类型父组件的修改可能会反向影响子组件内部的数据。这个现象在多人协作时非常容易出现因为代码审查的时候不仔细看很难发现。举一个实际例子子组件从接口拿了一组列表数据点击某一行时把这个列表的item通过事件传给父组件。父组件在事件回调里做了一些字段修正比如给item追加了一个remark字段。看起来只是父组件自己改了数据但因为item是从子组件的list里直接取出来的引用所以子组件的list对应项也悄悄多了remark字段。后面子组件再渲染列表时可能会因为多了字段出现样式错乱或者逻辑误判。这种问题的根治方案是在事件传参边界上做好数据拷贝子组件向外传递引用类型数据时根据数据大小选择拷贝方式。小对象用展开运算符{ ...item }即可多层嵌套用JSON深拷贝或者结构化克隆如果数据量特别大可以考虑让父组件通过ref调用子组件方法获取原始数据而不是通过事件传递。5. 实战扩展复杂场景下的事件传参方案5.1 多层级组件嵌套时的事件中转有时候组件层级比较深孙子组件要把数据传给最顶层的爷爷组件。很多人的第一反应是逐层$emit中转孙子emit给儿子儿子再emit给父亲父亲再emit给爷爷。这种写法虽然能工作但每个中间组件都要多写一遍事件转发逻辑代码非常啰嗦而且中间组件的职责被污染了它本来只是做一个布局容器结果还要负责事件中继。对照实际项目经验有三种更优雅的方案。第一种是使用provide/inject配合事件回调函数。父组件通过provide提供自己内部的方法子孙组件通过inject注入这个方法直接调用。这种方式的本质是把“事件传递”换成了“方法调用”让跨层级调用变得直接。需要注意provide/inject的数据不是响应式的所以实际使用时配合一个Vue实例或者Vue.observable来管理会比较稳妥。第二种是使用EventBus。在主入口main.js里创建全局事件总线// main.js Vue.prototype.$bus new Vue();孙子组件里触发this.$bus.$emit(grandson-data-change, data);爷爷组件里监听this.$bus.$on(grandson-data-change, this.handleGrandsonData);在beforeDestroy钩子里记得取消监听this.$bus.$off(grandson-data-change, this.handleGrandsonData);EventBus用起来很方便但它本质上是全局状态滥用的话会让数据流变得难以追踪适合低频、跨层的通知场景。第三种是用Vuex适合数据需要被多个组件共享并且修改频繁的场景。dispatch一个action或者commit一个mutation所有mapState的组件都能响应变化。这三种方案的选择标准我的总结是跨层次数少就逐层中转层数多且通知频率低用EventBus层数多、数据状态复杂、多处使用就用Vuex。5.2 性能注意事项避免频繁触发大事件事件传递本身是轻量级的但如果子组件在一个高频操作里频繁$emit事件可能会引发父组件大量重复计算。常见场景是输入框的实时搜索、拖拽过程中的位置同步、滚动加载时的状态上报。一个典型的性能问题案例是子组件在input的input事件里每次敲击键盘都$emit一个input-change事件父组件监听后在回调里做了庞大的数据处理比如过滤一个上万条的数据列表。这样每次按键都会触发一次全量过滤页面就会卡顿明显。优化思路通常有两个方向。一个是在父组件里做防抖节流用lodash的debounce包裹事件回调限制高频事件的处理频率。另一个是在子组件里做事件合并比如输入框的上报事件延迟到用户停止输入后再统一$emit一次。哪种更好取决于具体场景如果子组件的输入事件还需要被其他逻辑监听建议在子组件里做合并如果只有父组件关心这个事件在父组件回调里做防抖会更直观。另外一个性能细节是事件参数的数据量。如果每次$emit传递的是一个大数组或者一个大对象需要评估这个传递是否有必要每次都全量发送还是只发送变化的部分。在移动端低端设备上这种细节的差距会直接影响页面流畅度。5.3 事件驱动的组件设计模式把事件传递用好了可以沉淀出一套比较成熟的组件设计模式。我自己在项目里常用的一种模式是“状态型子组件”。这类组件接收一个props作为初始值内部维护交互状态任何内部状态变化都通过事件同步给父组件同时通过watch监听外部props的变化来同步内部状态。父组件拿到的是单一的数据源子组件负责交互细节两者各司其职。以自定义下拉选择组件为例父组件传入options列表和当前选中的value子组件内部记录当前展开状态用户点击选中选项后子组件更新内部显示同时$emit(update:value, newValue)通知父组件。父组件更新了value之后通过props传给子组件的value又变成了新值如果子组件内部显示和props不同步就会出现在父组件外部重置时子组件里显示不刷新的bug。这种情况下子组件通常需要watch props中的value在外部值变化时同步更新内部显示。这就涉及我们之前提过的子组件内部不要直接修改props的原则正确做法是script export default { name: SelectBox, props: { value: { type: [String, Number], default: } }, data() { return { currentValue: this.value }; }, watch: { value(newVal) { this.currentValue newVal; } }, methods: { handleSelect(val) { this.currentValue val; this.$emit(update:value, val); } } }; /script这种模式在Element UI这类组件库里被大量使用掌握了它再去看开源组件库的源码理解成本会低很多。6. 事件通信方案的综合对比与选型建议6.1 各类通信方式横向对比常说的Vue2组件通信方式从数据流方向、解耦程度、适用场景几个维度做一个对比通信方式数据流方向解耦程度适用场景注意事项props父传子高父组件向子组件传递初始数据、配置项子组件不能直接修改$emit事件子传父高子组件通知父组件状态变化事件名要保持一致v-model双向语法糖中表单项、弹窗类组件默认绑定value和input.sync双向语法糖中轻量状态同步一个属性需要一个事件ref任意方向低父组件主动调用子组件方法依赖组件实例耦合度高provide/inject父传后代中跨层级传递配置、方法非响应式需要包装EventBus任意方向低非嵌套组件间通信全局状态不易追踪Vuex任意方向高复杂共享状态管理需遵循mutation规范这个表格看起来简单但实际选型的时候我一般会先问自己三个问题这两个组件在组件树上的距离有多远这个数据是不是被很多组件共享通信频率是低还是高明确了这三个问题的答案通信方式基本就是唯一的了。组件间是直接的父子关系就首选props和事件距离远但调用链清晰用provide/inject临时性跨组件通知用EventBus多组件共享频繁变化的全局状态用Vuex。这个原则基本能覆盖90%以上的项目场景。6.2 我建议的事件传递约定结合几年Vue2项目经验我在团队里推行了一套事件通信约定这里分享出来供大家参考。事件命名使用kebab-case并且带上组件名或者领域名作前缀。比如一个用户选择器组件对外事件统一命名为user-select-change、user-select-clear而不是简单的change或者clear。所有对外事件尽量在组件内部集中声明业务组件可以通过一个常量文件管理事件名避免字符串散落在代码各个角落导致拼写错误难以发现。维护一个事件清单的成本其实不高但后续排查问题的效率提升非常明显。事件参数统一用对象形式传递。单个参数确实省事但业务迭代后大概率要加参数从第一天就用对象形式可以避免后续重构回调签名。对象里保留足够的信息比如除了新值之外可以带上操作类型和原始值让父组件有更多决策空间。子组件里所有通过事件传给父组件的数据确保不包含对子组件内部状态的隐性依赖。如果父组件拿到数据后又回传给了另一个子组件要避免引用串改的问题。涉及引用类型建议做浅拷贝涉及深层嵌套数据做深拷贝再传。7. 实际项目中的一套完整案例7.1 需求描述与组件划分用一个实际案例把前面的所有知识点串起来。假设要做这样一个页面一个商品筛选区域包含品牌下拉选择、价格区间输入、重置按钮。筛选条件变化后要通知列表区域重新请求数据。同时筛选区域自己需要记住当前的筛选状态并在父组件重置时恢复初始状态。这个需求可以拆分成三个组件筛选区FilterPanel是父组件管理下的一个子组件内部再拆成BrandSelect和PriceInput两个孙子组件。FilterPanel需要接收孙子组件上传的品牌和价格数据整合成完整的筛选条件后统一$emit给父组件。组件层级父组件 FilterPage └── FilterPanel子组件 ├── BrandSelect孙子组件 └── PriceInput孙子组件7.2 孙子组件传参给子组件的实现BrandSelect在选中品牌时向FilterPanel上报品牌值template div classbrand-select label品牌/label select :valueselectedBrand changehandleChange option value全部品牌/option option valuebrandA品牌A/option option valuebrandB品牌B/option option valuebrandC品牌C/option /select /div /template script export default { name: BrandSelect, props: { value: { type: String, default: } }, data() { return { selectedBrand: this.value }; }, watch: { value(newVal) { this.selectedBrand newVal; } }, methods: { handleChange(e) { const val e.target.value; this.selectedBrand val; this.$emit(update:value, val); } } }; /scriptPriceInput在价格输入时向FilterPanel上报价格区间template div classprice-input label价格区间/label input typenumber :valueminPrice placeholder最低价 inputhandleMinInput / span至/span input typenumber :valuemaxPrice placeholder最高价 inputhandleMaxInput / /div /template script export default { name: PriceInput, data() { return { minPrice: , maxPrice: }; }, methods: { handleMinInput(e) { this.minPrice e.target.value; this.emitPriceChange(); }, handleMaxInput(e) { this.maxPrice e.target.value; this.emitPriceChange(); }, emitPriceChange() { this.$emit(price-change, { minPrice: this.minPrice, maxPrice: this.maxPrice }); } } }; /script这里BrandSelect用了v-model的默认模式FilterPanel里使用它时直接写v-modelbrandValue比较简洁。PriceInput则显式暴露了price-change事件FilterPanel里监听并接收价格区间对象。7.3 子组件整合参数再次上报给父组件FilterPanel组件整合两个孙子组件的数据统一上报给父组件template div classfilter-panel BrandSelect v-modelbrandValue / PriceInput price-changehandlePriceChange / button clickhandleReset重置筛选/button button clickhandleSubmit提交筛选/button /div /template script import BrandSelect from ./BrandSelect.vue; import PriceInput from ./PriceInput.vue; export default { name: FilterPanel, components: { BrandSelect, PriceInput }, data() { return { brandValue: , priceRange: { minPrice: , maxPrice: } }; }, methods: { handlePriceChange(payload) { this.priceRange { ...payload }; }, handleReset() { this.brandValue ; this.priceRange { minPrice: , maxPrice: }; this.$emit(filter-change, this.getFilterData()); this.$emit(filter-reset); }, handleSubmit() { this.$emit(filter-change, this.getFilterData()); }, getFilterData() { return { brand: this.brandValue, minPrice: this.priceRange.minPrice, maxPrice: this.priceRange.maxPrice }; } } }; /scriptFilterPanel的事件设计上filter-change负责传递筛选数据filter-reset负责通知父组件重置动作。两个事件分工明确父组件可以按自己的业务逻辑来决定监听哪些事件。7.4 父组件最终接收参数的完整闭环父组件FilterPage的完整逻辑template div classfilter-page FilterPanel filter-changehandleFilterChange filter-resethandleFilterReset / div classproduct-list div v-forproduct in filteredProducts :keyproduct.id {{ product.name }} - {{ product.price }} /div /div /div /template script import FilterPanel from ./components/FilterPanel.vue; export default { name: FilterPage, components: { FilterPanel }, data() { return { allProducts: [], filteredProducts: [], currentFilter: { brand: , minPrice: , maxPrice: } }; }, mounted() { this.loadProducts(); }, methods: { async loadProducts() { // 模拟接口请求 this.allProducts [ { id: 1, name: 商品A, brand: brandA, price: 100 }, { id: 2, name: 商品B, brand: brandB, price: 200 }, { id: 3, name: 商品C, brand: brandA, price: 300 } ]; this.applyFilter(); }, handleFilterChange(filterData) { this.currentFilter { ...filterData }; this.applyFilter(); }, handleFilterReset() { this.currentFilter { brand: , minPrice: , maxPrice: }; this.applyFilter(); }, applyFilter() { const { brand, minPrice, maxPrice } this.currentFilter; this.filteredProducts this.allProducts.filter((product) { let isMatch true; if (brand product.brand ! brand) { isMatch false; } if (minPrice ! product.price Number(minPrice)) { isMatch false; } if (maxPrice ! product.price Number(maxPrice)) { isMatch false; } return isMatch; }); } } }; /script这个案例完整覆盖了孙子组件通过事件传给子组件、子组件整合数据后通过事件传给父组件、父组件监听事件并更新自己的数据、父组件通过props再把重置后的数据传回给子组件。如果把这个项目从Vue2迁移到Vue3$emit的用法大体不变但事件名写法上Vue3的$emit(update:modelValue)替代了Vue2的$emit(input)另外Vue3的setup语法里需要从setup函数的上下文context中取出emit来调用这些差异是升级时要特别关注的。我个人在实际操作中的体会是事件传递作为Vue2组件通信的基石光会写$emit还不够把事件命名规范建立起来把事件触发的时机和生命周期对齐把引用传递的边界控制好这套东西才算真正掌握了。遇到复杂业务先把组件层级画清楚再确定事件流向最后写代码要比边写边想稳妥得多。而且这个过程建立起来的事件设计习惯后面切到Vue3或者其他框架也不会浪费通信的本质思路是互通的变的只是写法和API表面而已。
返回列表