Skip to content

Latest commit

 

History

History
702 lines (595 loc) · 24.6 KB

v-model.md

File metadata and controls

702 lines (595 loc) · 24.6 KB

本篇文章,我们来看看Vuev-model指令的实现,它也是内置指令中,实现最复杂的一个。它涉及到selectinputtextarea等多种标签,input又分为checkboxradio等多种类型。本篇文章可能会有些长,因为不同标签、类型的处理方式不同,我个人希望每一个的实现都过一下。

我们一起来揭开它的神秘面纱~

ast生成的处理流程,和其他普通指令都差不多,唯一不同的是,这里多了一个校验处理。

function processAttrs (el) {
  ...
  addDirective(el, name, rawName, value, arg, modifiers)
  if (process.env.NODE_ENV !== 'production' && name === 'model'){
    checkForAliasModel(el, value)
  }
  ...
}
function checkForAliasModel (el, value) {
  let _el = el
  while (_el) {
    if (_el.for && _el.alias === value) {
      warn(
        `<${el.tag} v-model="${value}">: ` +
        `You are binding v-model directly to a v-for iteration alias. ` +
        `This will not be able to modify the v-for source array because ` +
        `writing to the alias is like modifying a function local variable. ` +
        `Consider using an array of objects and use v-model on an object property instead.`
      )
    }
    _el = _el.parent
  }
}

上面的校验是告诉我们,不能用for循环的值来作为value。如下例子会报错:

<div id="app">
  <p v-for="item in value">
    <input v-model="item"/>
  </p>
</div>
<script type="text/javascript">
  var vm = new Vue({
    el: '#app',
    data: {
      value: ['test','test1']
    }
  }).$mount('#app');
</script>

类似于v-textv-html等指令,在函数生成时,platformDirectives中内置了对v-model的处理。

export default function model (
  el: ASTElement,
  dir: ASTDirective,
  _warn: Function
): ?boolean {
  warn = _warn
  const value = dir.value
  const modifiers = dir.modifiers
  const tag = el.tag
  const type = el.attrsMap.type
  // input的type不支持动态绑定
  // file类型是只读的,不能用v-model
  ...
  if (tag === 'select') {
    genSelect(el, value, modifiers)
  } else if (tag === 'input' && type === 'checkbox') {
    genCheckboxModel(el, value, modifiers)
  } else if (tag === 'input' && type === 'radio') {
    genRadioModel(el, value, modifiers)
  } else if (tag === 'input' || tag === 'textarea') {
    genDefaultModel(el, value, modifiers)
  } else if (!config.isReservedTag(tag)) {
    genComponentModel(el, value, modifiers)
    // component v-model doesn't need extra runtime
    return false
  } else if (process.env.NODE_ENV !== 'production') {
    ...
	// 其它标签不支持v-model
  }
  // ensure runtime directive metadata
  return true
}

上面的代码,我精简了不合法提示的部分。我们可以看到,v-model的处理,主要分为四种情况,selectcheckboxradio其它input || textarea以及自定义标签。

并且上面的几种情况中,只有自定义标签返回了false,其它返回的都是true,说明自定义标签不会把v-model指令添加到directives中,也就不会在patch过程中有钩子函数操作。而其它情况,在patch过程中,还会有一些操作。

接下来,我就带着大家一起来看看每种情况的实现。

select

先来看一个例子

<div id="app">
  <select v-model="value">
    <option>1</option>
    <option>2</option>
    <option>3</option>
  </select>
  <p>{{value}}</p>
</div>
<script type="text/javascript">
  var vm = new Vue({
    el: '#app',
    data: {
      value: 3
    }
  }).$mount('#app');
</script>

上面的例子,是最简单的select绑定v-model的例子。从上面的分析我们可以看到它是调用genSelect处理的。

