前言

上一篇文章主要了解Vue的数据对象的构建,实际主要是attrs、props和DomProps的比较,而在template形式中有个关键点就是特殊属性的处理,而数据属性中特殊属性的处理实际上就会涉及到v-model语法糖。

本文的目标有两个:

  • v-model语法糖的实现逻辑
  • 涉及特殊属性的v-model的特殊处理

v-model实现逻辑

从简单实例出发,来梳理v-model的处理逻辑:

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

<script>
  export default {
    data() {
      return {
        text: '测试'
      }
    }
  }
</script>

实际上这里只需要从processAttrs中的逻辑开始即可,前面的处理实际上之前的文章都有说明,这里就不在描述了(可查看数据对象这篇文章)。

解析阶段

v-model虽然是Vue提供的语法糖,从类型上来说是Vue指令,对解析器来来说都是属性。所有属性的而处理都是通过processAttrs来处理的。

在这里插入图片描述
上图中就是processAttrs的主要处理逻辑,实际上可以清晰的看到属性的处理就是4种:

  • 普通属性
  • prop
  • v-on事件绑定
  • 指令

而v-model就是指令,Vue所有指令都是以v-开头的,所以就具体看下指令的处理逻辑,代码量不多具体如下:

// v-model -> model,去除v-前缀
name = name.replace(dirRE, '');
// /:(.*)$/,例如v-model:test
var argMatch = name.match(argRE);
var arg = argMatch && argMatch[1];
if (arg) {
  name = name.slice(0, -(arg.length + 1));
}
// 添加到directives对象中
addDirective(el, name, rawName, value, arg, modifiers);
if ("development" !== 'production' && name === 'model') {
  checkForAliasModel(el, value);
}

通过processAttrs会将v-model保存到directives对象中,在directives中保存的model的数据结构如下:

{
    arg: null
	modifiers: undefined
	name: "model"
	rawName: "v-model"
	value: "text"
}

下一步需要专注的逻辑点就是依据数据对象来创建render函数,实际上就是generate函数的具体逻辑。

构建Render函数阶段

当对模板解析完成后即生成AST之后,就调用generate函数做一些编译处理,输出最终的code。

function generate (
  ast,
  options
) {
  var state = new CodegenState(options);
  // ast存在就会调用genElement输出最终的code
  var code = ast ? genElement(ast, state) : '_c("div")';
  return {
    render: ("with(this){return " + code + "}"),
    staticRenderFns: state.staticRenderFns
  }
}

generate中的主要处理逻辑就是genElement函数,genElement函数中会区分组件和原生标签的处理逻辑,简单来说就是:

  • 对于组件,会调用genComponent函数来生成对应code
  • 对于原生标签,直接调用genData$2来生成对应code、调用genChildren来生成对应子内容code

实际上genComponen函数内部逻辑也是调用genData$2、genChildren来生成对应的code和children code,但是顺序不同。

对于组件,是先生成子内容的code,再生成自身code即先调用genChildren、再调用genData$2

所有逻辑都指向genData$2,该函数的处理逻辑是用来处理指令、key、ref、attrs、props等VNode的数据对象。v-model是Vue中的指令,genData$2中针对v-model有的相关处理逻辑如下:

// 指令的处理
var dirs = genDirectives(el, state);
if (dirs) { data += dirs + ','; }

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

上面的逻辑实际上分为两部分:

  • 首先是指令的处理,因为v-model也是指令所以会存在这部分的逻辑
  • model code的构建,这里比较明了
genDirectives

genDirective函数的逻辑主要如下图:
在这里插入图片描述
从上面逻辑可知,对于组件来说最后v-model的处理需要调用genComponentModel函数来处理相关逻辑。

