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,具有“双向绑定”:
notion image
先操作 :value,数据不更新:
notion image
从录屏动图中可以看到,当我们首先修改 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 才是响应式的,我们可以推导出以下佳实践:
  1. 尽量在组件初始化的时候,在 data 里预先定义好 key
  1. 在动态 key 的情况下(如一些动态表单的场景),在能确定 key 的时机,代码里手动 this.$set 一下
  1. 在对动态 key 进行 v-model 调用之前,预先遍历这些 key,执行 delete 操作
 

参考

  1. https://stackoverflow.com/questions/57780626/how-v-model-make-a-property-of-object-reactive
  1. https://ssshooter.com/2019-09-03-vue-binding/
 

© Krist 2016 - 2024

|