Vue3疑问系列(5) — v-model(vModelCheckbox)指令是如何工作的?

1,150 阅读7分钟

前言

本文来聊聊 “v-model 使用在 input 上且 type 类型为 checkbox,其内部又是如何工作的”

看这篇文章前,一定要把官网中的 表单输入绑定[复选框 (Checkbox)例子看懂] 官网教程.

强烈建议看下我写的关于本次源码讲解的的4个小例子

很有意思的是,我在使用时发现使用姿势不一致的问题(vue3 vModelCheckbox的问题),问题可以在'4个小例子'那个地址去查看.

尝试编写 vModelCheckbox 指令对象

  思考:如果你看懂官网的的例子和我写的那4个小例子,在介绍vModelCheckbox实现之前,思考下,如果让你来实现一个vModelCheckbox对象,你会怎么写?

例子展示

这个例子就是通过实现一个自定义的vModelCheckbox指令对象来达到双向绑定 可以狠狠的点我去看看

const vModelCheckbox = (() => {
      const listener = (type = 'on') => {
          return (el, evt, handler, useCapture = false) => {
              if (el && evt && handler) {
                  el[type == 'on' ? 'addEventListener' : 'removeEventListener'](evt, handler, useCapture)
              }
          }
      }
      const on = listener('on')
      const off = listener('off')

      const hasOwnProperty = Object.prototype.hasOwnProperty

      const hasCustomValue = (vnode) => {
          const props = vnode.props || {}
          return hasOwnProperty.call(props, 'true-value') && hasOwnProperty.call(props, 'false-value')
      }

      const getCheckboxValue = (el, vnode) => {
          const props = vnode.props || {}
          if (hasCustomValue(vnode)) {
              const trueValue = props['true-value'], falseValue = props['false-value']
              return el.checked ? trueValue : falseValue
          } else {
              return el.checked
          }
      }

      const setMapValue = (el, vnode) => {
          const props = vnode['props'] || {}
          el._mapData = new Map([[
              props['true-value'] || true, true
          ], [
              props['false-value'] || false, false
          ]])
      }

      const getMapValue = (el, key) => {
          return el._mapData.get(key)
      }

      return {
          created(el, binding, vnode, preVnode) {
              const { value } = binding

              on(el, 'change', el._handleEvt = (evt) => {
                  const fn = vnode['props'] && vnode['props']['onUpdate:modelValue']

                  const domValue = vnode['props']['value']
                  const checked = evt.target.checked

                  if (Array.isArray(value)) {
                      if (checked && !value.includes(domValue)) {
                          value.push(domValue)
                          fn && fn(value)
                      } else if (!checked && value.includes(domValue)) {
                          const i = value.findIndex(m => m === domValue)
                          if (i !== -1) {
                              value.splice(i, 1)
                              fn && fn(value)
                          }
                      }
                  } else if (value instanceof Set) {
                      if (checked) {
                          fn && fn(value.add(domValue))
                      } else if (!checked && value.delete(domValue)) {
                          fn && fn(value)
                      }
                  } else {
                      fn && fn(getCheckboxValue(el, vnode))
                  }
              })
          },
          mounted(el, binding, vnode, preVnode) {
              setMapValue(el, vnode)
          },
          beforeUpdate(el, binding, vnode, preVnode) {
              setMapValue(el, vnode)
              const { value, oldValue } = binding
              const props = vnode['props']
              const fn = props && props['onUpdate:modelValue']

              if (Array.isArray(value)) {
                  el.checked = value.includes(props['value'])
              } else if (value instanceof Set) {
                  el.checked = value.has(props['value'])
              } else if (value !== oldValue) {
                  el.checked = getMapValue(el, value)
              }
          },
          beforeUnmount(el) {
              el._mapData = null
              off(el, 'change', el._handleEvt)
          }
      }
  })()

  Vue.vModelCheckbox = vModelCheckbox
  1. 上面的代码不考虑代码写的咋样, 功能起码完成了。注意,引用类型的数据,回显时必须使用同一个引用数据,因为我内部使用了[].includes和set.has方法来判断的.

  2. 比起Vue3 v-molde="数组", 当数组的值是引用类型的数据时,Vue3 回显是不需要同一个引用对象,就能进行回显,但是使用 Vue3 v-molde="set实例",如果set实例的成员是引用数据时,回显时set实例的成员必须是同一个引用类型数据。这样Vue3 v-molde="set实例"和Vue3 v-molde="数组"的回显的使用姿势不保持一致了.

  3. 上面代码实现的思路

  • 在created钩子中注册change事件,每次复选框选中或者不选中时,触发该事件,v-model="绑定值", 根据绑定值的类型求出value值然后赋值给绑定值
  • 在beforeUpdate钩子中, 根据绑定值的类型,和vnode[props][value]的值求出是否被选中,然后赋值给el.checked
  • 这样就达到了双向绑定