function genComponentModel (
  el,
  value,
  modifiers
) {
  var ref = modifiers || {};
  // .number修饰符、.trim修饰符
  var number = ref.number;
  var trim = ref.trim;

  var baseValueExpression = '$$v';
  var valueExpression = baseValueExpression;
  if (trim) {
    valueExpression =
      "(typeof " + baseValueExpression + " === 'string'" +
      "? " + baseValueExpression + ".trim()" +
      ": " + baseValueExpression + ")";
  }
  if (number) {
    valueExpression = "_n(" + valueExpression + ")";
  }
  var assignment = genAssignmentCode(value, valueExpression);
    
  // 增加model属性
  el.model = {
    value: ("(" + value + ")"),
    expression: ("\"" + value + "\""),
    callback: ("function (" + baseValueExpression + ") {" + assignment + "}")
  };
}

从上面逻辑中可以提取出几个关键的逻辑:

  • 指令的trim、number修饰符的支持逻辑
  • genAssignmentCode生成对应的code
  • model对象的构建:value、callback、expression

这里结合上面实例来说,构建出的model对象如下:

model:{
  value:(value),
  callback:function ($$v) {value=$$v},
  expression:"value"
}

当在组件上使用v-model指令,该指令会归属于应用的组件,子组件内部触发相关事件,v-model是如何实现双向绑定的呢?

从上面的结构可知,必然是调用callback函数,来实现双向绑定效果的

Vue官方对于v-model的简单概括就是:props属性 + input/change事件。从上面知道内部触发input/change事件就会执行相关事件的回调函数,这里涉及到组件事件的处理。

对于组件v-model指令,在render生成阶段是没有注册相关事件,这里先说明下Vue中关于子组件的创建处理实际上分为两个阶段:

  • 显式调用_c实例函数即render函数执行生成对应VNode对象
  • patch阶段createComponent触发VNode init hook实现Vue实例创建

对于组件,v-model的处理实际上在render调用阶段是有相关处理的,实际上就是注册相应事件。

render函数执行阶段

无论是自定义组件还是HTML标签都会调用_c实例方法即底层调用createElement,Vue提供的创建节点的方法(手动构建render函数也是需要显式调用该方法)。

createElement函数内部调用_createElement函数,这边核心的逻辑就是创建VNode,主要代码如下:

 if (typeof tag === 'string') {
    var Ctor;
   // html标签或svg标签
    if (config.isReservedTag(tag)) {
      vnode = new VNode(
        config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
      );
    } else if (isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // components中存在的组件
      vnode = createComponent(Ctor, data, context, children, tag);
    } else {
      vnode = new VNode(
        tag, data, children,
        undefined, undefined, context
      );
    }
  } else {
    // direct component options / constructor
    vnode = createComponent(tag, data, context, children);
  }

对于自定义组件会调用createComponent函数来处理,该函数相关逻辑如下:

createComponent函数
// transform component v-model data into props & events
if (isDef(data.model)) {
  transformModel(Ctor.options, data);
}
function transformModel (options, data) {
  // 默认prop是value
  var prop = (options.model && options.model.prop) || 'value';
  // 默认event是Input
  var event = (options.model && options.model.event) || 'input';
  // 保存到props属性中
  (data.props || (data.props = {}))[prop] = data.model.value;
  // 将input事件添加到on对象中
  var on = data.on || (data.on = {});
  if (isDef(on[event])) {
    on[event] = [data.model.callback].concat(on[event]);
  } else {
    on[event] = data.model.callback;
  }
}

从这里可以看出,当render函数调用期间会触发对子组件的创建,创建过程中会对v-model做事件定义:v-model默认的prop是value、event名称默认是input,其prop会保存到props属性中,事件与model对象上callback建立联系。

组件最终都会被转换成JSX数据对象形式,保存在虚拟对象VNode的data属性上

