ARTICLE DETAIL

资讯详情

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

Vue2组件通信解析:props、ref、$emit三件套实战指南

Vue2组件通信解析:props、ref、$emit三件套实战指南 1. 父传子props一套容易踩歪的数据通道先说个多数人第一次写Vue2的直观反应数据要往子组件里传就直接在子组件的props里写个名字收下然后模板里{{ 插值 }}就完事了。最基础的用法确实就是这样但一旦页面复杂起来props上最容易翻车的其实不是“不会写”而是“没搞清楚那条单向数据流到底意味着什么”。我在做订单管理后台的时候遇到过这么一次事故一个商品编辑弹窗父组件把当前行的商品对象整个通过props丢给了子组件子组件出于“省事”直接在编辑器里改了this.product.name结果列表页的商品名称跟着一起变了浏览器控制台还不断报警告“Avoid mutating a prop directly”。这个报错不是Vue2故意找茬它是在提醒你props是父组件数据的“复印件引用”你直接改它等于绕过了父组件的数据源让页面状态变得无法追踪。正确的做法是老老实实在子组件内部维护一份本地数据然后通过后续要讲的$emit把结果交还父组件。1.1 静态传值与动态绑定的本质区别props的写法有两种很多入门教程都是一笔带过但它们的语义完全不一样!-- 静态传值字符串字面量不参与响应式 -- child-component title固定标题/child-component !-- 动态绑定表达式或变量参与响应式更新 -- child-component :titledynamicTitle/child-component第一种写法传入的永远是字符串“固定标题”不会跟随父组件里同名变量的变化而更新第二种才是数据驱动的完整姿态。正则表达式、布尔值、数字、数组、对象一律得走冒号绑定。比如你写activefalse子组件拿到的其实是字符串false而active按布尔判断时会得到一个真值这个坑我曾经在一段权限判断里踩了两天。props本身还支持类型声明项目一大人一多这玩意儿就是你的第一道防线props: { product: { type: Object, required: true }, price: { type: Number, default: 0 }, hasCoupon: { type: Boolean, default: false }, status: { type: String, validator: value [active, disabled, pending].includes(value) } }validator是很多人容易忽视的能力。它比普通的if判断早得多子组件在接收非法入参时直接中断渲染错误信息也比“某属性undefined”这种报错友好得多。1.2 单向数据流为什么不能动“别人的奶酪”Vue2的官方设计是以单向数据流为铁律父组件通过props把数据向下传递子组件绝对不能直接修改props上的属性。为什么这么严因为假如子组件可以随便改props那多个子组件共享同一个父级对象时A子组件改了一个字段B子组件莫名其妙被联动所有组件各自的局部状态全部失去确定性调试起来就是个无底洞。但很多人会忽略一个特例引用类型对象、数组的props你不能“重新赋值”它但可以修改它内部的属性。比如// 不合法直接替换prop引用 this.product newProduct // 合法但危险修改内部字段 this.product.name 新名字第二种写法Vue2默认不会报错甚至会正常触发响应式更新但这就是开头说的那种事故现场。正确姿势是让子组件把自己那份数据存到data里或者用计算属性派生data() { return { localProduct: { ...this.product } } }注意用浅拷贝只对第一层安全如果对象里还嵌套着对象还得配合深层拷贝或者递归处理。我自己在业务里更习惯的做法是子组件不复制整个对象而是只复制需要编辑的字段保存时统一回传保持单一来源。1.3 异步入参子组件“首次读取”的陷阱props的响应式特性和子组件的初始化之间有非常经典的时序问题。假如父组件的productData一开始是null异步请求完成后才变成真实对象你可能会在子组件的mounted钩子里这样取初始值mounted() { this.form { name: this.productData.name, price: this.productData.price } }此时productData还是null拿到undefined直接报错即便不报错你也会得到一个永远都为空的表单。这个问题的根因是mounted只执行一次而且执行得很早。解决方案通常有两种用watch监听props变化后再初始化本地数据watch: { productData: { handler(newVal) { if (newVal) { this.form { name: newVal.name, price: newVal.price } } }, immediate: true } }父组件保证“数据就绪后再渲染子组件”用v-if控制div v-ifproductData child-component :productproductData/child-component /div第二种方案更彻底子组件从一开始拿到的就是完整数据。搭配上loading状态展示体验也更好。我个人的经验是能用v-if从源头避免“空数据初始化”就别让子组件内部做一堆防御性判断代码读起来清爽得多。2. 父组件调用子组件ref的时机和边界props解决了“数据往下传”但业务里经常需要的还有“从上层主动触发子组件的某个动作”。最典型的例子是编辑弹窗父组件点击“编辑”按钮得告诉弹窗组件“你打开吧把这些数据填进去”。这种“父到子的命令下发”在Vue2里最直接的手段就是ref。ref绑定在子组件上时拿到的不是DOM元素而是整个子组件的实例。你可以直接调用子组件的方法、读取子组件的datatemplate edit-modal refeditModal/edit-modal /template script export default { methods: { handleEdit(row) { this.$refs.editModal.open(row) } } } /script不过命令式的调用方式入口太自由了用着用着就容易踩到一些现世报下面两个问题是我见过出现频率最高的。2.1 别在created里拿ref挂载完成的硬性约束ref只有在子组件完成挂载后才会被注册。Vue2生命周期执行顺序是这样的父组件created- 父组件模板编译 - 子组件created- 子组件mounted- 父组件mounted。如果你在父组件的created钩子里写this.$refs.editModal.open()此时子组件实例还没挂载出来this.$refs.editModal是undefined代码直接当场崩掉。正确时机是在mounted里调用或者等异步渲染完成用this.$nextTick(() { ... })包一层mounted() { // 子组件此时已挂载可以安全访问 this.$refs.editModal.setTitle(初始化标题) }还有一种更隐蔽的情况子组件挂在v-if条件里。v-if为false时组件根本不存在ref自然拿不到等条件翻转后也不能立刻在同一个同步代码块里访问必须等Vue完成DOM更新同样要用$nextTick。理解了这一层很多“明明ref名没错但就是undefined”的问题都能瞬间破案。2.2 v-for里的ref一个名字多个实例在列表场景里你可能会循环渲染一个子组件并给每个实例都加上同一个ref。Vue2的规则是v-for内的ref会得到一个包含所有匹配实例的数组这个数组的顺序和循环顺序一致。template div todo-item v-for(item, index) in todoList :keyitem.id :reftodo${index} /todo-item /div /template更推荐的方式是直接传item.id之类有业务含义的名字然后通过this.$refs[todo id]来取。这样即使列表重新排序你也能准确锁定目标实例。还有个细节如果v-for外面单独绑了ref那外层拿到的就只是第一个子组件实例这点也最容易看走眼。2.3 调用子组件方法时的数据同步时机通过ref调用子组件的方法时传递的数据虽然是响应式的但子组件内部如果需要基于传参做进一步的计算要注意“改数据”和“读取数据”之间可能还隔着一个DOM更新的间隙。举个例子你调用子组件的open(row)子组件内部把row赋值给了this.currentRow紧接着子组件模板里某个依赖于currentRow的图表组件要用新数据重新渲染。通过ref调用后Vue不会在同一个事件循环里立刻把DOM刷完你要是紧接着再读取某个由currentRow派生出来的DOM尺寸拿到的往往是旧值。这种情况合理的方式是把绘图逻辑放在watch里处理或者对依赖新数据的代码套一层$nextTick。3. 子传父$emit如何把“发生的事”递交给上层如果说props是瀑布从山顶往下流那$emit就是喊话器站在山脚的子组件把一句“我这边有状况”喊给山上的父组件。业务场景非常普遍表单组件校验完数据要提交、文件选择组件选完要回传列表、分页器切换页码要通知列表刷新。Vue2给的标准方法是this.$emit(事件名, 参数)父组件用事件名监听。!-- 子组件内 -- button click$emit(save, formData)保存/button !-- 父组件内 -- child-component savehandleSave/child-component子组件通过props被动接收“现在是什么”通过$emit主动上报“接下来发生了什么”这个一来一往就是Vue2组件间协作的基本骨架。3.1 事件名大小写一个特容易阴沟翻船的细节Vue2官方文档提示事件名推荐使用kebab-case短横线命名因为模板里的DOM事件监听对大小写不敏感。如果子组件里$emit(myEvent)模板中写成myEvent还能生效但如果模板里写my-event在模板编译的时候事件名会被转成小写就接不上myEvent了。我的建议是统一约定事件名一律用kebab-case比如save-order、update-status避免在“哪个字母大写了”这种事上浪费十分钟。签约了ESLint的话也可以顺手配一个vue/custom-event-name-casing规则把这个约定固化下来。3.2 $event和多个参数把payload组织好父组件监听事件后如果在模板里直接写saveform $event那$event代表的就是子组件传来的第一个参数。要是你想传多个数据呢可以连传几份this.$emit(update, newData, pageIndex, total)模板上取的时候只有第一个参数能直接用$event拿到后面几个要么再包一层handler要么干脆约定多个参数时统一打包成对象。this.$emit(update, { data: newData, pageIndex, total })后者读起来清晰调试也方便还能避免顺序匹配错位的问题。我在团队里定了一个不成文的规矩凡是子组件一次要向父组件传递三个以上关联数据的一律合成一个对象事件签名“尽量短够明确”。3.3 v-model和.sync事件机制的高度封装v-model的本质其实是props加$emit的语法糖。在Vue2里组件上的v-model默认展开成props: valueemit: input!-- 等价写法 -- child-component :valuevalue inputvalue $event /如果子组件内部这样配合this.$emit(input, newValue)那父组件的value变量就自动被同步了。表单类组件这么干非常省事。类似地.sync修饰符展开成props: 属性名emit: update:属性名child-component :visible.syncdialogVisible / !-- 等价于 -- child-component :visibledialogVisible update:visibledialogVisible $event /弹窗的显隐状态就是.sync的经典使用场景。子组件内部只需要this.$emit(update:visible, false)就能让父组件的dialogVisible自行变成false父组件不需要额外写方法。这个封装的精髓在于命令从子组件来但数据的所有权始终放在父组件手里数据流依然清晰。4. 完整链路一个“列表编辑弹窗”的需求怎么串联三兄弟讲到这里props、ref、$emit各自的能力都过了一遍但真正让它们融会贯通的是一个组合场景。我用我做过的订单后台举例这个需求大概率你也会在项目里遇到列表页是父组件每一行有“编辑”按钮点击后弹出编辑弹窗弹窗内部维护表单点保存后要通知列表更新数据并关闭弹窗。三个角色分工如下父组件管理订单列表数据、弹窗显隐、接收子组件保存后的新数据更新列表子组件通过props接收当前编辑行的初始数据、维护临时表单副本、通过$emit把“保存完成”的事件和表单内容传给父组件ref充当父组件主动打开子组件弹窗、还能顺带把当前行数据塞进去的“操控杆”4.1 设计思路为什么直接改子组件的data是灾难有一种想省事的做法会让人很动心父组件通过ref直接拿到子组件实例然后this.$refs.editModal.isShow true; this.$refs.editModal.form row。一次调用所有状态都不用传。不推荐这么干的理由很实际状态分散。父组件为了操作子组件内部的form得知道子组件内部具体有哪几个字段、叫什么名字一旦子组件内部分工调整父组件这边的代码也要跟着改两个组件就这么焊死了。ref负责“下发动作指令”不适合“搬运业务数据”。数据应该走props和$emit的声明式通道方法调用留给ref做这个边界划清楚之后辅助类代码会好维护很多。4.2 落地代码父子两侧各写什么先看子组件的核心逻辑template div v-ifvisible classmodal el-form :modelform el-form-item label订单名称 el-input v-modelform.name/el-input /el-form-item el-form-item label订单金额 el-input v-modelform.amount/el-input /el-form-item /el-form el-button clickhandleCancel取消/el-button el-button typeprimary clickhandleSubmit保存/el-button /div /template script export default { name: EditModal, props: { // 父组件传入当前行的完整数据 currentRow: { type: Object, default: null } }, data() { return { visible: false, form: { name: , amount: 0 } } }, watch: { currentRow: { handler(newVal) { if (newVal) { this.form { name: newVal.name, amount: newVal.amount } } }, immediate: true } }, methods: { // 供父组件通过ref调用的“打开弹窗”方法 open() { this.visible true }, close() { this.visible false }, handleCancel() { this.close() }, handleSubmit() { // 把内部维护好的表单副本交给父组件 this.$emit(save, this.form) this.close() } } } /script再看父组件侧template div el-table :dataorderList click... el-table-column label操作 template slot-scope{ row } el-button clickhandleEdit(row)编辑/el-button /template /el-table-column /el-table edit-modal refeditModal :current-rowcurrentEditingRow savehandleSave /edit-modal /div /template script import EditModal from ./EditModal.vue export default { components: { EditModal }, data() { return { orderList: [], currentEditingRow: null } }, methods: { handleEdit(row) { // 先保存当前行再调用子组件方法打开弹窗 this.currentEditingRow row this.$nextTick(() { this.$refs.editModal.open() }) }, handleSave(newOrder) { // 用子组件回传的数据更新列表 const index this.orderList.findIndex(item item.id this.currentEditingRow.id) if (index ! -1) { this.$set(this.orderList, index, { ...this.orderList[index], ...newOrder }) } } } } /script注意我在handleEdit里处理当前行之后特意包了一层$nextTick再执行open()。原因是currentEditingRow刚刚被赋值要等Vue把props更新到子组件那边再让弹窗打开才能保证弹窗里拿到的表单数据是最新的。4.3 时序分析什么时机能做什么事这个例子里整个执行顺序值得摊开讲一遍理解了它就理解了props、ref、$emit三者如何协同用户点击编辑按钮父组件handleEdit触发currentEditingRow被赋上新值父组件DOM更新排队$nextTick回调排队$nextTick执行此时子组件的props.currentRow已经拿到新值open()被调用弹窗显示子组件内部watch currentRow触发如果值变了form被重置为当前行数据用户修改表单点保存子组件$emit(save, form)父组件handleSave被调用接收新数据$set更新列表这里有一个极端情况currentEditingRow赋值时如果和上一次的值完全一样比如连续编辑两行相同ID的数据子组件的watch不会触发form还是上一行的值。如果业务上存在这种需求可以在handleEdit里手动给子组件传一个刷新信号或者直接在open()调用时让父组件把当前行数据作为一个参数传进去由子组件内部统一处理。5. 跨层级与状态升级页面复杂之后通信怎么稳住到了一定规模你会发现父子直连已经开始吃力。业务里存在“爷孙”组件、兄弟组件、甚至整个页面级别需要共享登录信息的情况。Vue2本身还有几套配套方案在不同的复杂度阶段各自有最优解。5.1 reduce中间层$attrs和$listeners解决了什么三层嵌套时中间层要做很多“二传手”的活把爷爷传下来的props原封不动传给孙子把孙子$emit的事件原样转发给爷爷。时间一长中间层全是转发代码。Vue2.4之后提供了$attrs未被子组件声明为props的属性和$listeners被监听的事件。在中间层写template div !-- 把未消费的props和事件一次性透传给孙子组件 -- grand-child v-bind$attrs v-on$listeners/grand-child /div /template这样中间层只关心自己的业务不属于自己的数据通路可以透明透传。注意父组件的响铃事件即使没有在中间层声明也会被$listeners捕获并继续下传。这是降低“中继组件”负担很实用的技巧。5.2 事件总线与Vuex的阈值判断组件关系比较复杂、但不至于全局需要共享复杂状态时可以用一个简单的“事件总线”把通信方解耦// 全局创建一个总线 export const bus new Vue() // A组件发布 bus.$emit(order-created, payload) // B组件订阅 bus.$on(order-created, this.handleOrderCreated)事件总线用起来很爽但坑也明显事件名是魔法字符串组件销毁后如果忘了$off取消订阅会造成内存泄漏还可能重复触达。我一般在中小型非核心场景使用一旦状态开始牵扯多个页面模块那就不折腾总线了直接上Vuex。Vuex的价值不在于“能传数据”而在于每个数据的变化都有迹可循配合devtools可以回放整个状态变化过程。等你的项目里出现了“某个数据到底在哪被改了不好查”的顿悟时刻再上Vuex也不迟。5.3 从Vue2到Vue3通信底层思路没变变的只是写法和“成熟项目Vue2能否转Vue3”这个问题相关的是props和$emit的核心机制在Vue3里依旧保留只是写法上组合式API更灵活。比如Vue3里可以用defineProps和defineEmits组合式代码天然把相关的状态、动作聚在一起const props defineProps({ product: Object }) const emit defineEmits([save]) function handleSave() { emit(save, props.product) }平时学习的时候不用被“转Vue3”焦虑裹挟。先把props、ref、$emit这套数据流的骨架练成肌肉记忆升级框架时真正迁移的是写法、是生命周期钩子位置、是响应式API的用法而“父传子、子传父、父调子”的思维方式在所有组件化框架里都一模一样。这就像开车从手动挡换自动挡你掌握的依然是“方向盘往哪打、油门刹车在哪”变的只是脚下少了一个离合器踏板。通信思路同理。在hbuilderx里跑Vue2项目、维护老代码、甚至要切换到video.js这类播放器组件去和其它组件交接状态的时候先把这三件套的时序和数据归属想清楚后面任何花哨的功能都不会偏。
返回列表