小栗子

小栗子就不贴代码了,但是想要理解源码,最好要看下,不然下面的实现,可能会看不懂.
关于本次源码讲解的的4个小例子

使用姿势都会了,那接下来看看尤大是如何实现的(看看那个使用姿势的问题到底是哪行代码引起的)

vModelCheckbox内部实现

vModelCheckbox源码 runtime-dom/src/directives/vModel.ts

export const vModelCheckbox: ModelDirective<HTMLInputElement> = {
  created(el, _, vnode) {
    el._assign = getModelAssigner(vnode) // 拿到 onUpdate:modelValue 函数
    addEventListener(el, 'change', () => {
      const modelValue = (el as any)._modelValue // 获取绑定的值eg: v-model="arr" 这里的 modelValue就是 arr
      const elementValue = getValue(el) // 获取el的的value eg: <input v-model="arr" :value="{ name: 'xzw' }" /> 这里的elementValue就是 { name: 'xzw' }
      const checked = el.checked // input checkbox 的选中状态
      const assign = el._assign
      if (isArray(modelValue)) { // v-model="arr"
        const index = looseIndexOf(modelValue, elementValue) // 使用比较宽松的方式找出elementValue在modelValue的索引(这样,不是同一个引用对象,也能进行回显)
        const found = index !== -1
        if (checked && !found) { // 选中且没有找到
          assign(modelValue.concat(elementValue)) // 合并elementValue到modelValue上,然后给arr赋值
        } else if (!checked && found) { // 未选中且找到了
          const filtered = [...modelValue]
          filtered.splice(index, 1) // 删除已存在且未选中的
          assign(filtered) // 给arr赋值
        }
      } else if (isSet(modelValue)) { // v-model="set实例"
        const cloned = new Set(modelValue)
        if (checked) { // 选中则添加(set实例不会重复添加)
          cloned.add(elementValue)
        } else { // 否则未选中则删除
          cloned.delete(elementValue)
        }
        assign(cloned) // 给 set实例赋值
      } else { // 处理原始值类型
        assign(getCheckboxValue(el, checked)) // getCheckboxValue获取真实的value值后给绑定值赋值
      }
    })
  },
  // set initial checked on mount to wait for true-value/false-value
  mounted: setChecked, // 回显
  beforeUpdate(el, binding, vnode) {
    el._assign = getModelAssigner(vnode) // 获取onUpdate:modelValue函数(为了每次更新都使用最新的绑定函数)
    setChecked(el, binding, vnode) // 回显
  }
}

function setChecked(
  el: HTMLInputElement,
  { value, oldValue }: DirectiveBinding,
  vnode: VNode
) {
  // store the v-model value on the element so it can be accessed by the
  // change listener.
  ;(el as any)._modelValue = value // 给el添加_modelValue属性 : eg: v-model="arr" 这里的 value 就是 arr
  if (isArray(value)) { // 数组
    el.checked = looseIndexOf(value, vnode.props!.value) > -1 // eg: <input v-model="arr" :value="{ name: 'xzw' }" /> 这里的vnode.props!.value就是 { name: 'xzw' }, 根据vnode.props!.value的值是否在value中,来进行回显
  } else if (isSet(value)) { // set实例
    el.checked = value.has(vnode.props!.value) // 根据set.has来判断(如果set的成员是引用对象,回显时必须同同一个引用对象才能回显,这就和上面数组的实现有差异了。)
  } else if (value !== oldValue) { // 原始值类型
    el.checked = looseEqual(value, getCheckboxValue(el, true)) // 根据a,b的判断是否相等再赋值el.checked
  }
}

// retrieve raw value for true-value and false-value set via :true-value or :false-value bindings
function getCheckboxValue(
  el: HTMLInputElement & { _trueValue?: any; _falseValue?: any },
  checked: boolean
) {
  const key = checked ? '_trueValue' : '_falseValue'
  return key in el ? el[key] : checked
}

