从动态表单输入校验学习vue响应式原理

966 阅读4分钟

最近做了一个动态表单输入校验的功能,碰到了一个颇费加班时长的bug,最后发现是vue响应式原理不清楚造成的知识盲区,下面是整理的学习小结。

业务需求:如下图弹框,需要实现精确到每个输入框的输入提示。其中等级是不确定列数的,根据查询到的等级数据动态渲染。具体业务是,表格支持多选,点击表格上的设置按钮,如果选中项只有一行,弹框里的表单将回显选中项的信息,如果选中多行将重置表单为默认值。

image.png

以下是部分代码:

<div v-if="formData.distributionInvestmentType=='xx'">
  <div class='be-center'>
    <div class="item">商品佣金比例</div>
    <div
      class="item"
      v-for="item in distributionLevels"
      :key="'customRatioGoods_'+item.distributionLevelCode"
    >
      <el-form-item
        :prop="'customRatio.'+'goods_'+item.distributionLevelCode"
        :rules="customRatioRule"
      >
        <el-input
          size='mini'
          placeholder="0-100,最多两位小数"
          v-model="formData.customRatio['goods_'+item.distributionLevelCode]"
          @input="handleForceUpdateInput"
        ></el-input>%
      </el-form-item>
    </div>
  </div>   
</div>
//data中定义的表单model:
formData: {
  extensionStatus: "xx",
  distributionInvestmentType: "xx",
  customRatio: {},
  customMoney: {},
},
//取得等级数据为表单的el-input绑定v-model
getLevels() {
  let data = [
    {
      distributionLevelCode: "xxxx0001",
      distributionLevelName: "等级一级",
    },
    //...
  ];
  this.distributionLevels = data;
  for (let item of data) {
    // 新增属性应使用this.$set,使新属性也是响应式数据  
    // 标注1:新增属性直接赋值,属性非响应式,通过Object.getOwnPropertyDescriptor打印属性描述符,没有相应的get,set
    this.formData.customRatio["goods_" + item.distributionLevelCode] = "";
    //...
  } 
},
handleResetForm() {
  this.formData = {
    extensionStatus: "xx",
    dangerDays: "1",
    distributionInvestmentType: "xx",
    customRatio: this.resetObj(this.formData.customRatio),
    customMoney: this.resetObj(this.formData.customMoney)
  };
  //标注2:formData赋值为新对象,formData的setter中为新对象做响应式处理  
},

问题

出现bug的地方主要是因为不清楚this.$set的用法,不好好读官方文档,又不懂$set源码😅。

标注1:表单数据formDatadata内的定义只定义到customRatio这层属性,也就是响应式数据只到这层属性,输入框绑定的v-mode="formData.customRatio['goods_'+item.distributionLevelCode]"formData.customRatio下新增的属性,直接赋值,新增的属性不是响应式数据,造成了后面的几个问题。

第一个问题:输入框无法输入。点设置按钮,表单输入框回显了表格中选中那一项的数据,但是后面输入框怎么输入都不更新,一直显示回显的数值,查看vue-devtoolsdata里的数据已经更新了,后面给输入框绑定@input事件,调用this.$forceUpdate强制更新,输入框能实时显示输入值了。

第二个问题:输入框虽然正常了,但是自定义的校验规则函数却获取不到最新的输入值,取到的也一直是回显的数值,这样就没法做输入校验。

因为知识有限,后面就开启了各种妖魔化的猜测、调试。以为和el-inputv-model嵌套层级太多有关,以为和 el-form-item动态rulesprop的值有关,以为和el-form rules的定义有关。多次尝试均无果,从跃跃欲试调到面无表情😂。

解决

