多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Vue v-model 完全解析:从语法糖到组件封装实践

Vue v-model 完全解析:从语法糖到组件封装实践 学 Vue 到第十天终于要啃v-model这块硬骨头了。说实话我第一天看到v-model的时候就觉得这玩意儿透着股邪门。你说它是指令吧它又能改数据你说它是双向绑定吧Vue 文档又整天喊“单向数据流”。这不矛盾吗等你用熟了之后才发现这货就是个披着魔法外衣的语法糖本质就是v-bind加v-on的组合封装。但即便看穿了本质v-model依然是 Vue 开发里绕不过去的核心尤其是你在写表单、弹窗、自定义组件的时候几乎天天要和它打交道。这篇笔记我就把第十天学到的东西做个完整复盘从原理到用法、从踩坑到进阶把我实际写代码过程中遇到的各种问题都记录下来希望能帮到正在学 Vue 的各位。1. 先搞清楚双向绑定到底在解决什么问题1.1 没有 v-model 的时候表单写起来有多痛苦咱们得先回到原始社会感受一下。Vue 的核心思路是“数据驱动视图”数据变了页面自动更新。这是它最爽的地方——你只需要维护一份数据就不用天天操作 DOM 了。但这里有个绕不过去的坎表单元素。用户在输入框里打字的时候这个输入框的“值”是浏览器 DOM 自己管理的。假如你只用单向绑定也就是:valueinputValue你会看到一种很尴尬的场景我把inputValue设为hello页面能显示出 hello但我往输入框里加了个字母Vue 的数据却完全不知道。用户输入“hello!”了你的 Vue 实例里的inputValue还是hello。那视图呢如果你没有额外的事件处理视图也不会重新渲染。于是你写了个表单结果数据根本拿不到用户输入了什么。所以你必须手动监听事件把 DOM 里的值写回数据。原生写法大概是这样的template input :valueinputValue inputhandleInput / /template script export default { data() { return { inputValue: } }, methods: { handleInput(event) { this.inputValue event.target.value } } } /script写一次两次还行如果一个页面里有十来个表单字段你就得写上十来个input方法全是重复劳动又臭又长。1.2 v-model 到底是干什么的v-model的出现就是为了把上面这段“赋值 监听事件”的流程缩短成一行input v-modelinputValue /就这么一行上面:value、input、handleInput那些活儿Vue 全帮你干了。所以我说它是“双向绑定的魔法师”并不夸张——它把最繁琐的绑定逻辑藏起来了你在模板上写的代码极度干净。但我要强调一句这里有个非常大的认知误区很多初学者以为v-model打破了 Vue 的单向数据流。其实不是这样。v-model并没有改变 Vue 的底层数据流方向它依然是通过“数据改变视图、事件改回数据”这个循环来工作的。所谓“双向”指的是它同时替你做了两个方向的事而不是它改变了数据流的规则。2. 揭开魔法外衣v-model 本质上是语法糖2.1 官方文档没说透的公式如果你翻 Vue 2 的文档里面有一句话是点睛之笔v-model本质上是语法糖它负责监听用户的输入事件以更新数据并特别处理一些极端的边界。但官方没给你写清楚的是这个等价公式!-- 下面这两行在 Vue 2 里是等价的 -- input v-modelmsg / input :valuemsg inputmsg $event.target.value /到了 Vue 3因为组件传参的 prop 命名规则改动等价公式变成了!-- 下面这两行在 Vue 3 里是等价的 -- input v-modelmsg / input :modelValuemsg update:modelValuemsg $event /你看Vue 3 里v-model默认绑定的 prop 叫modelValue监听的事件叫update:modelValue赋值也更直接直接拿$event就是新值。所以你现在再回头看v-model就没有任何魔法成分了它只是一个帮你写好了v-bind和v-on的语法糖。用户可以输入是因为它把 value 绑定给了输入框Vue 数据能更新是因为它监听了 input 事件并把你输入的值塞回数据。2.2 为什么要有这个语法糖很多人问那我不用v-model直接用:modelValue加update:modelValue不也能实现同样的功能吗确实能但你会发现代码瞬间多了一倍。而 Vue 的设计哲学就是“渐进式”。如果你这辈子只写两三个页面手写事件无所谓但真实项目里一个后台管理页面动辄几十个表单控件有v-model和没有v-model完全两个世界。我举一个很俗的类比做饭。没v-model的时候就跟你切菜前要自己磨刀、自己切菜一样能吃但效率低、切得还丑有了v-model相当于你拥有了一个会自动磨刀的刀架你把刀插进去它帮你磨好——本质上你的动作还是那个动作但省了很多事。这个语法糖带来的最大好处是声明式代码的可读性。v-modeluser.name一眼就知道这个输入框直接绑定了user.name后续维护代码的人不用去翻方法里到底改了哪个字段。2.3 v-model 在不同元素上的细微差别你得知道v-model在不同表单元素上监听的事件和绑定的属性并不完全一样元素绑定的属性监听的事件取值方式input[typetext]valueinput用 event.target.valuetextareavalueinput用 event.target.valueinput[typecheckbox]checkedchange用 event.target.checkedinput[typeradio]checkedchange用 event.target.checkedselectvaluechange用 event.target.value这里有个细节特别容易忽略输入类控件监听的是input事件而选择类控件监听的是change事件。因为选择类控件需要等用户做完整个选择动作再触发输入框则是边打边触发。Vue 在内部帮我们做了适配所以你写v-model的时候不用管这些但理解了这个差异你排查问题的时候思路会清晰很多。3. 日常使用全解析从表单到修饰符的实践细节3.1 文本输入框和文本域最基本的用法template div input v-modelmessage placeholder请输入内容 / textarea v-modeldescription/textarea pre{{ message }}/pre pre{{ description }}/pre /div /template我最初写的时候犯过一个错误觉得给textarea加v-model之后还能在里面放文本内容比如textarea v-modeldescription初始内容/textarea。这是不对的。只要加了v-modeltextare a的文本内容就应该通过绑定的数据来管理标签内部的文本会被忽略。这和 Vue 2 的插值机制有关文档里其实提了但新手很容易忽略。所以初始值记得写在 data 里面而不是写在标签内。3.2 复选框单选框和布尔值的陷阱单个复选框的v-model绑定的是一个布尔值input typecheckbox idagree v-modelisAgree / label foragree{{ isAgree }}/label这样能直观看出来用户是否勾选了。但真正容易出问题的是“多个复选框绑定同一个数组”的情况。比如做一个“兴趣标签”的多选template div input typecheckbox ideat value吃 v-modelhobbies / label foreat吃/label input typecheckbox idsleep value睡 v-modelhobbies / label forsleep睡/label input typecheckbox idcode value写代码 v-modelhobbies / label forcode写代码/label p选中的值{{ hobbies }}/p /div /template script export default { data() { return { hobbies: [] } } } /script这里有个关键前提data里hobbies必须初始化为数组。如果初始值是null或者undefinedVue 在往里面塞值的时候就会报错。我实际遇到过的报错是 “Cannot read properties of null (reading push)”排查了半天才意识到是初始值类型没写对。如果绑定的值是对象数组v-model 也可以直接双向绑定对象的某个字段input typecheckbox :valueitem v-modelselectedItems /这时候Vue 会把整个item对象塞进selectedItems数组里。但是请注意这要求item是引用相等才能正确取消勾选。如果你selectedItems里存的不是同一个对象引用而是拷贝出来的副本取消勾选的时候就会匹配不上。这是个非常隐蔽的坑。3.3 单选框和下拉选择单选框input typeradio idmale value男 v-modelgender / label formale男/label input typeradio idfemale value女 v-modelgender / label forfemale女/label这里有个小技巧多个单选框的v-model绑定同一个变量就够了不用像 Vue 2 里给每个 radio 手动写name来分组。因为当多个 radio 绑定了同一个v-model变量时Vue 会自动让它们互斥相当于隐式分组。虽然你手写namegender也能生效但v-model已经帮你做了这一层。下拉框的单选与多选!-- 单选 -- select v-modelcity option valuebeijing北京/option option valueshanghai上海/option option valueguangzhou广州/option /select !-- 多选 -- select v-modelcities multiple option v-forcity in allCities :keycity.id :valuecity.id{{ city.name }}/option /select多选的v-model绑定的也必须是一个数组否则界面上选不出多选效果。我在做省市区联动的时候就摔过跟头因为后端接口返回的城市列表是字符串数组而选择器内部需要的是一个对象数组结果下拉框一直显示不正常最后发现是数组里元素的类型对不上。3.4 三个修饰符的用法和实战坑v-model带三个修饰符.lazy、.number、.trim。写起来很简单但实际使用的时候有不少细节值得说道。.lazyinput v-model.lazymsg /加了.lazy之后数据就不会在每次input事件触发时更新了而是等到change事件一般是失去焦点或回车才更新。这对需要减少更新频率的场景很有用比如搜索框不想打一个字就去请求一次接口。但注意这不是防抖。.lazy只是把更新时机从“每次输入”变为“失焦时”它不会帮你做任何请求合并或过滤。如果你真的需要防抖还是要配合watch或者自己写个debounce。.numberinput v-model.numberage /这个修饰符会把用户输入的内容尝试转成数值。但这里有一个众所周知的坑如果用户输入的是非数字开头的内容parseFloat会返回NaN。也就是说.number并没有做很健壮的“只允许数字”校验。之前有项目需求输入框只允许输入 0-100 的年龄我天真地以为v-model.number就够了。结果用户可以输入abc数据变成了NaN后端一存全是 null。究其根本.number只是类型转换它不会阻止任何输入。真要限制输入格式还是得自己在input里手动处理或者用表单校验规则。.triminput v-model.trimusername /这个修饰符会自动把输入内容首尾的空格去掉。注意它只去掉首尾空格不去掉中间的空格。但有意思的是.trim是在“更新数据时”去掉空格而输入框里显示的字符会短暂保留空格直到下一次刷新。如果你希望输入框里不显示空格那么.trim做不到得自己写一个 watch 来强制替换输入框的值。3.5 v-model 在组件上的演进从 value 到 modelValue 到多值绑定学 v-model 的第十天刚好能接触到组件封装。如果你把一个自定义组件也声明了v-model那说明这个组件允许父组件直接双向绑定数据。Vue 2 的默认写法是props: { value: String }, methods: { changeValue(event) { this.$emit(input, event.target.value) } }Vue 3 把它改成了更语义化的方式props: { modelValue: String }, emits: [update:modelValue], methods: { changeValue(event) { this.$emit(update:modelValue, event.target.value) } }Vue 3 另一个重要的改动是支持多个v-model绑定。比如一个弹窗组件既需要控制显隐又需要传递标题custom-dialog v-model:visibleshowDialog v-model:titledialogTitle /custom-dialog对应的组件里props: { visible: Boolean, title: String }, emits: [update:visible, update:title]这个特性对封装有多个数据位的组件特别友好。想一想如果没有多 v-model你得给组件传三四个 prop再监听三四个事件代码非常繁琐。现在一个v-model:xxx就说明白了。3.6 给对象的某个属性绑定时要小心写表单时经常会写v-modelform.username这种绑定。在 Vue 2 里如果你给一个原本不存在的属性做双向绑定Vue 2 不会自动为它建立响应式依赖因为Object.defineProperty只能对已存在的属性做响应式处理。换句话说如果data里的form初始化时根本没有username这个字段那v-modelform.username在 Vue 2 里是无效的。Vue 3 使用的是 Proxy这个问题被整体解决了直接给对象动态添加属性也没问题。这个差异在我维护老项目时踩过坑从 Vue 3 切到 Vue 2 项目发现表单新增字段永远没有响应最后才想到是 Vue 2 响应式的“属性必须预先声明”规则。所以我养成了一个习惯无论什么 Vue 版本我都尽量在 data 初始化的时候把对象的所有可能字段都写出来。即使初始值是空字符串也比不写强这样以后排查响应式问题能少掉很多烦恼。4. 组件上的 v-model让别人也能用你的“魔法”4.1 为什么要封装一个支持 v-model 的组件日常开发中你大概率会遇到这种场景你要做一个通用的搜索框、一个弹窗、一个日期选择器、一个树形选择器这些组件的核心特点就是“有一个值随时可能被用户修改”。如果这些组件不支持v-model调用方用起来会非常难受。想象一下你封装了一个my-input别人用的时候是这样my-input :modelValuename update:modelValuename $event blurhandleBlur /三天后你再看这段代码你会怀疑自己是不是精神分裂了明明一个需求写了两个绑定。但如果你让组件支持了v-model调用方就会变成my-input v-modelname blurhandleBlur /代码整洁度提升一个档次。4.2 亲手实现一个支持 v-model 的输入框组件假设我们需要一个带清空按钮的输入框组件HTML 结构很简单但数据流就是核心。!-- MyInput.vue -- template div classmy-input-wrapper input classmy-input :valuemodelValue inputhandleInput focusisFocused true blurisFocused false / span v-ifmodelValue isFocused classclear-btn clickclearValue ×/span /div /template script export default { name: MyInput, props: { modelValue: { type: String, default: } }, emits: [update:modelValue], data() { return { isFocused: false } }, methods: { handleInput(event) { this.$emit(update:modelValue, event.target.value) }, clearValue() { this.$emit(update:modelValue, ) } } } /script父组件这么用template my-input v-modelsearchText placeholder搜索关键词 / p你输入的是{{ searchText }}/p /template这样就实现了自定义组件的 v-model 双向绑定。你可能会问为什么内部要用:valuemodelValue而不是直接用v-modelmodelValue呢因为props是非响应式写法的你把modelValue直接作为v-model的绑定等于是试图直接修改propsVue 会在控制台告警。所以正确做法是prop 负责传入事件负责传出。4.3 用 v-model 写一个显隐控制的弹窗组件弹窗组件的显隐控制也很适合用 v-model 来做。假设我们有一个自定义弹窗MyDialog希望实现my-dialog v-modeldialogVisible title编辑用户 !-- 弹窗内容 -- /my-dialog点击关闭按钮的时候内部需要通知父组件把dialogVisible置为 false。子组件里这样写!-- MyDialog.vue -- template div v-ifmodelValue classdialog-mask div classdialog-content div classdialog-title slot nametitle{{ title }}/slot span classdialog-close clickcloseDialog×/span /div div classdialog-body slot/slot /div /div /div /template script export default { name: MyDialog, props: { modelValue: { type: Boolean, default: false }, title: { type: String, default: } }, emits: [update:modelValue], methods: { closeDialog() { this.$emit(update:modelValue, false) } } } /script在这个场景下弹窗的显隐状态成了“数据”子组件只是通过事件把数据告诉父组件父组件再去改dialogVisible然后整个视图再次渲染弹窗关闭。这个模式非常经典你在 Element Plus 的el-dialog、Naive UI 的n-modal里都能看到类似设计。4.4 多个 v-model 绑定和修饰符自定义前面说了 Vue 3 支持多个v-model绑定。假设我们再做一个分组选择器range-select v-model:startstartDate v-model:endendDate /range-select组件里props: { start: String, end: String }, emits: [update:start, update:end]这在实际工作中特别实用比如日期区间选择、价格区间输入你不再需要给组件传两个 prop再监听两个事件只要把两段数字字段塞给两个v-model就行。再延伸一步在 Vue 3 里你还可以给自定义组件定义v-model的修饰符。比如你希望数字组件的输入只能保留两位小数可以这样my-number v-model:value.roundprice /组件里通过modelModifiers来识别。以单个v-model为例props: { modelValue: Number, modelModifiers: { default: () ({}) } }, emits: [update:modelValue], methods: { updateValue(val) { let value val if (this.modelModifiers.trim) { // 处理 trim 修饰符 } if (this.modelModifiers.round) { value Math.round(value * 100) / 100 } this.$emit(update:modelValue, value) } }这里modelModifiers是 Vue 3 自动注入的一个 prop它的 key 就是你在使用组件时写的那些修饰符。这个能力很强大但不是必须天天用的。我觉得知道有这个东西面试被问到能答上两句实际封装需求出现了再深入研究也不迟。4.5 v-model 和 .sync 修饰符的前世今生如果你是 Vue 2 的老开发者可能会记得update:语法还可以通过.sync修饰符来使用!-- Vue 2 里 -- comp :title.synctitle/comp这个写法和:titletitle update:titletitle $event等价。到了 Vue 3.sync被官方移除了统一改成v-model:title来使用。为什么要这么改因为 Vue 3 作者觉得v-model本身就支持参数和修饰符再单独保留一个.sync让心智负担变重了。这个演进其实很能说明问题v-model 的边界在逐渐扩大正在成长为一个通用数据绑定原语。所以你现在学了v-model:visible这种多个绑定写法以后看 Vue 3 生态组件代码时一点障碍也没有。5. 高频踩坑与排查技巧实录5.1 v-model 和 value 同时写哪个生效这个问题我至少有十次看到新手这么写input v-modelmessage value默认内容 /结果页面上输入框永远显示“默认内容”但message的值始终是空。原因很简单HTML 原生value属性赋予了输入框一个初始值v-model又用自己的逻辑绑定数据。当v-model触发更新时它会尝试以数据源为准重新赋值。但如果你在最开始没有给data里的message赋值那么value属性的优先级高把输入框内容固定了。解决办法很直接永远不要在一个加了 v-model 的输入框上同时写原生的 value 属性。默认值写到 data 里而不是写到标签里。除非你确实想给这个输入框一个“无 v-model 干预”的初始值比如第三方组件场景否则别这么写。5.2 输入框内容变了但数据没更新如果你发现输入框界面能正常输入但data里的值不跟着变化八成是事件没绑对。我举一个例子input :valueform.name changeform.name $event.target.value /这里用的是change而不是input所以你要等输入框失焦之后form.name才会被更新。change在输入的时候根本不会触发。区别就在于v-model在内部监听的是input而change则是另一套事件。如果你写的是inputform.name $event.target.value那逻辑是对的。但得注意$event.target.value里拿到的一定是字符串如果你需要的是数字还得做转换。5.3 checkbox 勾选失效原因竟然在初始值之前做过一个权限管理页面权限项目用复选框展示我用v-model绑定每一项对应的权限值。结果诡异的是有几个 checkbox 怎么点都没反应。排查了很久最后发现那几个 checkbox 的:value值不是字符串而是数字而我的数组初始值是字符串数组// 错误 permissions: [read] // 正确 permissions: [1001]Vue 在判断值与数组是否存在时用的是严格相等includes字符串和数字不会匹配上。所以 checkbox 的勾选判断失效了。解决方式就是保证初始数组里的元素类型和:value的类型一致。5.4 弹窗关闭再打开数据被清空了这个是我自己做过的一个典型需求编辑弹窗里有一个v-model绑定的表单每次打开弹窗时里面的数据明明是上一次提交的内容没有回显最新数据原因分析我给dialogVisible设置为 true 时组件内部的数据没有重新初始化。而v-model的绑定只负责把父组件传下来的值同步给子组件并不会主动重置子组件内部的表单数据。解决方式用watch监听modelValue或弹窗的visible属性在变为 true 时执行一次表单初始化。或者更简单直接的做法是每次关闭后让父组件把表单对象 reset 掉。这类问题和v-model本身关系不大但它和v-model结合使用时很容易让人困惑v-model 管理的只是数据的绑定不是组件的生命周期。数据要不要重置得组件自己处理。5.5 计算属性和 v-model 结合允许你写逻辑如果直接给v-model绑定一个计算属性是不行的会报 Computed property xxx was assigned to but it has no setter. 因为计算属性默认只有 getter没有 setter。但如果业务上确实需要拦截数据变化这时候就要给计算属性手动写 settercomputed: { fullName: { get() { return this.firstName this.lastName }, set(value) { const names value.split( ) this.firstName names[0] || this.lastName names[1] || } } }这样在模板里直接v-modelfullName就能实现“一个字段控制两个字段”的效果。就比如做一个“手机号 验证码”的合并输入框。不过我一直建议初学者不要为了炫技强行用计算属性的 setter 去做数据转换。搞得太花哨后面看代码的人会骂人的。大多数情况下一行方法就能解决input v-modelphoneNumber inputhandlePhoneChange / methods: { handlePhoneChange() { this.phoneNumber this.phoneNumber.replace(/\D/g, ) } }这样做逻辑更直白。5.6 在 v-model 上直接绑定对象引用导致互相污染多个数组、对象之间的v-model绑定如果不小心让它们共享了同一个引用会出现改一个全变的诡异问题。比如两个输入框都绑定v-modelform.a.b和v-modelform.a.c这种如果form对象是通过浅拷贝赋值的那么修改子对象的属性时因为引用相同两个视图都会更新不是我们想要的效果。解决办法就是在数据源处确保每个对象都独立。不管是JSON.parse(JSON.stringify())、Object.assign、还是手动 new 一个新对象都要保证引用不共享。我自己现在写代码有个默认习惯从接口拿回来的对象只要后续有“基于它生成一份可编辑副本”的需求第一件事就是做深拷贝不给它留下任何共享引用的机会。6. 写在最后的现实体会把 v-model 学透最大的收获其实不是多会写几行代码而是对“Vue 的数据流”有了更深刻的感知。它没有改变“数据驱动视图、事件反向修改”的闭环模型它只是把闭环的入口和出口都封装成了一条指令。学到这里我建议你可以主动做个练习不查官方文档用:modelValue和update:modelValue手写一个等价于v-model的组件看看自己能不能把数据流给走通。如果能在十分钟内写出来那我敢说你已经真正理解了这个“魔法师”的套路。顺便分享一个小技巧当你在项目里拿到一个第三方组件不确定属性/事件定义时优先看它支持不支持v-model支持几个参数。如果支持那说明组件的“值”是有明确归口的用起来会顺滑很多。比如一些播放器组件它的“当前播放时间”就可以用 v-model 来双向绑定进度条拖到哪数据就更新到哪一定规整自封装组件也会用 v-model 来管理像“中心点坐标”这样的核心字段。这个模式以后你会越来越常见。第十天学完干杯接着往下啃。
返回列表