v-model 指令的底层原理

1,694 阅读8分钟

基础用法

  • v-model 本质上不过是语法糖,可以用 v-model 指令在表单 <input><textarea> 及 <select> 元素上创建双向数据绑定。它会根据控件类型自动选取正确的方法来更新元素。它负责监听用户的输入事件以更新数据,并对一些极端场景进行一些特殊处理。v-model 会忽略所有表单元素的 valuecheckedselected 特性的初始值而总是将 Vue 实例的数据作为数据来源。你应该通过 JavaScript 在组件的 data 选项中声明初始值。

  • v-model 在内部为不同的输入元素使用不同的属性并抛出不同的事件:

    • text 和 textarea 元素使用 value 属性和 input 事件;
    • checkbox 和 radio 使用 checked 属性和 change 事件;
    • select 字段将 value 作为 prop 并将 change 作为事件。

实现原理

  • v-model只不过是一个语法糖而已,真正的实现靠的还是

    • v-bind:绑定响应式数据
    • 触发oninput 事件并传递数据
  • 举例如下:

<input v-model="sth" />
//  等同于
<input :value="sth" @input="sth = $event.target.value" /> 
//自html5开始,input每次输入都会触发oninput事件,所以输入时input的内容会绑定到sth中,于是sth的值就被改变;
//$event 指代当前触发的事件对象;
//$event.target 指代当前触发的事件对象的dom;
//$event.target.value 就是当前dom的value值;
//在@input方法中,value => sth;
//在:value中,sth => value;
  • v-model是双向绑定,即表单可以拿到vue中的数据,表单中的数据也可以传到vue中
    而v-bind:value 只能是表单拿到vue的数据,vue无法拿到表单的数据
    个人理解,有瑕疵以后补充

  • v-bind

    • 缩写::

    • 预期:any (with argument) | Object (without argument)

    • 参数:attrOrProp (optional)

    • 修饰符:\

      • .prop - 被用于绑定 DOM 属性。
      • .camel - (2.1.0+) 将 kebab-case 特性名转换为 camelCase.
      • .sync (2.3.0+) 语法糖,会扩展成一个更新父组件绑定值的 v-on 侦听器。
    • 用法:
      动态地绑定一个或多个特性,或一个组件 prop 到表达式。
      在绑定 class 或 style 特性时,支持其它类型的值,如数组或对象。可以通过下面的教程链接查看详情。
      在绑定 prop 时,prop 必须在子组件中声明。可以用修饰符指定不同的绑定类型。
      没有参数时,可以绑定到一个包含键值对的对象。注意此时 class 和 style 绑定不支持数组和对象。

    v-on

    • 缩写:@

    • 预期:Function | Inline Statement | Object

    • 参数:event

    • 修饰符:\

      • .stop - 调用 event.stopPropagation()。
      • .prevent - 调用 event.preventDefault()。
      • .capture - 添加事件侦听器时使用 capture 模式。
      • .self - 只当事件是从侦听器绑定的元素本身触发时才触发回调。
      • .{keyCode | keyAlias} - 只当事件是从特定键触发时才触发回调。
      • .native - 监听组件根元素的原生事件。
      • .once - 只触发一次回调。
      • .left - (2.2.0) 只当点击鼠标左键时触发。
      • .right - (2.2.0) 只当点击鼠标右键时触发。
      • .middle - (2.2.0) 只当点击鼠标中键时触发。
      • .passive - (2.3.0) 以 { passive: true } 模式添加侦听器
    • 用法:
      绑定事件监听器。事件类型由参数指定。表达式可以是一个方法的名字或一个内联语句,如果没有修饰符也可以省略。
      从 2.4.0 开始,v-on 同样支持不带参数绑定一个事件/监听器键值对的对象。注意当使用对象语法时,是不支持任何修饰器的。
      用在普通元素上时,只能监听 原生 DOM 事件。用在自定义元素组件上时,也可以监听子组件触发的自定义事件。
      在监听原生 DOM 事件时,方法以事件为唯一的参数。如果使用内联语句,语句可以访问一个 $event 属性:v-on:click="handle('ok', $event)"

      • Vue.js为两个最为常用的指令提供了特别的缩写:

    v-bind缩写

    <!--完整语法-->
    <a v-bind:href="url">测试</a>
    <!--缩写-->
    <a :href="url">测试</a>
    

    v-on缩写

    <!--完整语法-->
    <a v-on:click="doSomething">修改</a>
    <!--缩写-->
    <a @click="doSomething">修改</a>
    

