抛出问题

我们先来看一下下面这段代码

<template>
  <div>
    <div class="message">{{ info.message }}</div>
    <div><input v-model="info.message" type="text"></div>
    <button @click="change">click</button>
  </div>
</template>

<script>
  export default {
    data () {
      return {
        info: {}
      }
    },
    methods: {
      change () {
        this.info.message = 'hello world'
      }
    }
  }
</script>

上述代码很简单,就不做过多的解释了。

问题重现步骤

我现在对上述代码做两种操作:

  1. 一进页面先在输入框中输入hello vue
  2. 一进页面先点击 click 按钮进行赋值操作,再在输入框中输入hello vue

上述两种情况分别会出现什么现象呢?

第一种操作,当我们在输入框中输入hello vue的时候,class 为 message 的 div 中会联动出现hello vue,也就是说 info 中的message属性是响应式的

第二种操作,当我们先进行赋值操作,之后无论在输入框中输入什么内容,class 为message的 div 中都不会联动出现任何值,也就是说 info 中的 message 属性非响应式的

问题引发的猜想

查阅 vue 官方文档我们得知 vue 在初始化的时候会对 data 中所有已经定义的对象及其子属性进行遍历,给他们添加 getter 和 setter,使得他们变成响应式的(关于响应式这块之后会单开文章进行解析),但是 vue 不能检测对象属性的添加或删除。但是,可以使用 Vue.set(object, propertyName, value) 方法向嵌套对象添加响应式属性

基于上述描述,我们先看第一种操作。直接在输入框中输入hello vue,class 为 message 的 div 中会联动出现hello vue。但是我们看 data 中只定义了 info 对象,其中并没有定义 message 属性,message 属于新增属性。根据 vue 官方文档中说的,vue 不能检测对象属性的添加或删除,所以我猜测 vue 底层在解析 v-model 指令的时候,每当触发表单元素的监听事件(例如 input 事件),就会有 Vue.set() 操作,从而触发 setter

带着这个猜测,我们来看第二种操作。一进页面先点击 click 按钮,对 info.message 进行赋值,message 属于新增属性,根据官方文档中说的,此时 message 并不是响应式的,没问题。但是我们接着在 input 输入框中输入值,class 为message的 div 中没有联动出现任何值,根据我们对于第一种情况的猜测,当输入框监听到 input 事件的时候,会对 info 中的 message 进行 Vue.set() 操作,所以理论上就算一开始 click 中是对新增属性 message 直接赋值的,导致该属性并非响应式的,在经过输入框 input 事件中的 Vue.set() 操作之后,应该会变成响应式的,而现在呈现出来的情况并不是这样的啊,这是为什么呢?

聪明的你们应该已经猜到在 Vue.set() 底层源码中,应该是会判断 message 属性是否一开始就在 info 中,如果存在就只是进行单纯的赋值,不存在的话在进行响应式操作,绑定 getter 和 setter

但是光猜测肯定是不够的,我们要用事实说话,做到有理有据。接下来我们就去看下 vue 源码中 v-model 这块,看看是不是如我们猜想的一样

探索真相-源码分析

v-model 指令使用分为两种情况:一种是在表单元素上使用,另外一种是在组件上使用。我们今天分析的是第一种情况,也就是在表单元素上使用

v-model实现机制

我们先简单说下 v-model 的机制:v-model 会把它关联的响应式数据(如 info.message ),动态地绑定到表单元素的 value 属性上,然后监听表单元素的 input 事件:当 v-model 绑定的响应数据发生变化时,表单元素的 value 值也会同步变化;当表单元素接受用户的输入时,input 事件会触发,input 的回调逻辑会把表单元素 value 最新值同步赋值给 v-model 绑定的响应式数据。

v-model实现原理

我用来分析的源码是在vue官网安装模块里面下载的开发版本(2.6.10),便于调试

编译

我们今天讲的内容其实就是把模版编译成 render 函数的一个流程,这里不会对每步流程都展开讲解,我可以给出一个步骤实现的流程,大家有兴趣的话可以根据这个流程来阅读代码,提高效率
$mount()->compileToFunctions()->compile()->baseCompile()

真正的编译过程都是在这个 baseCompile() 里面执行,执行步骤可以分为三个过程

  1. 解析模版字符串生成AST
    const ast = parse(template.trim(), options)
  2. 优化语法树
    optimize(ast, options)
  3. 生成代码
    const code = generate(ast, options)

