前阵子在改一个老项目要封一个省市区的级联选择组件同时还要支持用户手动输入门牌号。父组件那边要把选中值拿去做筛选和提交我一开始图省事直接在子组件里给父组件传进来的 prop 赋值。然后控制台就给我上了一课满屏的 Avoid mutating a prop directly数据倒是同步了但整个数据流彻底乱套改起来连自己都看不懂。后来我才认真把 Vue2 的 v-model 从头到尾梳理了一遍亲手把父子组件双向绑定在代码里重写了一遍。回头看这个看似基础的机制其实是整个 Vue 组件通信体系里最值得吃透的一块。这篇文章我就把自己手搓 v-model 的过程、源码层面的编译逻辑、以及各种现实中的坑全部记录下来希望能让正在写 Vue2 组件的朋友少走弯路。1. 先撕开 v-model 的伪装它只是一个语法糖很多同学刚接触 Vue 时会以为 v-model 是什么魔法写一行代码数据就能两边同步。其实它根本不是什么魔法本质上是属性绑定 事件监听的组合只是 Vue 在编译模板的时候帮你把这两件事自动拼好了。1.1 原生 input 上的 v-model 编译后长什么样先看最普通的场景一个搜索框input v-modelsearchText /这段代码在 Vue2 模板编译阶段会被改写成大致这样input :valuesearchText inputsearchText $event.target.value /searchText作为 value 传给 input 元素展示当前值用户输入时input 事件触发$event.target.value取到输入框里的新文本再写回searchText。数据一变value 也跟着变页面重新渲染。这就是一个单向数据流的闭环数据流向视图视图事件再把新数据流回数据源。v-model 就是帮你少写这两行绑定代码。为什么 Vue 默认的监听事件是input而不是change因为input在键盘输入过程中就会持续触发同步节奏快输入一个字符页面就立刻更新change要等到失焦或者回车才会触发。这个选择保证了 v-model 的实时性。注意上面说的是 Vue2 原生 input 元素的编译结果。$event在这里是 DOM 事件对象所以取的是$event.target.value。但到了自定义组件上$event的含义会变成你 emit 出来的那个值不是 DOM 对象。这是新手最容易搞混的一个点后面的章节会专门讲。1.2 表单元素之间的差异v-model 不是只有一种口味如果你以为 v-model 在所有元素上都是value input的组合那就掉进坑里了。Vue2 对不同表单元素做了差异化处理元素类型绑定的属性监听的事件写回的值input[typetext] / textareavalueinput$event.target.valueinput[typecheckbox]checkedchange$event.target.checkedinput[typeradio]checkedchange$event.target.value选中项的值selectvaluechange$event.target.value所以 checkbox 绑定的是checked属性监听的是change事件写回的是$event.target.checked。这些细节平时被 v-model 盖住了一旦你手动去封装自定义组件就会意识到 v-model 的默认口味只是针对普通输入控件的。自定义组件上的 v-model默认值是valueprop、默认事件是input但完全可以通过model选项改成别的组合。这个后面第 4 章专门讲。2. 从单向数据流到双向通信props 和事件的配合要理解 v-model 在父子组件中的作用得先搞清楚一个前提Vue 默认的组件数据流是单向的。2.1 为什么 Vue 要搞单向数据流父组件通过 props 把数据传给子组件子组件不能直接改这个 props只能向父组件发一个通知让父组件自己去改数据。这样做的最大好处是状态变化可追踪。打个比方你把银行卡借给朋友让他帮忙取钱朋友不能直接拿你的卡去改密码他得打电话告诉你我取了一笔钱你确认后自己去处理账目。如果每个人都随手改密码最后你连自己的卡都刷不了。单向数据流保证任何一笔状态变更都能顺着事件向上追溯到源头调试起来容易得多。2.2 从零开始手写一个最简双向绑定先绕开 v-model纯手写一套父子组件双向通信看看数据是怎么转一圈的。父组件template div classpage manual-input :valueaddress updateaddress $event / p当前地址{{ address }}/p /div /template script export default { data() { return { address: } } } /script子组件template input classmanual-input :valuevalue input$emit(update, $event.target.value) / /template script export default { name: ManualInput, props: { value: { type: String, default: } } } /script流程是这样的父组件把address通过:value传给子组件文本框显示当前值。用户输入input 事件触发子组件把输入框的新值通过$emit(update, newValue)广播出去。父组件监听到update把address更新为新值。数据一变:value再次把新值传回子组件界面刷新。一个完整环。这个过程里address这个数据的所有权始终在父组件手里子组件只是借用它来展示并向上报告应该改成什么。2.3 数据回传的三种常见折腾方式实际开发里想实现子组件数据回传常见就这么几种写法直接改 propsthis.value xxx。Vue 会很生气控制台直接告警。数据虽然表面上变了但父组件重新渲染时又会被覆盖纯粹给自己挖坑。自己取一个事件名emit 出去父组件手动监听。这就是上面 2.2 节的做法最靠谱但每次都要手动写:xxx和yyy字段多了代码很啰嗦。父组件传一个对象进来子组件直接改对象的属性this.obj.name yyy。因为对象是引用类型父组件的原数据确实变了但 Vue 的响应式更新在这里容易产生视图不刷新、watch 不触发的诡异现象非常不好排查。第三种方式尤其要警惕。表面上很方便实际上是绕过了 Vue 的数据流规则。等到数据出错你根本分不清是哪一层改了这个对象。后面第 6.2 节会详细讲这个坑。3. 手搓 v-model最朴素的父子组件双向绑定整个第 2 章相当于把 v-model 的下半身先搭好了现在我们要把它拼成完整的自动挡。3.1 子组件里必须同时具备接收和通知的能力自定义组件想要支持 v-model核心就两件事接收一个叫value的 prop并且向外抛一个叫input的事件。先做一个更实际的例子一个支持清空按钮的输入框组件。template div classclearable-input input :valueinnerValue inputhandleInput focusfocused true blurfocused false / button v-ifvalue focused classclear-btn mousedown.preventhandleClear 清除 /button /div /template script export default { name: ClearableInput, props: { value: { type: String, default: } }, data() { return { focused: false } }, computed: { innerValue: { get() { return this.value }, set(val) { this.$emit(input, val) } } }, methods: { handleInput(e) { this.innerValue e.target.value }, handleClear() { this.$emit(input, ) } } } /script这里用了一个非常有用的模式innerValue计算属性的 get 返回父组件传下来的valueset 的时候不直接改 props而是 emit 一个input事件。这样子组件内部模板可以直接用v-model风格的绑定父组件的数据还能保持唯一数据源的地位。补充mousedown.prevent是为了防止按钮触发 input 的失焦事件导致按钮先消失。这个细节你实际写清空按钮时肯定用得上。3.2 父亲写一行 v-model 搞定一切现在父组件里用法就极度舒服了clearable-input v-modelkeyword /这一行代码的编译结果等价于clearable-input :valuekeyword inputkeyword $event /注意在这个场景里$event已经是子组件 emit 出来的新字符串不再是 DOM 事件对象。所以父组件可以直接keyword $event不需要再取.target.value。我自己当时第一次封装这种组件写父组件回调时习惯性写了个$event.target.value结果拿到的$event是字符串undefined.value直接报错。后来想明白了自定义组件 emit 什么$event就是什么这就是组件通信和原生 DOM 事件的区别。3.3 初版组件立刻暴露的硬伤value 名称不够表达语义如果所有组件都默认value input确实可以工作但有些组件用起来语义会很奇怪。比如一个开关组件switch-component v-modeldarkMode /darkMode这个值是布尔值子组件内部却要 props 接收value事件叫input。语义上怎么读怎么别扭开关的输入是什么而它实际上应该叫checked属性、change事件才符合直觉。再比如一个日期选择器你希望父组件传的是一个selectedDate但 v-model 默认只认value。虽然能跑但看代码的人需要额外绕一层理解。这就引出了 v-model 的一个进阶能力model选项允许你重新定义 v-model 的属性名和事件名。4. 进阶用 model 选项重新定义 v-model 的形状model选项是 Vue2 在 2.2.0 版本加入的目的就是让你别被value和input绑死。4.1 为什么需要 model 选项一个组件可能同时有多个状态比如一个弹窗组件主要状态是visible一个步进器组件主要状态是count。这些组件的核心值用统一的value来表示往往不是最好的表达。更重要的是有些组件内部本身已经有一个valueprop 用来干别的事了你还要用它承载 v-model 的默认绑定很容易冲突。这时候用model选项重新指定 v-model 绑定的 prop 和事件能让你在设计组件 API 的时候更自由。4.2 给开关组件配置 model 并接入 v-model先做一个简单的开关组件template button classswitch-button :class{ is-active: checked } clicktoggle {{ checked ? 开 : 关 }} /button /template script export default { name: ThemeSwitch, model: { prop: checked, event: change }, props: { checked: { type: Boolean, default: false } }, methods: { toggle() { this.$emit(change, !this.checked) } } } /script父组件里theme-switch v-modeldarkMode /编译后等价于theme-switch :checkeddarkMode changedarkMode $event /这样组件内部读的是checked向外抛的是change事件语义和开关控件的真实交互完全吻合。之后代码审查的人看到theme-switch v-modeldarkMode /一眼就能明白这个开关控制的是暗黑模式。如果用默认的value input组件内部就要写value还要抛input开关的布尔语义被硬生生抹掉了任何读代码的人都需要额外想一下。4.3 想绑定两个双向值怎么办v-model 只能绑定一个值这是 Vue2 的设计限制。如果你的组件需要同时双向同步两个数据比如一个弹窗组件又要控制visible又要同步title常见有几种方案方案一一个值用 v-model另一个值用.syncedit-dialog v-modeltitle :visible.syncdialogVisible /方案二全部都用.syncedit-dialog :title.synctitle :visible.syncdialogVisible /方案三传一个对象进去子组件内部维护并 emit。// 父组件 edit-dialog :formform update:formform $event / // 子组件操作 this.$emit(update:form, { ...this.form, title: value })个人经验是多个双向值场景优先用.sync每个值对应一个update:xxx事件语义清晰、不会互相干扰。对象方案的优点是参数集中但一旦对象被多处引用就非常容易踩进第 6.2 节的改引用陷阱里。5. 一山不容二虎v-model 与 .sync 的取舍很多人第一次看到.sync会疑惑这不就是另一个 v-model 吗它们确实是近亲但各自有各自的主场。5.1 .sync 的本来面貌先看语法。父组件这样写empty-modal :visible.syncvisible /编译后等价于empty-modal :visiblevisible update:visiblevisible $event /子组件只需要在要改变状态时抛一个事件this.$emit(update:visible, false).sync本质上是一组属性名 对应 update 事件的约定没有默认的 value 和 input 绑定。它的设计初衷就是应对 Vue2 里一个组件需要多个双向绑定值的情况。5.2 v-model 和 .sync 怎么选对比维度v-model.sync默认绑定value prop input 事件无默认需要写:prop.sync可定制性可通过 model 选项定制 prop/event事件名固定为update:prop名主要值数量只能绑一个主值支持绑定多个属性适用场景组件的核心值只有一个语义适合值组件有多个状态需要双向同步代码可读性简洁一行搞定主值多字段时一目了然不会挤在同一个 value 里我一般遵循这样的规则组件只有一个绝对主值比如输入框的值、开关的开关状态、日期选择器的日期用 v-model。组件有多个需要同步的状态比如弹窗的visibletitleloading用多个.sync。如果组件接收的是一个独立的业务对象优先考虑抛完整对象副本由父组件重新赋值或者干脆只做单向传值 事件上报减少耦合。5.3 我在组件库设计里的一点偏执手搓过几次 v-model 之后我在自建组件库里订了一条不那么潮的规矩对外暴露主值尽量统一用valueinput内部再用计算属性包一层。除非是开关、弹窗这种语义差异特别大、特别容易混淆的组件才用model选项改掉 prop/event 名称。统一了协议之后写表格、写表单的时候特别省心。比如封装一个表单生成器每个字段的组件入口都可以统一按:value和input对接一套逻辑走到底。如果今天这个组件叫value、那个叫checked表单引擎就不得不在内部维护一张映射表时间长了很容易漏。这不是什么高深结论就是工程实践里约定大于配置的老道理。Vue 已经把默认协议定好了除非有充分的必要否则别轻易打破它。6. 手搓 v-model 的踩坑现场与排查指南这部分是全文真正的血泪史。我代码 review 时见过太多 v-model 相关的问题自己也都踩过整理出来给你当速查表用。6.1 经典错误子组件里直接给 props 赋值症状控制台出现大段警告Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. Instead, use a data or computed property based on the props value.原因子组件代码里写了this.value xxx。Vue 认为 props 是父组件传给子组件的只读快照每次父组件 re-renderprops 会被重新计算并覆盖。你改的只是临时快照不但会丢还会让数据流变成一锅粥。解法用 emit 通知父组件改。如果只是子组件内部临时编辑用 computed setter 或者 data 里拷贝一份。改进后的写法watch: { value: { handler(val) { this.localValue val }, immediate: true } }或者更推荐的 computed setter 写法前面 3.1 节已经演示过。6.2 更隐蔽的错误修改了 props 引用内部的属性症状父组件传了一个对象:formform子组件里this.form.name xxx。没报错、控制台也干净但父组件页面有时候不刷新或者watch完全没有触发。原因对象是引用类型子组件动的还是父组件的那个对象。数据确实变了但是 Vue 的响应式系统在一些场景下追踪不到属性新增或深层变更尤其是你没有提前声明这个属性的时候。更重要的是这违反了单向数据流约定排查时根本不知道哪个组件动了那个字段。解法子组件永远不要改写 props 对象的内部属性。要更新就生成一个新对象抛给父组件this.$emit(update:form, { ...this.form, name: newName })父组件监听update:form把整个对象替换掉。这样引用变了Vue 能稳定触发更新。6.3 认错事件v-model 默认听 input你却 emit 了 update:value症状子组件里this.$emit(update:value, abc)父组件v-modelxxx死活不更新。原因v-model 默认监听的是input事件不是update:value。update:xxx是.sync那套协议里的事件名。你把两套协议混在一起用事件对不上号绑定自然失效。解法要么老老实实this.$emit(input, abc)配上 v-model要么父组件改成:value.syncxxx配上update:value事件。6.4 第三方组件 v-model 不生效的最后一根稻草排查如果你引入一个第三方组件v-model不生效按这个顺序排查看源码确认组件到底支不支持 v-model 协议有没有valueprop有没有input事件或者有没有model选项自定义。如果组件 API 明确规定要用change事件或者update:xxx那就别用 v-model改用普通绑定加监听。检查父组件那边传来的值本身有没有变化。有时候不是绑定失效是数据源就没动你可以在子组件的watch里先打一行日志确认 props 有没有进来。watch: { value(val, oldVal) { console.log(value changed:, oldVal, -, val) } }有些组件是内部把value又拆成了其他 prop比如 vue 生态里的一些输入型组件会再包一层你要看它文档里 v-model 的具体传参方式必要时直接看props选项。6.5 踩坑速查表症状原因解法控制台报 Avoid mutating a prop directly子组件直接改 props 基本值改用 computed setter 或 emit 事件页面部分区域不刷新改了 props 引用类型内部的属性用扩展运算符生成新对象再 emitv-model 绑定后无任何响应事件名不是默认 input或组件不支持 v-model看组件 API切换到对应协议父组件收不到 $event 的值对组件 $event 的理解还停留在 DOM 事件对象自定义组件的 $event 就是 emit 的参数两个值同时需要双向同步一个 v-model 只能绑一个值用多个 .sync 或多个事件自定义组件里 model 配置后父组件没反应可能没传对应 prop 或没触发对应 event检查 model.prop 与 props、model.event 与 emit 是否一一对应我自己在写这种组件时还会固定加一条注释在组件顶部// v-model 协议value prop input event。团队协作时后来接手的人不需要去翻源码一眼就知道这个组件对外暴露的同步姿势。手搓完 v-model我给自己定的几条规矩这次亲手从零搓完 v-model我对它的认识从一个会自动同步的指令变成了一个由 value 流入、由事件流出的标准协议。再回头看那些封装级的组件难点根本不在语法而在设计组件 API 时有没有想清楚数据的唯一来源是谁变更事件有没有对应的出口。我现在写组件会强制自己遵守三条规矩第一props 一律是只读的要变更就 emit哪怕麻烦一点第二多值双向通信用.sync不要硬塞到一个对象里到处改第三对外协议保持统一能用value input就用除非语义上确实有很大冲突才动用model选项改掉绑定名。这三条规矩看起来简单但每次遇到数据流诡异报错时回去翻代码基本都是违反了其中某一条。最后再分享一个小技巧下次你看到别人封装的一个组件如果它 props 里只有value、$emit 里只有input基本可以断定它能直接用 v-model不需要看文档。这个判断标准比翻文档快得多也准得多。
阅读完成 · 觉得有帮助?