# v-model 指令的底层原理

今天和大家讲讲 v-model 的底层原理,v-model 既可以用在自定义的组件上,也可以用在表单输入元素上,这两者在 v-model 的使用体验上几乎一模一样,但是底层的实现原理却有所差别,应该分开进行讨论解析。

不管是用在组件上还是用在原生的表单元素上,v-model 的本质都是语法糖,也就是说 Vue 通过编译器和 render 函数,使得在模板字符串中不管是使用 v-model="message" 还是使用 <component-a :value="message" @input="inputHandler">,最终生成的 vnode 是一样的,既然 vnode 都是一样的,那么通过 vnode 渲染出来的页面自然也是一样的。

1,v-model 用在自定义组件上 这里,需要重点关注自定义组件的 vnode 和自定义组件的 Vue 实例。

首先看看在自定义组件的场景下,在模板字符串中使用 v-model 和使用 :value、@input 最终生成的 vnode,我们使用如下的例子。

Vue.component('component-a', {
  template: `
    <div>
      <input :value="value" @input="updateValue">
    </div>
  `,
  props: ['value'],
  methods: {
    updateValue(e) {
      this.$emit('input', e.target.value)
    }
  }
})
 
// 模板字符串1,使用 v-model
let template1 = `
  <div id="app">
    <p>message is:{{message}}</p>
    <component-a v-model="message"></component-a>
  </div>
`
 
// 模板字符串2,使用 :value 和 @input
let template2 = `
  <div id="app">
    <p>message is:{{message}}</p>
    <component-a :value="message" @input="inputHandler"></component-a>
  </div>
`
 
new Vue({
  el: '#app',
  data() {
    return {
      message: ""
    }
  },
  methods: {
    inputHandler(v){
      this.message = v
    },
    inputHandlerOriginal(e){
      this.message = e.target.value
    }
  },
  template: template1
})

1-1,在自定义组件上使用 v-model 和使用 :value、@input 最终生成的 vnode

1-1-1,在自定义组件上使用 v-model 最终生成的 vnode

image.png

我们看 children 数组中的第三个元素,就是 component-a 组件对应的 vnode 节点对象,该对象中需要重点关注的是 componentOptions 对象,这个对象中的数据会用于 component-a 组件 Vue 实例的生成,从这个对象中的 propsData 和 listeners 属性可以发现,v-model 会被翻译成 value = ""、v-on:input=""。

1-1-2,在自定义组件上使用 :value、v-on:input 最终生成的 vnode

image.png 可以发现当在自定义组件上使用 :value 和 v-on:input 最终生成的组件 vnode 和上一小节使用 v-model 生成的组件 vnode 中的 propsData、listeners 属性是一样的。所以,这两种场景下,渲染到页面上,最终实现的效果也肯定是一样的。

1-2,自定义组件的 model 选项

建议先看看官网中有关这部分的介绍,点击这里。 在默认的情况下,组件上使用的 v-model 会被解析成名为 value 的 prop 和名为 input 的事件,但是在一些组件中,value 和 input 并不是很贴切。此时,我们可以使用组件中的 model 选项,指定该组件上的 v-model 会被解析成的 prop 和 event,我们在这里看看这个功能是如何实现的。

该功能实现的重点主要看组件 vnode 是如何创建出来的,对应的源码看 src/core/vdom/create-component.js >>> createComponent、transformModel。

createComponent 函数用于创建组件的 vnode,transformModel 用于处理 v-model。