然后我们看下generate里面的代码,这也是我们今天讲的重点

function generate (
  ast,
  options
) {
  var state = new CodegenState(options);
  var code = ast ? genElement(ast, state) : '_c("div")';
  return {
    render: ("with(this){return " + code + "}"),
    staticRenderFns: state.staticRenderFns
  }
}

generate() 首先通过 genElement()->genData$2()->genDirectives() 生成 code,再把 code 用 with(this){return ${code}}} 包裹起来,最终的到 render 函数。
接下来我们从genDirectives()开始讲解

genDirectives

在模板的编译阶段,v-model 跟其他指令一样,会被解析到 el.directives 中,之后会通过genDirectives方法处理这些指令,我们这里从genDirectives()重点开始讲,至于怎么到这步,如果大家感兴趣的话,可以从generate()开始看

function genDirectives (el, state) {
  var dirs = el.directives;
  if (!dirs) { return }
  var res = 'directives:[';
  var hasRuntime = false;
  var i, l, dir, needRuntime;
  for (i = 0, l = dirs.length; i < l; i++) {
    dir = dirs[i];
    needRuntime = true;
    var gen = state.directives[dir.name];
    if (gen) {
      // compile-time directive that manipulates AST.
      // returns true if it also needs a runtime counterpart.
      needRuntime = !!gen(el, dir, state.warn);
    }
    if (needRuntime) {
      hasRuntime = true;
      res += "{name:\"" + (dir.name) + "\",rawName:\"" + (dir.rawName) + "\"" + (dir.value ? (",value:(" + (dir.value) + "),expression:" + (JSON.stringify(dir.value))) : '') + (dir.arg ? (",arg:" + (dir.isDynamicArg ? dir.arg : ("\"" + (dir.arg) + "\""))) : '') + (dir.modifiers ? (",modifiers:" + (JSON.stringify(dir.modifiers))) : '') + "},";
    }
  }
  if (hasRuntime) {
    return res.slice(0, -1) + ']'
  }
}

我对上面这个代码打个断点,结合我们上面的代码例子,这样子看的更清楚,如下图:

我们可以看到传进来的el是Ast语法树,el.directives是el上的指令,在我们这里就是el-model的相关参数,然后赋值给变量dirs

往下看代码,for循环中有段代码:

var gen = state.directives[dir.name];
if (gen) {
  // compile-time directive that manipulates AST.
  // returns true if it also needs a runtime counterpart.
  needRuntime = !!gen(el, dir, state.warn);
}

这里面的state.dirctives是什么呢?打个断点看一下,如下图:

我们可以看到state.directives里面包含了很多指令方法,model 就在其中,

var gen = state.directives[dir.name];

其实就是等价于

var gen = state.directives[model];

所以代码中的变量 gen 得到的是model()

needRuntime = !!gen(el, dir, state.warn);

其实就是执行了model()

model

那我们再来看看model这个方法里面做了些什么事情,先上model的代码:

function model (el,dir,_warn) {
  warn$1 = _warn;
  var value = dir.value;
  var modifiers = dir.modifiers;
  var tag = el.tag;
  var type = el.attrsMap.type;

  {
    // inputs with type="file" are read only and setting the input's
    // value will throw an error.
    if (tag === 'input' && type === 'file') {
      warn$1(
        "<" + (el.tag) + " v-model=\"" + value + "\" type=\"file\">:\n" +
        "File inputs are read only. Use a v-on:change listener instead.",
        el.rawAttrsMap['v-model']
      );
    }
  }

  if (el.component) {
    genComponentModel(el, value, modifiers);
    // component v-model doesn't need extra runtime
    return false
  } else 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 {
    warn$1(
      "<" + (el.tag) + " v-model=\"" + value + "\">: " +
      "v-model is not supported on this element type. " +
      'If you are working with contenteditable, it\'s recommended to ' +
      'wrap a library dedicated for that purpose inside a custom component.',
      el.rawAttrsMap['v-model']
    );
  }

  // ensure runtime directive metadata
  return true
}

model 方法根据传入的参数对 tag 的类型进行判断,调用不同的处理逻辑,本 demo 中 tag 的类型为 input,所以会执行genDefaultModel方法

genDefaultModel