标注2:调试时发现切换弹窗内佣金类型选项,造成el-form-item重新渲染,可使rules校验规则取到一次最新的输入值,后面还是不再更新(这里猜测是模板渲染的时候做了一次赋值,还没有研究清楚?)。 后面又发现,批量设置总是正常的,没有这些问题。对比设置与批量设置的数据处理,发现批量设置时是把值赋值为新对象,而设置的时候总是在原先对象上直接修改赋值。 后面通过查阅资料、博客了解到和vue响应式处理有关。

defineReactive

已经被observe的响应式数据,被赋值为新对象时,新对象的所有属性会被递归defineReactive,使新对象也是响应式的。

// vue源码位置 src/core/observer/
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()
  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }
  // cater for pre-defined getter/setters
  const getter = property && property.get
  const setter = property && property.set
  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key]
  }

  let childOb = !shallow && observe(val) //解释:value为对象,递归value的所有属性,再次调用defineReactive。value为数组,value的 __proto__ 属性指向arrayMethods
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        dep.depend() //解释:依赖收集
        if (childOb) {
          childOb.dep.depend() //解释:嵌套对象依赖收集
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      /* eslint-enable no-self-compare */
      if (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter()
      }
      // #7981: for accessor properties without setter
      if (getter && !setter) return
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = !shallow && observe(newVal) //解释:响应式对象属性的值赋值为新对象,新对象执行响应式处理
      dep.notify()
    }
  })
}

关键是最后!shallow && observe(newVal)这里,对赋值使用的新对象执行了observe

export function observe (value: any, asRootData: ?boolean): Observer | void {
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob: Observer | void
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) { 
    ob = value.__ob__  //解释:响应式数据返回已有的__ob__,非响应式数据调用new Observer
  } else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    ob = new Observer(value)
  }
  if (asRootData && ob) {
    ob.vmCount++
  }
  return ob
}

export class Observer {
  value: any;
  dep: Dep;
  vmCount: number; // number of vms that have this object as root $data
  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    def(value, '__ob__', this) //解释:__ob__ 数据是否是响应式的标识
    if (Array.isArray(value)) {
      if (hasProto) {
        protoAugment(value, arrayMethods)
      } else {
        copyAugment(value, arrayMethods, arrayKeys)
      }
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }
  /**
   * Walk through all properties and convert them into
   * getter/setters. This method should only be called when
   * value type is Object.
   */
  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }
  /**
   * Observe a list of Array items.
   */
  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}

/**
 * Augment a target Object or Array by intercepting
 * the prototype chain using __proto__
 */
function protoAugment (target, src: Object) {
  /* eslint-disable no-proto */
  target.__proto__ = src  //解释:重置响应式数组数据的__proto__指向vue重写的对象arrayMethods,arrayMethods内重写了几个数组方法,调用相应的数组方法修改数组时,触发notify
  /* eslint-enable no-proto */
}

$set( target, propertyName/index, value )

使用范围:向响应式对象中添加一个属性,并确保这个新属性同样是响应式的。

export function set (target: Array<any> | Object, key: any, val: any): any {
  if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
  ) {
    warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
  }
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val) //解释:调用arrayMethods重写的splice方法,触发notify
    return val
  }
  if (key in target && !(key in Object.prototype)) { //解释:非新增属性,不做响应式处理,赋值后退出
    target[key] = val
    return val
  }
  const ob = (target: any).__ob__
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid adding reactive properties to a Vue instance or its root $data ' +
      'at runtime - declare it upfront in the data option.'
    )
    return val
  }
  if (!ob) {  //解释:非响应式数据,不做响应式处理,赋值后退出
    target[key] = val 
    return val
  }
  defineReactive(ob.value, key, val) //解释:响应式对象新增属性执行响应式处理
  ob.dep.notify()
  return val
}

经过这次的学习,发现vue还是挺高深的,以前提到vue响应式原理,只是粗略的知道是defineProperty数据劫持,再深层的就不懂了,调过这次的bug,发现不会的还有很多,继续学习,继续卷吧🤪。

以上,欢迎讨论,如有错漏也欢迎指正,希望对你有帮助😀