截止到这里,我们还没有看到事件被注册,只是看到事件并定义保存在虚拟对象VNode的data下on数据对象中。实际上这里on数据对象中保存的关于model对应的input/change事件只是临时保存的,看createComponent后面的逻辑就知道了,具体逻辑如下:

 // extract listeners, since these needs to be treated as
  // child component listeners instead of DOM listeners
  // 提出事件,这些事件应该作为子组件的事件,而不是原生DOM事件
  var listeners = data.on;
  // replace with listeners with .native modifier
  // so it gets processed during parent component patch.
  data.on = data.nativeOn;
  // return a placeholder vnode
  var name = Ctor.options.name || tag;
  var vnode = new VNode(
    ("vue-component-" + (Ctor.cid) + (name ? ("-" + name) : '')),
    data, undefined, undefined, undefined, context,
    { Ctor: Ctor, propsData: propsData, listeners: listeners, tag: tag, children: children },
    asyncFactory
  );

由上面逻辑可知数据对象on中事件都会被保存到虚拟对象VNode的componentOptions中,而数据对象on会被数据对象nativeOn覆盖掉。

此时v-model的事件定义都保存在componentOptions属性中,该属性时组件配置项,用于创建Vue实例的。那么事件是在哪里被注册的呢?

答案是在patch阶段createComponent,该函数会触发子组件的Vue实例化过程,则执行_init实例方法,而在该实例方法中有一个逻辑是至关重要的

    if (options && options._isComponent) {
      // optimize internal component instantiation
      // since dynamic options merging is pretty slow, and none of the
      // internal component options needs special treatment.
      initInternalComponent(vm, options);
    }

所有通过$createElement创建的组件都会存在_isComponent这个属性,所以可知对于内部组件(即render函数阶段触发创建组件创建都看成是内联组件)会优化选项处理。

function initInternalComponent (vm, options) {
  var opts = vm.$options = Object.create(vm.constructor.options);
  // doing this because it's faster than dynamic enumeration.
  var parentVnode = options._parentVnode;
  var vnodeComponentOptions = parentVnode.componentOptions;
  opts._parentListeners = vnodeComponentOptions.listeners;
}

function initEvents (vm) {
  vm._events = Object.create(null);
  vm._hasHookEvent = false;
  // 对于render函数创建createElemnt
  var listeners = vm.$options._parentListeners;
  if (listeners) {
    updateComponentListeners(vm, listeners);
  }
}

为什么这里是parentVnode?

实际上是因为render函数中组件创建调用$createElement,而针对于自定义组件会调用createComponent函数,该函数内部对于会调用Vue.extend方法,所以会是parentVnode

实际上组件的v-model对应的事件会在这里被触发,即updateComponentListeners。

实际上可以大胆猜想,所有组件的更新操作都是通过updateComponentListeners函数来处理的

而updateComponentListeners最终还是调用updateListeners来实现事件更新操作,最终调用$on来实现组件事件的注册。

当emit相关事件,就会触发已经注册的v-model的input/change事件,从而实现双向绑定。

涉及特殊属性的v-model的特殊处理

首先明确下特殊属性:

var acceptValue = makeMap('input,textarea,option,select,progress');
var mustUseProp = function (tag, type, attr) {
  return (
    (attr === 'value' && acceptValue(tag)) && type !== 'button' ||
    (attr === 'selected' && tag === 'option') ||
    (attr === 'checked' && tag === 'input') ||
    (attr === 'muted' && tag === 'video')
  )
};

本次v-model实际实际上关注的是处了video之外,主要分为:

  • input[type=“radio”]
  • input[type=“checkbox”]
  • input[attr=“value”]

实际上从普通组件的v-model实现逻辑中可以看出可以总结出三个阶段的逻辑:

  • parse阶段中的processAttrs
  • render函数构建阶段genData$2
  • render函数执行_createElement中正对标签和组件的处理

而涉及特殊属性的处理:

  • parse阶段就不在关注了,即特殊属性都放在props对象中了
  • render函数构建阶段-genDirectives
  • render执行阶段-_createElement

Vue官网中就有对表单元素的v-model的说明:

v-model 会忽略所有表单元素的 valuecheckedselected 特性的初始值而总是将 Vue 实例的数据作为数据来源。

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

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

