Vue3疑问系列(4) — v-model(vModelText)指令是如何工作的?__Vue.js
发布于 3 年前 作者 banyungong 1089 次浏览 来自 分享
粉丝福利 : 关注VUE中文社区公众号,回复视频领取粉丝福利

前言

“v-model 指令可以在表单 input、textarea、select 元素创建双向数据绑定, 它会根据控件类型自动选取正确的方法来更新元素。尽管有些神奇…”

上面的语句是来自官网,没错,我就是对他的神奇很感兴趣,那这篇文章就先探索 v-model 绑定在 input[type为text和number和range] 和 textarea 元素上是如何进行双向绑定的?

看这篇文章前,一定要把官网中的 表单输入绑定[文本 (Text)和多行文本 (Textarea)例子看懂] 官网教程

尝试编写 vModelText 指令对象

  提醒:如果你看懂官网的那2个例子,且对render函数有点了解,那下面这个例子可以先看下.

例子展示

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

const vModelText = (() => {

      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')

      return {
          created(el, binding, vnode) {
              const { number, trim, lazy } = binding.modifiers

              on(el, lazy ? 'change' : 'input', el._handleEvt = (e) => {
                  let target = e.target, domValue = target.value

                  if (trim) {
                      domValue = domValue.trim()
                  } else if (number || el.type == 'number') {
                      domValue = isNaN(parseFloat(domValue)) ? domValue : parseFloat(domValue)
                  }

                  const fn = vnode['props'] && vnode['props']['onUpdate:modelValue']
                  if (fn) fn(domValue)
              })
          },
          beforeUpdate(el, binding) {
              const { number, trim, lazy } = binding.modifiers
              if (el.value !== binding.value) {
                  el.value = binding.value
              }
          },
          beforeUnmount(el, { modifiers: { lazy } }) {
              off(el, lazy ? 'change' : 'input', el._handleEvt)
          }
      }
  })()

例子实现讲解

     <input v-model="value" />
  1. 双向绑定的实现原理(下面只是input[type为text和number和range]、textarea的实现原理):
  • 在指令created钩子中, 监听$\color{red}{ input }$元素的$\color{red}{ input事件 }$;
  • 用户输入时当值发生变化,触发input事件,重新赋值给响应式$\color{red}{value}$变量;
  • 当响应式值$\color{red}{ value }$改变时,调用指令的beforeUpdate钩子,这个钩子函数中把响应式$\color{red}{ value }$的值赋值给$\color{red}{ input这个dom元素 }$;
  1. 上面例子的代码就不细讲(举这个例子就是想让大家养成看源码前,先学会思考,如果是你来实现vModelText,你会怎么想),瞅瞅就好,下面才开始进入正题.

小栗子

  1. 本次举的小例子, 不是源码中的单侧,而是我编写的小例子,为了让大家更直观的去理解.
    <div id="app"></div>
    const { render, defineComponent, h, withDirectives, vModelText } = Vue

    const root = document.querySelector('#app')

    const component = defineComponent({
        data() {
            return { value: null }
        },
        template: `
            <div>
                <input v-model="value" />
                <p>{{ value }}</p>
            </div>
        `
    })

    render(h(component), root)

这个例子很简单,就是在$\color{red}{ input }$上使用了$\color{red}{ v-model }$然后把$\color{red}{ value }$的值实时的展示在$\color{red}{ p }$元素上.

function render() {
    const _this = this
    return h('div', [
        withDirectives(h('input', {
            'onUpdate:modelValue'($event) {
                _this.value = $event
            }
        }), [[vModelText , this.value ]]),
        h('p', this.value)
    ])
}

上面的模板其实可以写成这样的render函数,可以看到本质还是调用了withDirectives方法.

function render(_ctx, _cache) {
  with (_ctx) {
    const { vModelText: _vModelText, createVNode: _createVNode, withDirectives: _withDirectives, toDisplayString: _toDisplayString, openBlock: _openBlock, createBlock: _createBlock } = _Vue

    return (_openBlock(), _createBlock("div", null, [
      _withDirectives(_createVNode("input", {
        "onUpdate:modelValue": $event => (value = $event)
      }, null, 8 /* PROPS */, ["onUpdate:modelValue"]), [
        [_vModelText, value]
      ]),
      _createVNode("p", null, _toDisplayString(value), 1 /* TEXT */)
    ]))
  }
}
  • 这个render函数是运行时根据上面的模板通过Vue.compile编译出来的,可以看到模板编译的render函数和上面写的render函数类似
  • 从我写的render函数或者通过Vue.compile编译出来的render函数来看
   <input v-model="value" />
   被编译成了
   withDirectives(h('input', {
      'onUpdate:modelValue'($event) {
          _this.value = $event
      }
   }), [[vModelText , this.value ]])
  • 当input元素触发input事件时,会调用onUpdate:modelValue函数从而对value进行赋值更新
  • 当响应式value更改触发指令beforeUpdate钩子时,会把响应式value的值赋值给input元素
  • 其实就是这么简单,那接下来看看尤大是如何实现的

