美文网首页
简单版源码分析双向绑定

简单版源码分析双向绑定

作者: zdxhxh | 来源:发表于2019-10-31 12:26 被阅读0次

    从源码分析双向绑定

    这部分代码,是源码的简化版,相对比较容易理解。

    html代码:

    <body>
      <div id="mvvm-app">
        <input type="text" v-model="message" />
        <p>{{message}}</p>
        <button v-on:click="sayHi">change model</button>
      </div>
    </body>
    <script src="./index.js"></script>
    <script>
      var vm = new MVVM({
        el: "#mvvm-app",
        data: {
          message: "hello world"
        },
        methods: {
          clickBtn: function(message) {
            vm.message = "clicked";
          }
        }
      });
    </script>
    

    从html代码,vue仅仅从初始化vm实例就完成了双向绑定,简直溜啊,我们还在想是用模块还是啥玩意搞的时候,人家就直接实例->视图,完成全部,秀啊。

    在看看vm实例初始化过程中干了啥

    function MVVM(options) {
      this.$options = options;
      var data = (this._data = this.$options.data),
      self = this;
      Object.keys(data).forEach(function(key) {
        self._proxy(key);
      });
      observe(data, this);
      this.$compile = new Compile(options.el || document.body, this);
    }
    

    我们来数:

    • this.$options = options // 它将入参的引用地址缓存了一遍
    • var data = this._data = this.$options.data // 他将入参的data挂载到自身_data,并在当前词法作用域声明一个data参数
    • Object.keys(...)... // 它将data的key遍历,并调用自身原型的方法,_proxy

    就此打断,我们来看看_proxy怎么玩

    MVVM.prototype = {
      _proxy: function(key) {
        var self = this;
        Object.defineProperty(self, key, {
          configurable: false,
          enumerable: true,
          get: function proxyGetter() {
            return self._data[key];
          },
          set: function proxySetter(newVal) {
            self._data[key] = newVal;
          }
        });
      }
    };
    

    这段代码,是将自身的 vm.data.key1,vm.data.key2 变成 vm.key1和vm.key2,这就是为什么你可以在vm的方法中调用this.key1的原因了。

    继续构造函数的解析:

    • observe(data, this); // 这里就是实现2的一部分过程

    我们来看看observe的实现

    function observe(data) {
      if (!data || typeof data !== "object") {
        return;
      }
      if (Array.isArray(data)) {
        throw new TypeError("data must Object");
      }
      defineReactive(data);
    }
    

    结果是它只做了一些类型判断,并调用了defineReactive这个函数

    我们看看defineReactive的实现

    function defineReactive(data) {
      // 创建一个消息订阅器实例
      var dep = new Dep();
      for (let key in data) {
        var type = Object.prototype.toString.call(data[key]);
        if (type === "[object Array]") {
          Object.defineProperty(data[key], "push", {
            value: arrayMethods.push
          });
        } else if (type === "[object Object]") {
          // 递归调用
          defineReactive(data[key]);
        } else {
          proxy(data, key,dep);
        }
      }
    }
    
    function proxy(obj,prop,dep) {
      var val = obj[prop];
      Object.defineProperty(obj, prop, {
        get: function() {
          if (Dep.target) {
            dep.depend();
          }
          return val;
        },
        set: function(newVal) {
          val = newVal;
          dep.notify();
        }
      });
    }
    

    这里

    • 首先传入了data实例作为入参
    • 创建一个消息容器实例dep
    • 遍历data的key
    • 判断data[key]的类型
      • 数组 : 对数组的push、unshift(这里偷懒了没写)...方法进行劫持,这是个难点,有兴趣看section3
      • 对象 : 对象直接递归调用
      • 普通类型属性 : 调用proxy进行数据劫持

    我们再来看proxy怎么实现的

    • 首先,它有三个入参,分别是defineReactive遍历对象,defineReactive遍历出的key,容器实例dep
    • Object.defineProperty(...),这里做真正的数据劫持,重新定义data.key的访问器属性[[Get]] 与[[Set]],在getter与setter中调用容器实例的depend与notify方法。实际上这是最难理解的地方,为什么是调用容器的方法,而不是直接写入操作DOM的代码呢?而且这些散落的dep容器对象是不可预测的。

    好,现在data对象劫持完成了,再无数次递归后,你可以想象一下dep实例的分布。

    假设data是这样的一个结构

    data : { 
      user : { 
        name : '2222娘',
        age : '18'
      },
      key : 1
    }
    

    dep的分布应该是这样的

    data(dep1) : { 
      key : { 
        getter : function(){ dep1 },
        setter : function() { dep1 }
      },
      user(dep2) : { 
        name : { 
          getter : function(){ dep2 },
          setter : function() { dep2 }
        },
        age : { 
          getter : function(){ dep2 },
          setter : function() { dep2 }
        },
      }
    }
    

    dep寄生在data实例以及子属性为对象的身上

    好,回到vm的构造函数,看看这句

    this.$compile = new Compile(options.el || document.body, this);

    这里创建了一个Compile实例,并挂载到自身的$compile属性身上。来看Compile的构造函数

    /**
     * @param {dom} 传入的dom节点
     * @param vm 传入的vm实例
     */
    function Compile(el, vm) {
      this.$vm = vm;  // 挂载到自身
      this.$el = this.isElementNode(el) ? el : document.querySelector(el);  // 是节点直接用
      if (this.$el) {
        // 以下这句是提高性能的
        this.$fragment = this.node2Fragment(this.$el);
        // 调用原型方法
        this.init();
        // 调用完后,给$el添加$fragment
        this.$el.appendChild(this.$fragment);
      }
    }
    

    我们来看一下init方法

    Compile.prototype = {
      init: function() {
        this.compileElement(this.$fragment);
      },
      compileElement: function(el) {
        var childNodes = el.childNodes;
        var self = this;
        Array.prototype.slice.call(childNodes).forEach(function(node) {
          var text = node.textContent;
          var reg = /\{\{(.*)\}\}/; // 表达式文本呢
          if (self.isElementNode(node)) {
            self.compile(node);
          } else if (self.isTextNode(node) && reg.test(text)) {
            self.compileText(node, RegExp.$1);
          }
          // 遍历编译子节点
          if (node.childNodes && node.childNodes.length) {
            self.compileElement(node);
          }
        });
      },
      compile: function(node) {
        var nodeAttrs = node.attributes;
        var self = this;
        Array.prototype.slice.call(nodeAttrs).forEach(function(attr) {
          var attrName = attr.name; // v-text
          if (self.isDirecitive(attrName)) {
            var exp = attr.value;
            var dir = attrName.substring(2);
            if (self.isEventDirective(dir)) {
              // 事件指令, 如 v-on:click
              compileUtil.eventHandler(node, self.$vm, exp, dir);
            } else {
              // 普通指令
              compileUtil[dir] && compileUtil[dir](node, self.$vm, exp);
            }
          }
        });
      }
      isElementNode: function(el) {
        return el.nodeType && el.nodeType === 1;
      },
      isTextNode: function(el) {
        return el.nodeType && el.nodeType === 3;
      },
      isDirecitive: function(attrName) {
        return attrName.indexOf("v-") == 0;
      },
      isEventDirective: function(dir) {
        return dir.indexOf("on") === 0;
      },
      node2Fragment: function(el) {
        var fragment = document.createDocumentFragment();
        var child;
        while ((child = el.firstChild)) {
          fragment.appendChild(child);
        }
        return fragment;
      },
      compileText: function(node, exp) {
        compileUtil.text(node, this.$vm, exp);
      }
    };
    

    init()方法分析

    • 调用compileElement()方法
    • 对入参el提取所有子节点(这是准备遍历的节奏)
    • 遍历子节点
    • 判断节点类型
      • 元素节点 :调用complie方法
      • 文本节点 : 调用compileText
    • 判断节点是否还有子节点,有就递归调用compileElement方法

    complie方法分析

    • 通过attributes提取节点的属性集合(类数组)
    • 遍历这些元素属性
    • 通过元素属性名判断是否是指令并判断指令类型
      • 是事件指令(v-on) : 调用compileUtil单例的指令处理方法对node进行事件绑定
      • 普通指令(v-model,v-bind) : 调用compileUtil单例的指令处理方法对node进行处理

    指令处理集合compileUtil代码分析,我这里之分析几个重要的方法

    // 指令处理集合
    var compileUtil = {
      text: function(node, vm, exp) {
        this.bind(node, vm, exp, "text");
      },
      // ...省略
      bind: function(node, vm, exp, dir) {
        var updaterFn = updater[dir + "Updater"];
        // 第一次初始化视图
        updaterFn && updaterFn(node, this._getVMVal(vm, exp));
        // 实例化订阅者,此操作会在对应的属性消息订阅器中添加了该订阅者watcher
        new Watcher(vm, exp, function(value, oldValue) {
          // 一旦属性值有变化,会收到通知执行此更新函数,更新视图
          updaterFn && updaterFn(node, value, oldValue);
        });
      },
      _getVMVal: function(vm, exp) {
        var val = vm;
        exp = exp.split(".");
        exp.forEach(function(k) {
          val = val[k];
        });
        return val;
      },
      model: function(node, vm, exp) {
        this.bind(node, vm, exp, "model");
        var me = this,
            val = this._getVMVal(vm, exp);
        node.addEventListener("input", function(e) {
          var newValue = e.target.value;
          if (val === newValue) {
            return;
          }
          me._setVMVal(vm, exp, newValue);
          val = newValue;
        });
      },
    
      _setVMVal: function(vm, exp, value) {
        var val = vm;
        exp.forEach(function(k, i) {
          // 非最后一个key,更新val的值
          if (i < exp.length - 1) {
            val = val[k];
          } else {
            val[k] = value;
          }
        });
      },
      // 事件处理
      eventHandler: function(node, vm, exp, dir) {
        var eventType = dir.split(":")[1],
          fn = vm.$options.methods && vm.$options.methods[exp];
    
        if (eventType && fn) {
          node.addEventListener(eventType, fn.bind(vm), false);
        }
      }
    };
    

    当我们遇到v-model这样的指令会调用compileUtil.model方法

    入参

    • 节点
    • vm实例
    • exp(属性值) v-model='xx' 的xx

    调用过程

    • 调用自身的bind方法
    • _getVMVal 是通过vm实例的_data属性值的引用
    • 为元素节点添加input事件监听,当有新的值传入时触发_setVMVal,调用vm[exp] = newVal

    这里就完成了input中绑定原生事件,回调更新数据层

    再看bind方法,以下是形参说明

    • 当前指令的dom节点
    • vm,vm实例
    • exp(属性值) v-model='xx' 的xx
    • dir 更新类型
    bind: function(node, vm, exp, dir) {
      var updaterFn = updater[dir + "Updater"];
      // 第一次初始化视图
      updaterFn && updaterFn(node, this._getVMVal(vm, exp));
      // 实例化订阅者,此操作会在对应的属性消息订阅器中添加了该订阅者watcher
      new Watcher(vm, exp, function(value, oldValue) {
        // 一旦属性值有变化,会收到通知执行此更新函数,更新视图
        updaterFn && updaterFn(node, value, oldValue);
      });
    }
    // 更新函数
    var updater = {
      textUpdater: function(node, value) {
        node.textContent = typeof value == "undefined" ? "" : value;
      },
      modelUpdater: function(node, value, oldValue) {
        node.value = typeof value == 'undefined' ? '' : value;
    }
      // ...省略
    };
    

    这里它做这些事情

    • 获取单例updater对应的dirUpdater方法。这里为modelUpdater
    • 第一次使用bind时,初始化对应的dom节点(如v-model="text",text=2)则dom.value = 2
    • 实例化一个Watcher,并传入回调函数,updateFn作为闭包传递了下去

    再来看Watcher的构造函数

    function Watcher(vm, exp, cb) {
      this.cb = cb;
      this.vm = vm;
      this.exp = exp;
      this.depIds = {};
      // 此处为了触发get方法,从而在dep添加自己
      this.value = this.get();
    }
    

    它做了如下事情

    • 将vm,exp,cb挂载到自身
    • 创建一个depIds的集合
    • 触发它原型身上的get方法,并将返回值挂载到自己身上

    再看它的原型get方法

    Watcher.prototype = {
      get: function() {
        Dep.target = this; // 将订阅者指向自己
        var value =  compileUtil._getVMVal(this.vm,this.exp); // 触发getter,添加自己到属性订阅器
        Dep.target = null; // 添加完毕 重置
        return value;
      },
      update: function() {
        this.run(); // this.run();  // 属性值变化收到通知
      },
      run: function() {
        var value = this.get(); // 取到最新值
        var oldVal = this.value;
        if (value !== oldVal) {
          this.value = value;
          this.cb.call(this.vm, value, oldVal); // 执行compile中的回调 更新视图
        }
      },
      addDep: function(dep) {
        if (!hasOwnProperty(this.depIds, dep.id)) {
          dep.addSub(this);
          this.depIds[dep.id] = dep;
        }
      }
    };
    

    在它的get方法触发后

    • Dep.target = this ,它将自己挂载到Dep静态变量target上。
    • 再次调用comlileUtil._getVMVal(exp),逐层往下触发vm的getter
      vm.data = { user : { name : 22333 }}
      v-model = "user.name"
      data(dep1) { 
        user(dep2): { 
          name :2222
        }
      }
      dep1.depend(Dep.target)
      dep2.depend(Dep.target)
      
      Dep.prototype = {
        addSub: function(sub) {
          this.subs.push(sub);
        },
        depend: function() {
          Dep.target.addDep(this);
        },
        removeSub: function(sub) {
          var index = this.subs.indexOf(sub);
          if (index != -1) {
            this.subs.splice(index, 1);
          }
        },
        notify: function() {
          this.subs.forEach(function(sub) {
            sub.update();
          });
        }
      }
      触发Wathcer的addDep方法,则当前的Watch.depIds = [dep1,dep2],
      并将传入dep1.addSub(Watch); dep2.addSub(Watch),双方都保留了各自的引用。
      
      这个过程时非常的绕,首先,我们在编译时创建watcher的实例,创建完一个实例后,我们想通过将与v-model绑定属性相关的订阅者加入到wathcer实例中,但是这些实例是通过闭包保存在属性的getter与setter中,通过以上办法可以获取到这些分散的实例。总结为以下几步
      • 将当前的watcher挂载到全局变量中
      • 触发data的各个getter
      • getter中保留的dep引用,触发dep的depend方法
      • depend方法触发全局变量的addDep方法,并将自身作为实参传入
      • 全局变量watcher成功收集所有与之有关的dep实例
    • Dep.target = null; 这句将自身暴露的引用删除
    • return value 返回获取到的vm属性值

    我们来看一下watcher与dep的引用数谁的多

    data(dep1) : { 
      user(dep2) : { name : 22333}
    }
    
    <div>{{user.name}}</div>
    <div>{{user.name}}</div>
    <div>{{user.name}}</div>
    
    这里会创建三个watcher ,
    watcher1: { depIds :[dep1,dep2]}
    watcher2: { depIds :[dep1,dep2]}
    watcher3: { depIds :[dep1,dep2]}
    
    
    dep1 : { subs : [watcher1,wathcher2,watcher3] }
    dep2 : { subs : [watcher1,wathcher2,watcher3] }
    

    以上就完成了 数据层 --(数据劫持)--> DOM

    在触发某个属性的setter 后,有关的dep会通知所有订阅该属性的watcher,并触发watcher的更新视图方法。

    事实上,最难理解的是加入dep与watcher这样的相互映射。有点像笛卡尔积与二维表

    Watcher\ Dep dep1 dep2 dep3
    wathcer1
    wathcer2
    wathcer3
    watcher4

    事实上,depIds的作用是用于记录当前watcher实例订阅dep实例,如果已经订阅过了,则不再订阅。

    最后,当触发data.xxx = "xxx"的时候,dep就会调用notify通知相关的watcher更新视图

    这就完成了 当数据层变化时,更新input或关联元素的value (2),最后,双向绑定就实现了

    相关术语

    收集依赖

    对于dep.addSub(watcher) 这个过程,我们叫做收集依赖,这个过程实在complie中实现的,每次新建完watcher后,都会在相关的dep添加该watcher实例。

    image.png

    面试怎么回答?

    双向绑定怎么实现啊 ? 面试你可不能回答大白话,毕竟造航母

    答 :双向绑定的基本原理是在vm实例初始化过程中对data对象进行数据劫持,并创建订阅容器dep,在render(compile)过程中遍历每个节点并创建watcher依赖,创建依赖过程中通过getter触发订阅容器的依赖收集。最后,当data对象下的属性触发setter操作时,订阅容器通知相关依赖触发更新。

    相关文章

      网友评论

          本文标题:简单版源码分析双向绑定

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