美文网首页
v-model原理的深入解析(超详细)

v-model原理的深入解析(超详细)

作者: var_Change | 来源:发表于2019-12-12 16:25 被阅读0次

    抛出问题

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

    <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)
    
    1. 优化语法树
        optimize(ast, options)
    
    1. 生成代码
        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/1190000015848976#articleHeader0
    https://blog.csdn.net/fabulous1111/article/details/85265503

    相关文章

      网友评论

          本文标题:v-model原理的深入解析(超详细)

          本文链接:https://www.haomeiwen.com/subject/kkytnctx.html