vModelText内部实现

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


const getModelAssigner = (vnode: VNode): AssignerFn => {
  const fn = vnode.props!['onUpdate:modelValue']
  return isArray(fn) ? value => invokeArrayFns(fn, value) : fn
}

function onCompositionStart(e: Event) {
  ;(e.target as any).composing = true
}

function onCompositionEnd(e: Event) {
  const target = e.target as any
  if (target.composing) {
    target.composing = false
    trigger(target, 'input')
  }
}

function trigger(el: HTMLElement, type: string) {
  const e = document.createEvent('HTMLEvents')
  e.initEvent(type, true, true)
  el.dispatchEvent(e)
}

// We are exporting the v-model runtime directly as vnode hooks so that it can
// be tree-shaken in case v-model is never used.
export const vModelText: ModelDirective<
  HTMLInputElement | HTMLTextAreaElement
> = {
  created(el, { modifiers: { lazy, trim, number } }, vnode) {
    el._assign = getModelAssigner(vnode)
    const castToNumber = number || el.type === 'number'
    addEventListener(el, lazy ? 'change' : 'input', e => {
      if ((e.target as any).composing) return
      let domValue: string | number = el.value
      if (trim) {
        domValue = domValue.trim()
      } else if (castToNumber) {
        domValue = toNumber(domValue)
      }
      el._assign(domValue)
    })
    if (trim) {
      addEventListener(el, 'change', () => {
        el.value = el.value.trim()
      })
    }
    if (!lazy) {
      addEventListener(el, 'compositionstart', onCompositionStart)
      addEventListener(el, 'compositionend', onCompositionEnd)
      // Safari < 10.2 & UIWebView doesn't fire compositionend when
      // switching focus before confirming composition choice
      // this also fixes the issue where some browsers e.g. iOS Chrome
      // fires "change" instead of "input" on autocomplete.
      addEventListener(el, 'change', onCompositionEnd)
    }
  },
  // set value on mounted so it's after min/max for type="range"
  mounted(el, { value }) {
    el.value = value == null ? '' : value
  },
  beforeUpdate(el, { value, modifiers: { trim, number } }, vnode) {
    el._assign = getModelAssigner(vnode)
    // avoid clearing unresolved text. #2302
    if ((el as any).composing) return
    if (document.activeElement === el) {
      if (trim && el.value.trim() === value) {
        return
      }
      if ((number || el.type === 'number') && toNumber(el.value) === value) {
        return
      }
    }
    const newValue = value == null ? '' : value
    if (el.value !== newValue) {
      el.value = newValue
    }
  }
}
  1. 从实现来看,比上面尝试写的vModelText的实现,要多好多其他情况的处理

  2. 当执行created钩子时:

    • 拿到需要执行的函数: 通过getModelAssigner方法,从vnode的props上提取 onUpdate:modelValue的函数,对于本次的小栗子其实就是
     "onUpdate:modelValue": $event => (value = $event)
    

    后面的那个函数

    • 根据 lazy 来给 input 元素注册 change 或者 input 事件
    • 根据 trim 来给 input 元素再次注册一个change事件
    • 根据 lazy 来给 input 元素分别注册 compositionstart compositionend change 事件为了修复Safari < 10.2的bug
  3. 当执行mounted钩子时:

    • 为 type=“range” 的 input元素 做特殊处理
  4. 当执行beforeUpdate钩子时:

    • 拿到需要执行的函数
    • 避免清除未解析的文本
    • 给input.value赋值
  5. 上面代码小总结:

    • 调用created钩子时注册相关事件,当input值发生改变后,调用onUpdate:modelValue函数给value赋值
    • 当value值改变时,调用beforeUpdate钩子,把value值赋值给input.value
    • 这就达到了双向绑定(话说,我觉得最好加个beforeUnmount钩子,把created钩子中注册的事件移除掉)

总结

一个优秀的库真不容易,要处理那么多特殊情况

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

版权声明:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 作者: 徐志伟酱 原文链接:https://juejin.im/post/6933509352154365959

回到顶部