function genSelect (
    el: ASTElement,
    value: string,
    modifiers: ?ASTModifiers
) {
  const number = modifiers && modifiers.number
  const selectedVal = `Array.prototype.filter` +
    `.call($event.target.options,function(o){return o.selected})` +
    `.map(function(o){var val = "_value" in o ? o._value : o.value;` +
    `return ${number ? '_n(val)' : 'val'}})`

  const assignment = '$event.target.multiple ? $$selectedVal : $$selectedVal[0]'
  let code = `var $$selectedVal = ${selectedVal};`
  code = `${code} ${genAssignmentCode(value, assignment)}`
  addHandler(el, 'change', code, null, true)
}

number修饰符,表示值要作为数字来处理,_n函数其实就是把val转换成数字。selectedVal中的含义是先获取optionsselected的元素,然后依次获取_value || value的值。如果下拉列表是多选的,assignment值为数组,否则就是单个val

这里有一个比较重要的函数genAssignmentCode,所有情况的操作过程中,都用到了它。

export function genAssignmentCode (
  value: string,
  assignment: string
): string {
  const modelRs = parseModel(value)
  if (modelRs.idx === null) {
    return `${value}=${assignment}`
  } else {
    return `var $$exp = ${modelRs.exp}, $$idx = ${modelRs.idx};` +
      `if (!Array.isArray($$exp)){` +
        `${value}=${assignment}}` +
      `else{$$exp.splice($$idx, 1, ${assignment})}`
  }
}

parseModel是解析value,因为我们绑定value可以有多种情况,比如valuevalue.avalue['a']value[0]等。

export function parseModel (val: string): Object {
  str = val
  len = str.length
  index = expressionPos = expressionEndPos = 0
  // 没有中括号或不是以中括号结尾的
  if (val.indexOf('[') < 0 || val.lastIndexOf(']') < len - 1) {
    return {
      exp: val,
      idx: null
    }
  }

  while (!eof()) {
    chr = next()
    /* istanbul ignore if */
    if (isStringStart(chr)) {
      parseString(chr)
    } else if (chr === 0x5B) {
      parseBracket(chr)
    }
  }

  return {
    exp: val.substring(0, expressionPos),
    idx: val.substring(expressionPos + 1, expressionEndPos)
  }
}

