简单版源码分析双向绑定

从源码分析双向绑定

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

html代码:


  

{{message}}

从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}
}

{{user.name}}
{{user.name}}
{{user.name}}
这里会创建三个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操作时,订阅容器通知相关依赖触发更新。

你可能感兴趣的:(简单版源码分析双向绑定)