function genDefaultModel (el,value,modifiers) {
  var type = el.attrsMap.type;
  {
    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',
        el.rawAttrsMap[binding]
      );
    }
  }

  var ref = modifiers || {};
  var lazy = ref.lazy;
  var number = ref.number;
  var trim = ref.trim;
  var needCompositionGuard = !lazy && type !== 'range';
  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) {
    code = "if($event.target.composing)return;" + code;
  }

  addProp(el, 'value', ("(" + value + ")"));
  addHandler(el, event, code, null, true);
  if (trim || number) {
    addHandler(el, 'blur', '$forceUpdate()');
  }
}

我们对genDefaultModel()中的代码进行分块解析,首先看下面这段代码:

是否同时具有指令v-modelv-bind

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',
    el.rawAttrsMap[binding]
  );
}

这块代码其实就是解释表单元素是否同时有指令v-modelv-bind

var ref = modifiers || {};
var lazy = ref.lazy;
var number = ref.number;
var trim = ref.trim;

修饰符: 这段代码就是获取修饰符 lazy, number 及 trim

  1. .lazy 取代 input 监听 change 事件
  2. .number 输入字符串转为数字
  3. .trim 输入首尾空格过滤
var needCompositionGuard = !lazy && type !== 'range';

这里的needCompositionGuard后面再说有什么用,现在只用知道默认是 true 就行了

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 + ")";
}

上面这段代码中,event = 'input',定义变量 valueExpression,修饰符 trim 和 number 在我们这个 demo 中默认都没有,所以跳过往下看

genAssignmentCode

var code = genAssignmentCode(value, valueExpression);
if (needCompositionGuard) {
  code = "if($event.target.composing)return;" + code;
}

这里涉及到一个函数 genAssignmentCode,上源码:

function genAssignmentCode (
  value,
  assignment
) {
  var res = parseModel(value);
  if (res.key === null) {
    return (value + "=" + assignment)
  } else {
    return ("$set(" + (res.exp) + ", " + (res.key) + ", " + assignment + ")")
  }
}

这段代码是生成v-model绑定的value的值,看到这段代码,我们就知道离真相不远了,因为我们看到了$set()。现在我们通过断点具体分析下,如下图:

通过断点我们可以很清楚的看到我们先执行parseModel('info.message')获取到一个对象 res,由于我们的 demo 中绑定的值是路径形式的对象,即 info.message,所以此时 res 通过 parseModel 解析出来就是{exp: "info", key: "message"}。那下面的判断就进入 else,即:

return ("$set(" + (res.exp) + ", " + (res.key) + ", " + assignment + ")")

回到上面的getDefaultModel()中

var code = genAssignmentCode(value, valueExpression);
if (needCompositionGuard) {
  code = "if($event.target.composing)return;" + code;
}

此时 code 获取到genAssignmentCode()返回的字符串值"$set(info, "message", $event.target.value)"

$event.target.composing

上面我说的到变量needCompositionGuard = true,经过拼接,最终code = “if($event.target.composing)return;$set(info, "message", $event.target.value)”

这里的$event.target.composing有什么用呢?其实就是用于判断此次 input 事件是否是 IME 构成触发的,如果是 IME 构成,直接 return。IME 是输入法编辑器(Input Method Editor) 的英文缩写,IME 构成指我们在输入文字时,处于未确认状态的文字。如图:

带下划线的ceshi就属于IME构成,它会同样会触发input事件,但不会触发v-model更新数据。

继续往下看

addProp(el, 'value', ("(" + value + ")"));
addHandler(el, event, code, null, true);
if (trim || number) {
  addHandler(el, 'blur', '$forceUpdate()');
}

addProp

先说下addProp(el, 'value', ("(" + value + ")")),

function addProp (el, name, value, range, dynamic) {
  (el.props || (el.props = [])).push(rangeSetItem({ name: name, value: value, dynamic: dynamic }, range));
  el.plain = false;
}

照常打个断点看下:如下图

可以看到此方法的功能为给 el 添加 props,首先判断 el 上有没有 props,如果没有的话创建 props 并赋值为一个空数组,随后拼接对象并推到 props 中,代码在此 demo 中相当于push{name: "value", value: "(info.message)"}

如果一直往下追,可以看到这个方法其实是在 input 输入框上绑定了 value,对照我们的 demo 来看,就是将<input v-model=”info.message” type=”text”>变成<input v-bind:value=”info.message” type=”text”>

addHandler

同样的,addHandler()相当于在input上绑定了input事件,最终我们demo的模版就会被编译成

<input v-bind:value="info.message" v-on:input="info.message=$event.target.value">

render

后续再根据一些指令拼接,我们最终的到的render如下:

with(this) {
  return _c('div', {
    attrs: {
      "id": "app-2"
    }
  }, [_c('div', [_v(_s(info.message))]), _v(" "), _c('div', [_c('input', {
    directives: [{
      name: "model",
      rawName: "v-model",
      value: (info.message),
      expression: "info.message"
    }],
    attrs: {
      "type": "text"
    },
    domProps: {
      "value": (info.message)
    },
    on: {
      "input": function ($event) {
          if ($event.target.composing) return;
          $set(info, "message", $event.target.value)
      }
    }
  })]), _v(" "), _c('button', {
    on: {
        "click": change
    }
  }, [_v("click")])])
}

最后通过createFunction()把 render 代码串通过 new Function 的方式转换成可执行的函数,赋值给 vm.options.render,这样当组件通过vm._render的时候,就会执行这个 render 函数

至此,针对表单元素上的 v-model 指令从开始编译到最终生成render()并执行的过程就讲解完了,我们验证了在编译阶段,v-model 会在监听到 input 事件时对我们绑定的 value 进行Vue.$set()操作

还记得我们上面说的对 demo 第二种操作情况么?先进行 click 操作赋值,那 v-model 中的Vue.$set()操作似乎没有作用了。我们当时猜测的是Vue.$set()底层源码中有应该是会判断 message 属性是否一开始就在 info 中,如果存在就只是进行单纯的赋值,不存在的话在进行响应式操作,绑定 getter 和 setter

现在我们就去Vue.$set()中看一下

set

先上代码:

/**
   * Set a property on an object. Adds the new property and
   * triggers change notification if the property doesn't
   * already exist.
   */
  function set (target, key, val) {
  if (isUndef(target) || isPrimitive(target)
  ) {
    warn(("Cannot set reactive property on undefined, null, or primitive value: " + ((target))));
  }
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key);
    target.splice(key, 1, val);
    return val
  }
  if (key in target && !(key in Object.prototype)) {
    target[key] = val;
    return val
  }
  var ob = (target).__ob__;
  if (target._isVue || (ob && ob.vmCount)) {
    warn(
      'Avoid adding reactive properties to a Vue instance or its root $data ' +
      'at runtime - declare it upfront in the data option.'
    );
    return val
  }
  if (!ob) {
    target[key] = val;
    return val
  }
  defineReactive$$1(ob.value, key, val);
  ob.dep.notify();
  return val
}

看到这句代码了么?这就是证据,验证我们猜想的证据

if (key in target && !(key in Object.prototype)) {
  target[key] = val;
  return val
}

验证猜想

当我们首先点击 click 的时候,执行this.info.message = 'hello world',此时 info 对象中新增了一个 message 属性。当我们在 input 框中输入值并触发Vue.$set()时,key in target为true,并且 message 又不是 Object 原型上的属性,所以!(key in Object.prototype)也为 true,此时 message 属性并不是响应式属性,没有绑定 setter,所以仅仅进行了单纯的赋值操作。

而当我们一进页面首次在 input 中执行输入操作时,根据上面我们的分析 input 框监听到了 input 事件,先执行了Vue.$set()操作,因为时首次,所以 info 中还没有 message 属性,所以上面的key in target 为 false,跳过了赋值操作,到了下面的

defineReactive$$1(ob.value, key, val);
ob.dep.notify();

这个 defineReactive 的作用就是为 message 绑定了getter()setter(),之后再对 message 的赋值操作都会直接进入自身绑定的 setter 中进行响应式操作

一个意外的发现

我突然奇想把 vue 的版本换到了 2.3.0,发现 v-model 不能对 demo 中的 message 属性实现响应化,跑去看了下 vue 更新日志,发现在 2.5.0 版本中,有这么一句话
now creates non-existent properties as reactive (non-recursive) e1da0d5, closes #5932 (See reasoning behind this change)
上面这句话的意思是从 2.5.0 版本开始支持将不存在的属性响应化,非递归的。
因为 message 属性一开始在info中并没有定义,在 2.3.0 中,还不支持将不存在的属性响应化的操作,所以对 demo 无效

总结

到这里,我们这篇文章就结束了 里面有一些细节如果大家有兴趣的话可以自己再去深究一下。有时候很小的一个问题,背后牵扯到的知识点也是很多的,尽量把每个不懂背后的逻辑搞清楚,才能尽快的成为你想成为的人

参考链接

v-model源码解析(超详细)
Vue源码解读之v-model
[Vue源码分析] v-model实现原理