从之前的genDirectives中逻辑图中可知:

if (tag === 'input' && type === 'radio') {
  genRadioModel(el, value, modifiers);
}
function genRadioModel (
  el,
  value,
  modifiers
) {
  // .number
  var number = modifiers && modifiers.number;
  // value属性
  var valueBinding = getBindingAttr(el, 'value') || 'null';
  valueBinding = number ? ("_n(" + valueBinding + ")") : valueBinding;
  // 添加checked属性到props, _q对应的实例方法是looseEqual,比较两个数是否相等
  addProp(el, 'checked', ("_q(" + value + "," + valueBinding + ")"));
  // change事件
  addHandler(el, 'change', genAssignmentCode(value, valueBinding), null, true);
}

实际上从addHandler可以看到与组件v-model的区别了:

组件的v-model会在patch阶段组件Vue实例创建时触发updateComponentListeners来实现事件注册等操作,而表单元素的v-model直接在构建render函数阶段就已经注册了

input或textarea value

从之前的genDirectives中逻辑图中可知 input和textarea会调用genDefaultModel函数,而该函数的处理逻辑就相对radio复杂些。

function genDefaultModel (
  el,
  value,
  modifiers
) {
  var type = el.attrsMap.type;
  // 判断是否value作为prop,会与v-model冲突报警告
  {
    var value$1 = el.attrsMap['v-bind:value'] || el.attrsMap[':value'];
    var typeBinding = el.attrsMap['v-bind:type'] || el.attrsMap[':type'];
    if (value$1 && !typeBinding) {
      var binding = el.attrsMap['v-bind:value'] ? 'v-bind:value' : ':value';
      warn$1(
        binding + "=\"" + value$1 + "\" conflicts with v-model on the same element " +
        'because the latter already expands to a value binding internally'
      );
    }
  }

  var ref = modifiers || {};
  // .lazy修饰符
  var lazy = ref.lazy;
  // .number修饰符
  var number = ref.number;
  // .trim修饰符
  var trim = ref.trim;
  var needCompositionGuard = !lazy && type !== 'range';
  // lazy就使用change事件否则使用input事件,range的兼容处理:IE只支持change
  var event = lazy
    ? 'change'
    : type === 'range'
      ? RANGE_TOKEN
      : 'input';

  var valueExpression = '$event.target.value';
  if (trim) {
    valueExpression = "$event.target.value.trim()";
  }
  if (number) {
    valueExpression = "_n(" + valueExpression + ")";
  }

  var code = genAssignmentCode(value, valueExpression);
  if (needCompositionGuard) {
    // composing判断是否在输入中,防止输入中响应式属性更新
    code = "if($event.target.composing)return;" + code;
  }
  // value属性添加到props中
  addProp(el, 'value', ("(" + value + ")"));
  // 绑定事件
  addHandler(el, event, code, null, true);
  if (trim || number) {
    // blur事件,.trim、.number修饰符会强制更新
    addHandler(el, 'blur', '$forceUpdate()');
  }
}

实际上input或textarea的v-model的callback:

callback: function($event) {
  if ($event.target.composing)return;
  value=$event.target.value;
}
input checkbox

从之前的genDirectives中逻辑图中可知 input和textarea会调用genCheckboxModel函数,具体逻辑如下:

function genCheckboxModel (
  el,
  value,
  modifiers
) {
  var number = modifiers && modifiers.number;
  // value属性
  var valueBinding = getBindingAttr(el, 'value') || 'null';
  // 支持true-value和false-value属性,自定义属性非HTML标准特性
  var trueValueBinding = getBindingAttr(el, 'true-value') || 'true';
  var falseValueBinding = getBindingAttr(el, 'false-value') || 'false';
  // checked属性添加到props对象中
  addProp(el, 'checked',
    "Array.isArray(" + value + ")" +
    "?_i(" + value + "," + valueBinding + ")>-1" + (
      trueValueBinding === 'true'
        ? (":(" + value + ")")
        : (":_q(" + value + "," + trueValueBinding + ")")
    )
  );
  // v-model-change事件
  addHandler(el, 'change',
    "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($$el.checked){$$i<0&&(" + (genAssignmentCode(value, '$$a.concat([$$v])')) + ")}" +
      "else{$$i>-1&&(" + (genAssignmentCode(value, '$$a.slice(0,$$i).concat($$a.slice($$i+1))')) + ")}" +
    "}else{" + (genAssignmentCode(value, '$$c')) + "}",
    null, true
  );
}