// retrieve raw value set via :value bindings
function getValue(el: HTMLOptionElement | HTMLInputElement) {
  return '_value' in el ? (el as any)._value : el.value
}
  1. 上面的实现不难理解,和 '尝试编写 vModelCheckbox 指令对象' 的思路差不多。

  2. 注解都写在代码中了,所以不重复解释了,这里主要说下他和 '尝试编写 vModelCheckbox 指令对象' 实现的差异:

  • getCheckboxValue 方法中的 _trueValue 和 _falseValue属性,这2个属性何时给el添加上去的?
    我们知道安装元素vnode的时候,会把vnode上的属性,class,style和事件都添加到创建好的el上.
    [后面会单独写一篇关于vnode如何变成el,属性和事件如何添加上去的,这里不做过多介绍]

patchProp 源码地址

  export const patchProp: DOMRendererOptions['patchProp'] = (
    el,
    key,
    prevValue,
    nextValue,
    isSVG = false,
    prevChildren,
    parentComponent,
    parentSuspense,
    unmountChildren
  ) => {
    switch (key) {
      // special
      case 'class':
        patchClass(el, nextValue, isSVG)
        break
      case 'style':
        patchStyle(el, prevValue, nextValue)
        break
      default:
        if (isOn(key)) {
          // ignore v-model listeners
          if (!isModelListener(key)) {
            patchEvent(el, key, prevValue, nextValue, parentComponent)
          }
        } else if (shouldSetAsProp(el, key, nextValue, isSVG)) {
          patchDOMProp(
            el,
            key,
            nextValue,
            prevChildren,
            parentComponent,
            parentSuspense,
            unmountChildren
          )
        } else {
          // special case for <input v-model type="checkbox"> with
          // :true-value & :false-value
          // store value as dom properties since non-string values will be
          // stringified.
          if (key === 'true-value') {
            ;(el as any)._trueValue = nextValue
          } else if (key === 'false-value') {
            ;(el as any)._falseValue = nextValue
          }
          patchAttr(el, key, nextValue, isSVG)
        }
        break
    }
}

原来如此,难怪能拿到我们绑定的引用数据,而在'尝试编写 vModelCheckbox 指令对象'中,我是通过vnode['props']['value']来获取的,似乎也没有问题.

  • getValue方法中的 _value属性又是何时添加上去的呢?

    在上面的 patchProp 方法内部会调用 patchDOMProp 方法

    patchDOMProp 源码

  export function patchDOMProp(
    el: any,
    key: string,
    value: any,
    // the following args are passed only due to potential innerHTML/textContent
    // overriding existing VNodes, in which case the old tree must be properly
    // unmounted.
    prevChildren: any,
    parentComponent: any,
    parentSuspense: any,
    unmountChildren: any
  ) {
    // ...

    if (key === 'value' && el.tagName !== 'PROGRESS') {
      // store value as _value as well since
      // non-string values will be stringified.
      el._value = value
      const newValue = value == null ? '' : value
      if (el.value !== newValue) {
        el.value = newValue
      }
      return
    }

    // ...
}

原来还是在patchProp时添加的,这样就很方便拿到原始值或者true-value|false-value的值,而不必像'尝试编写 vModelCheckbox 指令对象'实现中通过setMapValue设值和通过getMapValue来取值.

  1. 在beforeUpdate钩子中,他的实现采用宽松的比较方式去比较的,有点类似鸭式辨型的思想,像鸭子一样嘎嘎叫且用2条腿行走的动物就认为它是鸭子.
    而在'尝试编写 vModelCheckbox 指令对象'采用 [].includes 和 set.has 方法来判断的

    宽松的比较代码,比较简单,就不黏贴了,可以点我去查看

总结

使用: v-model作用在 <input type="checkbox" v-mode="绑定值" /> 绑定值可以是原始值变量或者数组或者set实例

实现:

  • 在created钩子中注册change事件,事件触发后根据绑定值的类型求出value值然后赋值给绑定值
  • 在beforeUpdate钩子中根据绑定值的类型和vnode[props][value]的值求出是否被选中然后赋值给el.checked
  • 从而达到双向绑定

下篇: Vue3疑问系列(6) — v-model(vModelRadio)指令是如何工作的?