如果有中括号,且是以中括号结尾的,则会执行一个while循环。该部分操作代码比较多,我就不列出来了,大体的过程是遍历每一个字符,最终找出与最后一个]对应的[,然后把[之前的内容放到exp中,中括号中间的内容放到idx中。例如value值为value[0],最终解析之后返回的值为{exp: "value", idx: "0"}

回到genAssignmentCode,如果modelRs.idxnull,则直接给value赋值,这时就会直接触发模板的更新。否则如果exp不是数组,也直接赋值,如果exp是数组,则会直接删除之前的值,并在原来的位置插入新的值。

genSelect的最后,会通过addHandler方法(我们在事件处理中讲过事件处理的整体流程),把生成的回调函数内容,添加到元素的change事件中,所以改变下拉框的值时,会触发change事件,进而会修改value的值,触发模板的整体更新。

我们上面的例子,最终生成的render函数字符串如下:

"with(this){return _c('div',{attrs:{"id":"app"}},[_c('select',{directives:[{name:"model",rawName:"v-model",value:(value),expression:"value"}],on:{"change":function($event){var $$selectedVal = Array.prototype.filter.call($event.target.options,function(o){return o.selected}).map(function(o){var val = "_value" in o ? o._value : o.value;return val}); value=$event.target.multiple ? $$selectedVal : $$selectedVal[0]}}},[_c('option',[_v("1")]),_v(" "),_c('option',[_v("2")]),_v(" "),_c('option',[_v("3")])]),_v(" "),_c('p',[_v(_s(value))])])}"

整体比较长,我们注意到selectdatadirectives中包含了我们的v-model指令,并且on中有一个change事件,对应的函数体就是刚才我们讲过的处理操作。

以上是编译阶段的操作,有了directives,我们在patch的过程中,还会调用相应的钩子函数来处理,runtime/directives/model.js中,主要有两个钩子函数insertedcomponentUpdated

inserted (el, binding, vnode) {
  if (vnode.tag === 'select') {
    const cb = () => {
      setSelected(el, binding, vnode.context)
    }
    cb()
    /* istanbul ignore if */
    if (isIE || isEdge) {
      setTimeout(cb, 0)
    }
  } else if (vnode.tag === 'textarea' || el.type === 'text' || el.type === 'password') {
    ...
  }
}

先来看inserted,它是在dom已经绘制到页面之后调用。这里主要调用了一个setSelected方法:

function setSelected (el, binding, vm) {
  const value = binding.value
  const isMultiple = el.multiple
  if (isMultiple && !Array.isArray(value)) {
    process.env.NODE_ENV !== 'production' && warn(
      `<select multiple v-model="${binding.expression}"> ` +
      `expects an Array value for its binding, but got ${
        Object.prototype.toString.call(value).slice(8, -1)
      }`,
      vm
    )
    return
  }
  let selected, option
  for (let i = 0, l = el.options.length; i < l; i++) {
    option = el.options[i]
    if (isMultiple) {
      selected = looseIndexOf(value, getValue(option)) > -1
      if (option.selected !== selected) {
        option.selected = selected
      }
    } else {
      if (looseEqual(getValue(option), value)) {
        if (el.selectedIndex !== i) {
          el.selectedIndex = i
        }
        return
      }
    }
  }
  if (!isMultiple) {
    el.selectedIndex = -1
  }
}

首先,如果我们的下拉列表是多选的,我们的value值必须是一个数组,否则会报错。

然后遍历所有的option,如果是多选下拉列表,则依次判断option的值是否在value中,如果在则选中。如果是单选下拉框,则是通过修改selectselectedIndex值来控制哪一项被选中。

componentUpdated (el, binding, vnode) {
  if (vnode.tag === 'select') {
    setSelected(el, binding, vnode.context)
    // in case the options rendered by v-for have changed,
    // it's possible that the value is out-of-sync with the rendered options.
    // detect such cases and filter out values that no longer has a matching
    // option in the DOM.
    const needReset = el.multiple
    ? binding.value.some(v => hasNoMatchingOption(v, el.options))
    : binding.value !== binding.oldValue && hasNoMatchingOption(binding.value, el.options)
    if (needReset) {
      trigger(el, 'change')
    }
  }
}

componentUpdated是在dom模板更新之后调用,我们看到这了只有对select的操作。首先同样是通过setSelected来设置元素被选中。

由于我们的option可能是通过v-for生成,如果v-for中的数据改变了,则option也会随之改变。

function hasNoMatchingOption (value, options) {
  for (let i = 0, l = options.length; i < l; i++) {
    if (looseEqual(getValue(options[i]), value)) {
      return false
    }
  }
  return true
}

hasNoMatchingOption是判断value的值中,是否都有option与之对应,如果是needReset返回false,否则返回true。如果有不匹配的,则触发一次元素的change事件,来更新数据和模板。

checkbox

同样看一个例子:

<div id="app">
  <input type="checkbox" v-model="value" true-value="1" false-value="0" />
  <p>{{value}}</p>
</div>
<script>
  var vm = new Vue({
    el: '#app',
    data: {
      value: 1
    }
  }).$mount('#app');
</script>

checkbox比较特殊,它有两种状态,一个真一个假。我们可以通过true-valuefalse-value分别设置复选框选中时和未选中时的value值。如上面的例子中,选中时value为1,未选中时为0。true-value的默认值是"true",同理false-value的默认值是"false"。如果我们没有设置,当我们改变复选框状态时,value的值就会是"true"或"false"。当然这只是基本的一种情况,value如果是一个数组,处理方式就会又有所不同。

它是由genCheckboxModel进行处理的。

function genCheckboxModel (
  el: ASTElement,
  value: string,
  modifiers: ?ASTModifiers
) {
  const number = modifiers && modifiers.number
  const valueBinding = getBindingAttr(el, 'value') || 'null'
  const trueValueBinding = getBindingAttr(el, 'true-value') || 'true'
  const falseValueBinding = getBindingAttr(el, 'false-value') || 'false'
  addProp(el, 'checked',
    `Array.isArray(${value})` +
      `?_i(${value},${valueBinding})>-1` + (
        trueValueBinding === 'true'
          ? `:(${value})`
          : `:_q(${value},${trueValueBinding})`
      )
  )
  addHandler(el, CHECKBOX_RADIO_TOKEN,
    `var $$a=${value},` +
        '$$el=$event.target,' +
        `$$c=$$el.checked?(${trueValueBinding}):(${falseValueBinding});` +
    'if(Array.isArray($$a)){' +
      `var $$v=${number ? '_n(' + valueBinding + ')' : valueBinding},` +
          '$$i=_i($$a,$$v);' +
      `if($$c){$$i<0&&(${value}=$$a.concat($$v))}` +
      `else{$$i>-1&&(${value}=$$a.slice(0,$$i).concat($$a.slice($$i+1)))}` +
    `}else{${value}=$$c}`,
    null, true
  )
}

从上面的代码中,我们看到除了true-valuefalse-value,我们还可以传一个value属性,它的默认值是null。它的作用我们稍后再说。

因为我们设置复选框是否选中,是通过checked属性来控制的。如果我们的value绑定的数据是一个数组,则判断我们设置的"value"属性值是否在数组中,如果在则返回true,不在则返回false。如果value绑定的数据不是一个数组,则判断trueValueBinding === 'true',如果返回真,则直接返回value绑定的值,否则判断valuetrueValueBinding绑定的值是否相等。以上都是在模板第一次初始化时的处理。

同样,为了做的数据的双向绑定,我们需要给元素添加事件回调。这里添加的事件时CHECKBOX_RADIO_TOKEN,在事件绑定的讲解中,我们提到在addEventListener之前,需要对on中的事件进行处理:

function normalizeEvents (on) {
  let event
  /* istanbul ignore if */
  if (on[RANGE_TOKEN]) {
    // IE input[type=range] only supports `change` event
    event = isIE ? 'change' : 'input'
    on[event] = [].concat(on[RANGE_TOKEN], on[event] || [])
    delete on[RANGE_TOKEN]
  }
  if (on[CHECKBOX_RADIO_TOKEN]) {
    // Chrome fires microtasks in between click/change, leads to #4521
    event = isChrome ? 'click' : 'change'
    on[event] = [].concat(on[CHECKBOX_RADIO_TOKEN], on[event] || [])
    delete on[CHECKBOX_RADIO_TOKEN]
  }
}

这里其实就是主要对CHECKBOX_RADIO_TOKENRANGE_TOKEN的处理。根据不同的浏览器,绑定不同的事件。

我们checkbox事件的处理就是下面的一大段字符串,我们把它整理成可读的JavaScript代码:

var $$a=${value},
  $$el=$event.target,
  $$c=$$el.checked?(${trueValueBinding}):(${falseValueBinding});
if(Array.isArray($$a)){
  var $$v=${number ? '_n(' + valueBinding + ')' : valueBinding},
    $$i=_i($$a,$$v);
  if($$c){
    $$i<0&&(${value}=$$a.concat($$v))
  } else {
    $$i>-1&&(${value}=$$a.slice(0,$$i).concat($$a.slice($$i+1)))
  }
} else {
  ${value}=$$c
}

赋值什么的就不多说了,

1、如果value绑定的值是数组

根据复选框的选中状态来获取trueValueBindingfalseValueBinding的值并添加到$$c上。如果$$c返回真且valueBinding的值不在value绑定的数组中,则把valueBingding的值添加到数组的最后。如果$$c返回假且valueBinding的值在value绑定的数组中,则从数组中删除该值。

2、如果value绑定的值不是数组

直接把$$c的值赋值给数组

value不是数组的情况,我们上面的例子已经满足,我们在给一个value是数组的例子:

<div id="app">
 	<input type="checkbox" v-model.number="trueVal" value="3" />
 	<p>{{trueVal}}</p>
</div>
<script>
  var vm = new Vue({
    el: '#app',
    data: {
      trueVal: [1,2]
    }
  }).$mount('#app');
</script>

该例子运行,我们可以看到因为trueVal的值不包括3,所以初始情况复选框是没有被选中的。我们改变复选框的选中状态,发现选中时trueVal的值为[1,2,3],未选中时值为[1,2]

radio

radio的处理比较简单,我们还是来看一个例子:

<div id="app">
  <input type="radio" v-model="value" value="1" />
  <input type="radio" v-model="value" value="0" />
  <p>{{value}}</p>
</div>
<script>
var vm = new Vue({
  el: '#app',
  data: {
    value: 1
  }
}).$mount('#app');
</script>

我们需要给每个radio都添加一个value属性。然后通过判断value绑定的值与它是否相同。

它的处理是genRadioModel方法:

function genRadioModel (
    el: ASTElement,
    value: string,
    modifiers: ?ASTModifiers
) {
  const number = modifiers && modifiers.number
  let valueBinding = getBindingAttr(el, 'value') || 'null'
  valueBinding = number ? `_n(${valueBinding})` : valueBinding
  addProp(el, 'checked', `_q(${value},${valueBinding})`)
  addHandler(el, CHECKBOX_RADIO_TOKEN, genAssignmentCode(value, valueBinding), null, true)
}

初始化的时候,就判断value绑定的值与value属性的值是否相同,双向绑定是通过把valueBinding赋值给value属性而实现的。

其它 inputtextarea

除了上面提到的selectfilecheckboxradio,我们还有其它多种类型的input以及textarea可以使用v-model来实现双向绑定。它的处理方法是genDefaultModel

function genDefaultModel (
  el: ASTElement,
  value: string,
  modifiers: ?ASTModifiers
): ?boolean {
  const type = el.attrsMap.type
  const { lazy, number, trim } = modifiers || {}
  const needCompositionGuard = !lazy && type !== 'range'
  const event = lazy
    ? 'change'
    : type === 'range'
      ? RANGE_TOKEN
      : 'input'

  let valueExpression = '$event.target.value'
  if (trim) {
    valueExpression = `$event.target.value.trim()`
  }
  if (number) {
    valueExpression = `_n(${valueExpression})`
  }

  let code = genAssignmentCode(value, valueExpression)
  if (needCompositionGuard) {
    code = `if($event.target.composing)return;${code}`
  }

  addProp(el, 'value', `(${value})`)
  addHandler(el, event, code, null, true)
  if (trim || number || type === 'number') {
    addHandler(el, 'blur', '$forceUpdate()')
  }
}

这里我们会处理三种修饰符lazynumbertrim,如果设置了lazy修饰符,则绑定change事件,否则绑定input事件。numbertrim从字面意思,大家应该也都知道干什么的了。

这里不同的是对range类型单独做了处理,RANGE_TOKEN事件和上面的CHECKBOX_RADIO_TOKEN类似,绑定时根据浏览器来确定绑定什么事件。

needCompositionGuard是干什么呢?我们在使用输入法打汉字时,拼音会显示到输入框中,这个时候是会触发input事件的,它的处理就是判断用户如果是输入法模式,则直接return$event.target.composing不是一个标准的属性,浏览器中有三个相关的事件,分别是onCompositionStartonCompositionUpdateonCompositionEndonCompositionStart是开始用输入法打字时触发,onCompositionUpdate是过程中触发,onCompositionEnd是结束时触发。有兴趣的可以看一下这里

Vue中,其实就是通过在onCompositionStartonCompositionEnd事件中改变$event.target.composing的值来实现的,我们稍后介绍。

genDefaultModel中剩下的处理也比较简单了,就是初始化时设置值,然后绑定事件。最后如果有trimnumber修饰符,或类型是number,则在blur时触发模板更新。

我们上面也说过,v-modelpatch的过程中,还会调用相应的钩子函数来处理,composing就是在inserted中处理的。

inserted (el, binding, vnode) {
  if (vnode.tag === 'select') {
    ...
  } else if (vnode.tag === 'textarea' || el.type === 'text' || el.type === 'password') {
    el._vModifiers = binding.modifiers
    if (!binding.modifiers.lazy) {
      if (!isAndroid) {
        el.addEventListener('compositionstart', onCompositionStart)
        el.addEventListener('compositionend', onCompositionEnd)
      }
      ...
    }
  }
}
function onCompositionStart (e) {
  e.target.composing = true
}
function onCompositionEnd (e) {
  e.target.composing = false
  trigger(e.target, 'input')
}

自定义组件

除了在selectinputtextarea上可以绑定v-model,在自定义组件上,我们也同样可以,不过处理的流程大不相同。

该情况的处理方法是genComponentModel,与之前不同,这一次返回的是false,也就是说这种情况下v-model不会添加到directives数组中,之后对指令的操作和v-model没有任何关系。

export function genComponentModel (
  el: ASTElement,
  value: string,
  modifiers: ?ASTModifiers
): ?boolean {
  const { number, trim } = modifiers || {}

  const baseValueExpression = '$$v'
  let valueExpression = baseValueExpression
  if (trim) {
    valueExpression =
      `(typeof ${baseValueExpression} === 'string'` +
        `? ${baseValueExpression}.trim()` +
        `: ${baseValueExpression})`
  }
  if (number) {
    valueExpression = `_n(${valueExpression})`
  }
  const assignment = genAssignmentCode(value, valueExpression)

  el.model = {
    value: `(${value})`,
    expression: `"${value}"`,
    callback: `function (${baseValueExpression}) {${assignment}}`
  }
}

numbertrim修饰符不多说,最终会在元素的ast上添加一个model属性,它的值是一个对象,包含三个属性,其中callback是类似于之前添加到事件上的回调函数。

回到src/compiler/codegen/index.js中,在genData方法的中,我们第一个对指令进行处理,在最后有一个对el.model的处理。

function genData (el: ASTElement): string {
  let data = '{'

  // directives first.
  // directives may mutate the el's other properties before they are generated.
  const dirs = genDirectives(el)
  if (dirs) data += dirs + ','

  ...
  // component v-model
  if (el.model) {
    data += `model:{value:${
      el.model.value
    },callback:${
      el.model.callback
    },expression:${
      el.model.expression
    }},`
  }
  ...
  return data
}

最终,我们会在data数据中,添加一个model属性。

例如

<div id="app">
  <my-component v-model="value"></my-component>
</div>

生成的render函数字符串为:

"with(this){return _c('div',{attrs:{"id":"app"}},[_c('my-component',{model:{value:(value),callback:function ($$v) {value=$$v},expression:"value"}})],1)}"

自定义组件在创建VNode对象时,会调用createComponent方法。

export function createComponent (
  Ctor: Class<Component> | Function | Object | void,
  data?: VNodeData,
  context: Component,
  children: ?Array<VNode>,
  tag?: string
): VNode | void {
  ...
  // transform component v-model data into props & events
  if (data.model) {
    transformModel(Ctor.options, data)
  }
  ...
  const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
    data, undefined, undefined, undefined, context,
    { Ctor, propsData, listeners, tag, children }
  )
  return vnode
}

这里会对data.model属性进行处理。

function transformModel (options, data: any) {
  const prop = (options.model && options.model.prop) || 'value'
  const event = (options.model && options.model.event) || 'input'
  ;(data.props || (data.props = {}))[prop] = data.model.value
  const on = data.on || (data.on = {})
  if (on[event]) {
    on[event] = [data.model.callback].concat(on[event])
  } else {
    on[event] = data.model.callback
  }
}

文档中介绍过,因为用户可能把value属性用作别的目的,所以对于自定义组件Vue是允许用户自定义model的。从transformModel中可以看到,它是把data.model中的valuecallback分别添加到了data.propsdata.on中,把它分为了props和事件两个部分。propson的处理,之前也都讲过,这里就不再赘述。

最后给一个简单的例子:

<div id="app">
  <my-component v-model="value"></my-component>
  <p>{{value}}</p>
</div>
<script>
  var vm = new Vue({
    el: '#app',
    data: {
      value: "哈哈"
    },
    components: {
      myComponent: {
        template: '<p @click="change">子组件{{value}}</p>',
        props: ['value'],
        methods: {
          change: function(){
            this.$emit('input', "呵呵");
          }
        }
      }
    }
  }).$mount('#app');
</script>

当我们点击子组件的p标签时,会修改父组件中的value的值。