这里需要注意的是checkbox如果v-model绑定的是数组类型的值的特殊处理。

<template>
	<div>
		<input v-model="checkList" type="checkbox" :value="1">复选框A</>
		<input v-model="checkList" type="checkbox" :value="2">复选框B</>
  </div>
</template>
<script>
  export default {
    data() {
      return {
        checkList: [1]
      };
    }
  }
</script>
// checked属性的计算逻辑
const checked = Array.isArray(value)
	// 判断checkList是否存在当前chekcbox的value属性值
  ? _i(checkList, valueBinding})>-1
	: trueValueBinding === true
  	? checkList
    : _q(checkList, trueValueBinding)

// change事件的函数体
const change = function($event) {
  let checkList = checkList;
  const target = $event.target;
  if (Array.isArray(checkList)) {
    const formatValue = number ? _n(valueBinding) : valueBinding;
    // 判断checkList是否存在当前chekcbox的value属性值,获取其下标
    const index = _i(checkList, formatValue);
    if (target.checked) {
      // 不存在的但是选中状态
      index < 0 && (checkList = checkList.concat([formatValue]));
    } else {
      index > -1 && (
       checkList = checkList
        .slice(0,index)
        .concat(checkList.slice(index+1))
      );
    }
  } else {
    checkList = target.checked
      ? trueValueBinding
    	: falseValueBinding;
  }
}
Select标签

从之前的genDirectives中逻辑图中可知select会调用genSelect函数,具体逻辑如下:

function genSelect (
  el,
  value,
  modifiers
) {
  // number修饰符
  var number = modifiers && modifiers.number;
  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 " + (number ? '_n(val)' : 'val') + "})";

  var assignment = '$event.target.multiple ? $$selectedVal : $$selectedVal[0]';
  var code = "var $$selectedVal = " + selectedVal + ";";
  code = code + " " + (genAssignmentCode(value, assignment));
  addHandler(el, 'change', code, null, true);
}
const change = function($event) {
  const { options, multiple } = $event.target;
  const selectedVal = 
    Array.prototype.filter.call(options, function(o) {
      return o.selected;
    })
    .map(function(o) {
      const val = "_value" in o ? o._value : o.value;
      return number ? _n(val) : val;
    });
  // value就是v-model绑定的值
  value = multiple ? selectedVal : selectedVal[0]
}

总结

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

  • text 和 textarea 元素使用 value属性和 input 事件

    value属性存放在props,不可在显式存在:value属性,否则会报warning

  • checkbox 和 radio 使用 checked 属性和 change事件

    checked属性存放在props中,支持数组类型值(value属性表示当前checkbox或radio的值)

  • select 字段将 value 作为 prop 并将 change作为事件,即value会存放在props中

实际上Vue对于表单元素的特殊处理,也是源于DOM操作表单元素的基础来的,比如DOM操作checkbox就是通过设置checked来实现选中和未选中的。

唯一不同的是Vue双向绑定,v-model会忽略所有表单元素的 value、checked、selected特性的初始值而总是将 Vue 实例的数据作为数据来源。

表单元素和组件上应用v-model其背后的处理逻辑存在较大差异,最大的不同在于事件注册的时机(表单元素v-model事件注册在render函数生成阶段,而组件的v-model事件是在patch阶段,其vue实例创建时调用updateComponentListener实现的)

Logo

前往低代码交流专区

更多推荐