
如果你维护过稍微有点规模的中后台项目八成在弹窗、表单、抽屉这类组件里写过类似代码父组件传一个visible下去子组件要自己关闭时再发一个事件让父组件把值改回false。业务一多模板里到处是update:xxxxxx $event看着就烦。Vue 子父组件里的.sync就是专门收拾这种重复劳动的。这篇不打算从官方文档抄一遍概念而是把子父组件通信里.sync那点底细拆开讲透它到底是什么、为什么会出现、跟v-model的边界在哪里、有哪些专坑老手的细节以及 Vue 3 时代怎么迁移。适合刚学组件通信的同学也适合写了两三年业务、但一直把.sync当成压缩语法在用的中高级前端。1. 子父组件通信里 .sync 到底解决的是哪一类痛1.1 单向数据流有多重要实现起来就有多繁琐Vue 的组件通信金科玉律是props 向下传数据events 向上发事件。官方这样设计是为了保证数据流向可预测。没有单向数据流的灾难我不止见过一次组件树稍微变复杂后如果每个子组件都能直接改父组件的数据A 改了 B 不知道B 改了 C 看到的是旧值最后线上出问题时你根本不知道该盯住哪一块代码去排查。单向流动的核心逻辑很简单——数据从哪来修改权就归谁子组件只是借用了这个数据。但这个原则放到真实业务里会带来大量的机械劳动。举个最典型的场景父组件控制一个弹窗。template div button clickdialogVisible true打开弹窗/button Dialog :visibledialogVisible closedialogVisible false / /div /template子组件里面只要想关闭弹窗就得this.$emit(close)一下。一个visible属性就占了两行模板如果弹窗里还有 loading 状态、选中 id、表单回显数据父组件模板会堆满各种监听事件读起来要多难受有多难受。1.2 没有 .sync 的那些年大家是怎么模拟双向绑定的我记得维护老项目时见过三种土办法效果都不太理想。第一种是自由定义动词事件。有人用close有人用changeVisible有人用visibleChange。团队里没有统一规范时你用一次子组件就得去翻一遍源码确认它到底发的是哪个事件。命名全靠自觉最后总是有人对不上。第二种是干脆把对象传进去子组件内部直接改字段。这在 Vue 2 里确实能生效父组件数据会被改掉。但你完全失去了对变更的控制没有警告、没有审计点唯一的优势是省事。一旦出 bug定位成本直线上升。第三种是上状态管理工具。一个弹窗开关而已引入 Vuex、全局事件总线属于杀鸡用牛刀。全局状态越多项目越难维护局部问题被迫全局处理很不划算。1.3 一句话回答它为什么正好长这样.sync做的事是把prop 名和事件名做成一 一对应的协议visible的更新事件必须是update:visible。父组件模板里写完:visible.syncdialogVisible编译器会自动把传值和监听事件一次搞定。子组件作者不用再想动词父组件使用者也不用再去记事件名照着update:prop名的规则走就行。我经常跟同事说.sync不是 Vue 给你开的偷懒后门它是一套标准化的传值 回传协议。理解到这一层后面的坑都能避开。2. 手写一遍语法糖的展开过程你就不会再懵了2.1 先看最朴素的父亲组件写法子组件内部触发更新// 子组件 this.$emit(update:visible, false)父组件监听Dialog :visibledialogVisible update:visibledialogVisible $event /这样写有没有问题没有任何问题逻辑完全清晰。update:前缀加 prop 名一看就知道是在同步哪个属性。但问题就是烦。十个组件都这样写模板里到处是重复的赋值语句而且每次写update:visible都要确保事件名没拼错纯体力活。2.2 用 .sync 收编Dialog :visible.syncdialogVisible /这一行模板本质上就是上面两行绑定的合并。编译器会把.sync展开成:visible加update:visible的组合把你的手写量降为零。2.3 展开后到底长什么样如果你好奇编译器做了什么可以想象成它生成了这样一段渲染逻辑render(h) { return h(Dialog, { props: { visible: this.dialogVisible }, on: { update:visible: value { this.dialogVisible value } } }) }看到关键点没有.sync并没有让子组件获得直接改父组件 prop的能力。它只是在编译期替你补上了事件监听器真正去改父组件数据的还是那句this.dialogVisible value。数据更新的最终决策权依然在父组件手里。这里建议存个印象.sync是语法糖而不是魔法。它能帮你少写模板但没有改变 Vue 单向数据流的本质规则。2.4 它其实是 Vue 1.x 时代的回归很多人不知道Vue 1.x 里的.sync是真正的双向绑定子组件可以直接写父组件 prop。官方后来发现这种方式在复杂组件树里状态很难追踪就在 2.0 里把它移除了。但业务方确实需要一种省事又安全的写法于是 Vue 2.3.0 又把.sync带回来了只不过这次换成了事件驱动模式——子组件不再直接改 prop而是通过update:xxx事件通知父组件改。这算是一种妥协用法上接近双向绑定底层仍然守住了单向数据流。了解这段历史你能更好地理解它和v-model的关系。3. 实战封装用 .sync 做一个弹窗和一串可同步的属性3.1 父组件端最舒服的用法考虑一个真实业务新增用户弹窗。弹窗开关、保存 loading、当前选中用户 id三个状态都需要子父同步。template div classpage el-button clickopen true新增用户/el-button UserDialog :visible.syncopen :saving.syncsaving :user-id.synccurrentUserId / /div /template script export default { data() { return { open: false, saving: false, currentUserId: null } } } /script很多从 Vue 2 走过来的同学第一次看到这种连续三个.sync会有点慌这不会导致数据乱掉吗不会。visible、saving、userId是三条完全独立的同步链路互不干扰每条链路的修改权都还在父组件自己手上。3.2 子组件内部怎么承接子组件不能直接把this.visible false所以得用一个本地可写的桥接值。我用的最多的是 computed getter/setter 方案。export default { name: UserDialog, props: { visible: Boolean, saving: Boolean, userId: [Number, String] }, emits: [update:visible, update:saving, update:userId], computed: { localSaving: { get() { return this.saving }, set(val) { this.$emit(update:saving, val) } } }, methods: { closeDialog() { this.$emit(update:visible, false) } } }模板里有本地桥接值的属性直接用v-modellocalSaving这种常规绑定没有桥接值的属性就手动触发事件。template el-dialog :visible.syncvisible :titletitle UserForm :user-iduserId :loadinglocalSaving / template #footer el-button clickcloseDialog取消/el-button el-button :loadinglocalSaving clicksave保存/el-button /template /el-dialog /template其实 Element UI 的el-dialog自己就在用:visible.sync你每天都在享受这种协议带来的便利。它内部点击遮罩、点击关闭按钮时本质上就是执行了$emit(update:visible, false)。3.3 封装自己的同步 prop 五步法把上面的过程提炼一下以后任何组件要做同步属性都可以按这五步走父组件传值写作:属性名.sync变量。子组件 props 里正常声明这个属性。子组件 emits 里声明update:属性名。内部需要改值时调用$emit(update:属性名, 新值)。如果要在内部用 v-model 或双向控件绑定加一层 computed 的 get/set。3.4 为什么 v-model 做不到一次挂多个Vue 2 里一个组件的v-model默认只能绑定一个value加一个input事件。虽然可以通过model选项换事件名但一个组件始终只有一个v-model。.sync没有这个限制因为每条update:xxx都是独立事件你想挂几个就挂几个。这也是它在表单业务里特别顺手的原因。4. 子组件内部怎么改 prop 才不会翻车4.1 直接改 prop 的下场先看错误写法this.visible false在 Vue 2 里这样写组件会发出警告Avoid mutating a prop directly since the value will be overwritten. 在 Vue 3 里更干脆子组件改了 prop 根本不会同步回父组件实测下来就是改了也白改。原因其实很好理解props 是父组件通过 render 函数传给子组件的子组件内部直接改它的值两条数据链路会打架。父组件的响应式系统只知道我传了一个值并不知道这个值被子组件私自改过。数据流一旦不可预测调试就像看一团浆糊。4.2 正确的桥接姿势一computed 的 get/set这是最推荐的通用方案。computed: { localVisible: { get() { return this.visible }, set(val) { this.$emit(update:visible, val) } } }模板里就可以堂而皇之地写input v-modellocalVisible /get保证 UI 永远跟父组件传下来的 prop 保持一致set负责把新值通过update:visible事件传回父组件。父组件更新后新值又从get流回来形成一个闭环。这个闭环保住了单向数据流的底线子组件没有直接改 props它只是把用户想改数据这个请求发给了父组件改不改、改成什么由父组件决定。4.3 正确的桥接姿势二事件回调如果只是简单场景不需要桥接一个完整变量直接在模板里写事件回调就行input :valueuserId input$emit(update:userId, $event.target.value) /这种写法最简单适合改一个值没有太多附加逻辑的场景。一旦涉及计算、校验、联动就老老实实用 computed 桥接把逻辑放进 setter 里集中处理。4.4 专门提醒一下对象 prop对象类型的 prop 有个隐蔽风险。假设父组件传了:form.syncform子组件里写了this.form.name new name这句不会触发update:form事件但父组件的form.name已经被偷偷改掉了。可怕吗可怕。父组件没有机会做校验、拦截、审计连是谁改的都要靠猜。我踩过这个坑之后给自己定了一条规矩对象类型的同步不要改字段要派发新对象。this.$emit(update:form, { ...this.form, name: new name })每次修改都以新对象的形式整体回传父组件收到的永远是完整的数据快照。成本只是很小的浅拷贝换来的是数据流的完全透明。5. .sync 与 v-model一对容易混的双胞胎5.1 v-model 的真实面目不卖关子v-model也是一个语法糖。默认情况下input v-modelmsg /等价于input :valuemsg inputmsg $event.target.value /注意它的模式是固定的value属性加input事件。用在自定义组件上也是一样子组件要声明value这个 prop并且在交互时发出input事件。而.sync的模式是任意属性名加update:任意属性名事件。你可以用它同步visible、title、loading什么属性都行。两者表面上很像但服务场景并不完全重叠。5.2 一张表看清边界维度v-model.sync默认语法糖:valueinput:prop名update:prop名Vue 2 一次能绑定几个1 个多个自定义事件名可通过 model 选项修改事件名由 prop 名决定典型场景表单控件、输入组件任意属性同步、弹窗显隐子组件内部写法依赖 value input依赖 update:xxx 协议Vue 3 状态支持多参数不推荐继续用5.3 实际选型经验我的经验是看语义如果这个属性代表组件本身的值比如输入框的内容、选择器的选中项用v-model因为它的语义就是值变化如果这个属性只是组件控制项之一比如弹窗开没开、保存按钮转不转圈用.sync因为它能处理多个互不相关的状态。还有一个很容易让人纠结的点Vue 2 里如果一个组件既想用v-model又想同步第二个属性怎么办做法就是v-model加.sync混用UserForm v-modelformData :loading.syncloading /这在 Vue 2 项目里非常常见也是官方允许的组合方式。一个负责主要值一个负责附加状态各司其职。6. 我踩过的 .sync 五个坑6.1 事件名大小写不一致导致同步失效这是最常见、也最隐蔽的坑。假设 props 里声明了fatherName子组件$emit(update:fatherName, newVal)。如果父组件模板里写的是Child :father-name.syncname /编译时会生成update:father-name的监听但子组件发的是update:fatherName两边对不上值改了页面不动。这类 bug 不会报错只表现为子组件好像没生效。我的建议是一个项目里统一风格。如果 props 用驼峰fatherName模板和$emit全部保持一致都用fatherName如果用 kebab-casefather-name子组件$emit也必须发update:father-name。最怕的就是三个位置各写各的。6.2 .sync 不能跟表达式一起用官方文档里有一句容易被忽略带.sync修饰符的v-bind不能和表达式一起使用。!-- 错误 -- Child :visible.syncisShow || true / !-- 正确 -- Child :visible.syncisShow /原因很好理解.sync展开后要往这个变量的位置赋值。如果右侧是一个表达式赋值给谁编译器没有明确的落点。所以它只能绑定一个可以被赋值的标识符。6.3 自定义事件和原生事件容易混你在组件标签上写input监听的是子组件通过$emit(input)抛出的自定义事件不是 DOM 原生 input 事件。.sync协议中的update:xxx同理它是子组件自定义命名空间跟原生事件没有关系。如果你在子组件根节点上还绑定了一个同名的原生事件监听两者会互相干扰。遇到这种情况先分清这个事件是在组件标签上监听还是在子组件模板内部的某个 DOM 元素上监听。6.4 别把 .sync 用在 Vuex 状态上有一种写法我看到过不少次Child :visible.sync$store.state.dialogVisible /这会让数据流变得非常混乱。.sync会直接给$store.state.dialogVisible赋值等于绕过了 mutationVuex 的调试工具看不到任何状态变更记录。出问题时你会非常痛苦因为完全不知道这个值是什么时候、被谁改掉的。正确的做法是组件局部状态用.sync需要提升到全局状态时老老实实通过 mutation / action 更新不要在 Vuex 状态上套.sync。6.5 过度使用会让数据流变成面条.sync用起来确实爽但滥用会带来反效果。最典型的场景是三层以上组件嵌套父传子用.sync子再传给孙组件又用.sync中间的组件既是被同步方又是同步方整个链路几乎不可读。遇到这种情况先停下来想一想这个数据真的需要每一层都同步吗如果只是展示下游组件用普通 prop 就够了如果只是事件上游用普通事件就够了。只有当前层直接持有状态并需要被修改回流时.sync才有价值。跨层共享优先用状态库或 provide/inject别让.sync成为第二套全局状态系统。7. Vue 3 的手感多参数 v-model 与 defineModel7.1 v-model 参数化才是 Vue 3 的官方答案Vue 3 里v-model被大幅增强可以在一个组件上写多个 v-model每个都可以自定义属性名UserDialog v-model:visibleopen v-model:savingsaving v-model:userIdcurrentUserId /是不是看得很眼熟它和.sync的写法几乎是同一个思路。事实上v-model:属性名内部展开成的事件就是update:属性名。这就是为什么很多团队迁移到 Vue 3 后把.sync换成了这种写法子组件端几乎不用动。Vue 3 的官方建议很明确新写法优先使用v-model:xxx.sync语法虽然还能跑但不是推荐姿势了。父组件端的一行替换是基本无感的!-- 老写法 -- UserDialog :visible.syncopen / !-- 新写法 -- UserDialog v-model:visibleopen /7.2 Vue 3.4 的 defineModel 让子组件更省事Vue 3.4 以后子组件可以用defineModel直接声明一个可写 ref连手动 emit 都可以省掉!-- 子组件 -- script setup const visible defineModel(visible) /script template el-dialog v-modelvisible title用户管理 button clickvisible false关闭/button /el-dialog /templatedefineModel(visible)会同时声明visibleprop 和update:visible事件返回的visible是一个 ref可以直接读、直接写。赋值visible false时Vue 会自动帮你向父组件触发update:visible原理依然是那套事件协议。对团队里的老手来说这套 API 最大的价值是完全不改父组件的写法子组件少写一堆 props、emits、computed 桥接。7.3 迁移时的心态调整如果你还在维护老项目不需要急着把.sync全部改掉。它在 Vue 2 里是稳定方案改一次风险不小。但新写的代码我强烈建议直接拥抱 Vue 3 的v-model:xxx和defineModel。你一旦接受这个观念不管写法怎么变底层永远是update:xxx这套协议迁移就会变得非常平滑。拿我自己的项目举例祖传代码里一堆.sync新代码全部用的v-model:xxxdefineModel。两边混着用唯一要守住的标准就是子组件发出的update:xxx事件名绝不乱写。只要这个协议是稳的以后回头做统一重构替换成本就低得多。最后再分享一个小经验如果你在 code review 里看到有人问为什么这里用.sync不用v-model不要直接回答因为 v-model 只能绑一个。正确的解释是Vue 2 的v-model默认语义是组件主要值而.sync是任意属性同步协议。先让团队里每个人都清楚这套展开原理后面拆代码、写封装、做迁移大家才能用同一套语言对话。