v-model:想不到吧,我还有两幅面孔
date
Jan 11, 2023
slug
v-model-and-two-way-data-bindings
status
Published
tags
Web
summary
众所周知,v-model 语法糖可以帮助我们在 Vue 中轻松实现数据与视图的双向绑定,并且使数据具有响应式。但并非所有情况都是这样,了解 v-model 的具体实现,避免落入极端场景表现有别于预期的陷阱中
type
Post
注:本文的场景只限于 Vue 2.x 实现,Vue 3 因数据响应式的差异不在讨论范围内。
从一个 issue 说起
这两天参与维护的组件库 issue 中遇到了一个很奇怪的场景,v-model 在表单内的双向绑定竟然在一些情况下失效了(issue 中是点击“重置”按钮后,但后文会提到有其他别的场景)。这其实是与我们通常对 v-model 表现的预期相违背的,在更早的一个关联 issue 中尝试可以发现,在修改某个组件值后,虽然页面在当下未发生更新,但通过如「往 input 框中输入数据」等手段触发页面渲染,可以把之前修改的“未生效”的值一并更新到界面上。以上现象表明,这个问题并非是组件出现赋值失败,而是页面在数据发生修改后未能更新视图,「双向绑定」变成了「单向绑定」(只有V to M,从视图层到数据层)。
Vue 的响应式机制总是在数据发生变更后,将新数据更新到视图上(Model to View),而
v-model
创建的「双向绑定」能力,还会将视图更新的值,同步到来源的数据中(View to Model)。从 表单与输入绑定文档 可以得知,v-model
本质上是一个处理视图更新与数据输入的语法糖,许多地方也会将这个语法糖称为 “:value
与 @change
” 的集合。诚然,这个解释可以匹配大部分情况下 v-model
的表现,但上文 issue 里的情况却与这个结论相违背。转化场景
下方的 demo 代码是将上述 issue 的场景去除细枝末节后抽象的核心逻辑,代码中分别通过
v-model
与 :value
方式将同一个值绑定到了两个编辑框上,且这个值在 formData
中没有预先定义。在线的例子请 点击链接 查看,本地运行需要安装有 Vue 2.x 与 TDesign 依赖。<template>
<t-space direction="vertical" size="32px">
<t-form
ref="form"
:data="formData"
:resetType="'initial'"
colon
@reset="onReset"
>
<t-form-item label="姓名" name="name">
<input v-model="formData.name" placeholder="use v-model" />
</t-form-item>
<t-form-item label="姓名" name="name">
<input
:value="formData.name"
@input="formData.name = $event.target.value"
placeholder="use :value and @input"
/>
</t-form-item>
<t-form-item label="手机号码" name="tel">
<input v-model="formData.tel" placeholder="with v-model" />
</t-form-item>
<t-form-item style="margin-left: 100px">
<t-space size="10px">
<t-button theme="primary" type="submit">提交</t-button>
<t-button theme="default" variant="base" type="reset">重置</t-button>
<t-button
@click="formData.name = `用户${Math.round(Math.random() * 100)}`"
>
随机姓名
</t-button>
</t-space>
</t-form-item>
</t-form>
<div>{{ formData }}</div>
</t-space>
</template>
<script>
// 这是初始值,数据变化后可以设置表单重置为这个初始值
const INITIAL_DATA = {
tel: '18612345678',
};
export default {
data() {
return {
formData: { ...INITIAL_DATA },
};
},
methods: {
onReset() {
this.$message.success('重置成功');
},
},
};
</script>
先操作
v-model
,具有“双向绑定”:
先操作
:value
,数据不更新:
从录屏动图中可以看到,当我们首先修改
v-model
绑定的编辑框,可以触发双向绑定逻辑并完成正常的页面更新;但如果首先修改 :value
与 @change
绑定的编辑框,后续无论如何尝试,数据都会丢失响应式。奇怪,不是说好的,
v-model
就是 “:value
与 @change
” 的集合吗?原因分析
要弄清楚上面的现象,我们首先要知道,
v-model
里究竟干了啥。Vue 实现的依赖收集是通过观察 data
本身与遍历 data
每一个属性并加入观察来运作的。此外,Vue 还提供了一个 Vue.set
API(文档) 供开发者向对象中添加新的响应式属性。而
v-model
之所以能让在 data 中未定义的属性也带有响应式效果,是因为 v-model
依然会在内部对于新属性调用 set
API 来添加数据,相关的实现参考 下方代码 的第二个 return
。/**
* Cross-platform codegen helper for generating v-model value assignment code.
*/
export function genAssignmentCode(value: string, assignment: string): string {
const res = parseModel(value)
if (res.key === null) {
return `${value}=${assignment}`
} else {
return `$set(${res.exp}, ${res.key}, ${assignment})`
}
}
到这里,好像还是没有能解释 demo 中的第二个场景,为什么
:value
与 @change
的组合不能实现与 v-model
一致的效果。别急,让我们看看 Vue.set
又到底干了啥,参考官方代码,下方截取出了与上述现象有关联的一段逻辑:if (isArray(target) && isValidArrayIndex(key)) {
target.length = Math.max(target.length, key)
target.splice(key, 1, val)
// when mocking for SSR, array methods are not hijacked
if (ob && !ob.shallow && ob.mock) {
observe(val, false, true)
}
return val
}
if (key in target && !(key in Object.prototype)) {
target[key] = val
return val
}
看到上方代码的第二个
if
,效果是:如果通过 $set
操作的属性(key)在原对象已经存在的情况下,直接执行等号赋值,不会为该属性添加响应式。问题就出在这里,当一个属性通过 @input
的回调进行等号赋值后,这个属性在对象中就已经存在了,后续即使通过 v-model
来修改属性值,因为该属性已经被设置过,在 $set
的逻辑里会被判定为旧的属性,直接执行赋值操作。至此,造成 demo 中异常表现的原因算是弄清楚了。
解决方案
最后总结一下出现这个问题的原因:
v-model
在对对象进行操作的时候,需要进行一次 Vue.set
调用后才能使新属性(key)具有响应式;但是 Vue.set
对于调用前已经操作过的键值会默认其已经带了响应式,跳过依赖收集,故出现 “v-model
双向绑定失效” 的现象。根据 Vue 的官方文档 教程环节 可知,只有当实例被创建时就已经存在于
data
中的 property 才是响应式的,我们可以推导出以下较佳实践:- 尽量在组件初始化的时候,在
data
里预先定义好 key
- 在动态 key 的情况下(如一些动态表单的场景),在能确定 key 的时机,代码里手动
this.$set
一下
- 在对动态 key 进行
v-model
调用之前,预先遍历这些 key,执行delete
操作