9

抛出问题

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

<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中所有已经定义的对象及其子属性进行遍历,给他们添加gettersetter,使得他们变成响应式的,但是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中,如果存在就只是进行单纯的赋值,不存在的话在进行响应式操作,绑定gettersetter

但是光猜测肯定是不够的,我们要用事实说话,做到有理有据。接下来我们就去看下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,再把codewith(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) + ']'
         }
     }

    我对上面这个代码打个断点,结合我们上面的代码例子,这样子看的更清楚,如下图:
    getDirectives.png
    我们可以看到传进来的elAst语法树,el.directivesel上的指令,在我们这里就是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是什么呢?打个断点看一下,如下图:
genDirectives_state.png
我们可以看到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,修饰符trimnumber在我们这个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()。现在我们通过断点具体分析下,如下图:
    getAssignmentCode.png
    通过断点我们可以很清楚的看到我们先执行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构成指我们在输入文字时,处于未确认状态的文字。如图:
composing.png
带下划线的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;
    }

照常打个断点看下:,如下图
addProp.png

可以看到此方法的功能为给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中,如果存在就只是进行单纯的赋值,不存在的话在进行响应式操作,绑定gettersetter

现在我们就去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 targettrue,并且message又不是Object原型上的属性,所以!(key in Object.prototype)也为true,此时message属性并不是响应式属性,没有绑定setter,所以仅仅进行了单纯的赋值操作。

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

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无效

总结

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

参考资料

https://segmentfault.com/a/11...
https://blog.csdn.net/fabulou...


Change
225 声望15 粉丝

从不知道到知道,从知道到理解,从理解到深入探索,这是个有趣的过程