export function createComponent (
  Ctor: Class<Component> | Function | Object | void,
  data: ?VNodeData,
  context: Component,
  children: ?Array<VNode>,
  tag?: string
): VNode | void {
  // 对组件 v-model 的处理
  if (isDef(data.model)) {
    transformModel(Ctor.options, data)
  }
 
  // 借助 VNode 类创建 vnode,并返回
  const vnode = new VNode(
    // 组件 VNode 的 tag 以 'vue-component-' 开头,这是很重要的标识
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
    data,
    // 组件 VNode 的 children、text 和 elm 都是空
    undefined, undefined, undefined,
    context,
    // 创建组件 VNode 时,参数是通过 componentOptions 来传递的
    // 包括 children,组件的 children 在插槽的功能中会用到
    { Ctor, propsData, listeners, tag, children },
    asyncFactory
  )
 
  return vnode
}

createComponent 函数的最后通过 new VNode() 生成组件的 vnode,并返回,不过在 new VNode() 之前需要对 new VNode() 所需的参数进行处理,其中就包括对 v-model 的处理,这部分的代码在 transformModel 函数中,直接看注释。

// 该函数用于处理 v-model 特性
function transformModel (options, data: any) {
  // 这里用于实现自定义组件中的 model 选项,可以自定义组件上的 v-model 被解析成的 prop 和 event
  //
  // 这里的 options 是自定义组件的配置对象,在这里,判断该组件有没有配置 model 选项,如果配置了的话,
  // 再判断 model 选项中有没有配置 prop 和 event,如果也配置了的话,则取配置的 prop 和 event 作为
  // v-model 会被解析的 prop 和 event。
  // 如果没有配置的话,则 v-model 默认解析成 'value' prop 和 'input' event。
  const prop = (options.model && options.model.prop) || 'value'
  const event = (options.model && options.model.event) || 'input'
 
  // 将 v-model 指定的值赋值到 data.props[prop] 中
  // 例如 v-model="message",message = 'hello',则在这里,默认情况下,data.props.value = 'hello'
  ;(data.props || (data.props = {}))[prop] = data.model.value
 
  // 将 v-model 对应的事件回调函数保存到 data.on[event] 中
  const on = data.on || (data.on = {})
  // 因为 event 事件有可能已经存在,所以需要额外的处理
  if (isDef(on[event])) {
    // 如果 event 事件已经存在的话,则将 data.on[event] 的属性值变成一个数组,数组中保存该事件所有的回调函数
    on[event] = [data.model.callback].concat(on[event])
  } else {
    // 如果 event 还不存在的话,直接将 data.model.callback 赋值给 data.on[event] 即可
    on[event] = data.model.callback
  }
}

2,v-model 用在表单元素上

2-1,在表单元素上使用 v-model 和使用 :value、@input 最终生成的 vnode

以下面的代码为例。

// 用在 DOM 元素上
let template3 = `
  <div id="app">
    <p>message is:{{message}}</p>
    <input placeholder="edit me" v-model="message"/>
  </div>
`
 
// 编译成 :value @input,再加上 v-model 指令的处理
 
let template4 = `
  <div id="app">
    <p>message is:{{message}}</p>
    <input placeholder="edit me" :value="message" @input="inputHandlerOriginal"/>
  </div>
`
 
new Vue({
  el: '#app',
  data() {
    return {
      message: ""
    }
  },
  methods: {
    inputHandler(v){
      this.message = v
    },
    inputHandlerOriginal(e){
      this.message = e.target.value
    }
  },
  template: template3
})

2-1-1,在表单元素上使用 v-model 生成的 vnode

表单元素上使用 v-model 生成的 vnode 如下所示。

image.png

v-model 会被解析成 domProps.value 和 on.input,当 input vnode patch 到页面上的时候,domProps.value 会被设置到 DOM 元素的 value 属性上,on.input 事件会被绑定当 input DOM 元素上。

2-1-2,在表单元素上使用 :value、@input 在表单元素上使用 :value、@input 生成的 vnode 如下所示。

image.png 可以发现生成的 vnode 和上一小节生成的 vnode 一样,也有 domProps.value 和 on.input,所以最终渲染到页面上时,和 v-model 的效果是等价的。

2-2,在表单元素上使用 v-model 和使用 :value、@input 存在一些小的差异

使用 :value 和 @input 绑定值和事件的表单元素在使用输入法输入汉字的时候,每次打的拼音字符会同步到状态,而使用 v-model 绑定的表单元素就不会有这种情况,看下图。

image.png

出现这种差异的原因是 v-model 指令除了会被翻译成 :value、@input,这个指令本身还会以自定义指令的形式做一些其他的操作,关于自定义指令的解析可以看我的这篇文章。

在这里,先看下上面没有说到的内容,就是 v-model 所翻译成的 input 事件对应的回调函数,该函数的内容如下所示。

on:{
  "input": function($event){
    if($event.target.composing)return;
    message=$event.target.value
  }
}

可以发现,input 事件对应的回调函数在对状态进行更新赋值前,首先会判断 event.target.composing是否为true,如果为true的话,则直接return,不做更新状态的操作,只有当event.target.composing 是否为 true,如果为 true 的话,则直接 return,不做更新状态的操作,只有当 event.target.composing 的值是 false 的时候,才会进行下一步的状态变更。所以,这个 $event.target.composing 属性就是实现上述功能特性的重点,与之相关的源码看 src/platforms/web/runtime/directives/model.js,注释写的很详细,看注释即可。

首先说下 input 元素中四个相关的事件:input、change、compositionstart、compositionend。

input:该事件在用户输入时触发,不管输入的是英文字符,还是拼音状态下的字符。

change:在用户输入的时候不会触发,只有在输入之后让 input 元素失去焦点之后才会触发该事件。

compositionstart:当输入法是中文输入法,用户输入时会触发该事件,并且该事件的触发时机早于 input 事件。

compositionend:当输入法是中文输入法,且字符输入完毕时,触发该事件。

export default {
  inserted (el, binding, vnode) {
    if (vnode.tag === 'select') {
      setSelected(el, binding, vnode.context)
      el._vOptions = [].map.call(el.options, getValue)
    } else if (vnode.tag === 'textarea' || isTextInputType(el.type)) {
      // 看这个分支
      el._vModifiers = binding.modifiers
      if (!binding.modifiers.lazy) {
        el.addEventListener('change', onCompositionEnd)
        if (!isAndroid) {
          // compositionstart 事件触发时,调用 onCompositionStart 函数,
          // 将 e.target.composing 设为 true
          el.addEventListener('compositionstart', onCompositionStart)
          // compositionend 事件触发时,调用 onCompositionEnd 函数,
          // 将 e.target.composing 设为 false,并且用代码触发 input 元素的 input 事件
          // 这会触发执行 v-model 指令所翻译成的 input 回调函数,在这个回调函数中,更新状态
          el.addEventListener('compositionend', onCompositionEnd)
          // 通过上面两个事件及回调函数,就可以保证在输入拼音字符的过程中,v-model 对应的状态不被改变,
          // 这是因为在输入拼音字符的过程中,$event.target.composing 为 true,
          // 只有当拼音输入完毕之后,$event.target.composing 才为 false
        }
        /* istanbul ignore if */
        if (isIE9) {
          el.vmodel = true
        }
      }
    }
  },
  componentUpdated (el, binding, vnode) {}
}
 
function onCompositionStart (e) {
  // 将 e.target.composing 属性设置为 true
  e.target.composing = true
}
function onCompositionEnd (e) {
  // prevent triggering an input event for no reason
  if (!e.target.composing) return
  // 将 e.target.composing 属性设置为 true
  e.target.composing = false
  // 用代码触发 input 事件
  trigger(e.target, 'input')
}
// 触发事件的工具函数
function trigger (el, type) {
  const e = document.createEvent('HTMLEvents')
  e.initEvent(type, true, true)
  el.dispatchEvent(e)
}