实现简易的MVVM

324 阅读3分钟

MVVM中,数据、视图是相互影响的。
vue中通过数据劫持,实现双向数据绑定。

function MVVM(options={}){
    this.$options = options;// 将属性挂载到MVVM实例
    var data = this._data= this.$options.data;
    
}

踩坑笔记

在一开始,实现数据劫持时,我是采用以下方式:

function observe(data){
    if(typeof data  !== 'object' ){
        return;
    }
    for(let key in data){
        let val = data[key]
        Object.defineProperty(data,key,{
            configurable:false,
            enumerable:true,
            get(){
                return val
            },
            set(newVal){
                if(val === newVal){
                    return;
                }//如果新值和旧值相等,那就什么都不做
                val = newVal
                observe(val)
            }
        })
    }
}
var obj = {a:1,b:2,c:3,d:4}
observe(obj)

原因解析

因为我们实在for循环中使用Object.defineProperty(),而val是会随着循环不断发生变化的,再者,我们再get()中返回的是val,所以会造成值覆盖问题

var obj = {a:1,b:2,c:3,d:4}
observe(obj)

在这段代码中,obj[a],obj[b],obj[c],obj[d]的输出结果都是4。验证了我们的结论。
也许你会说,我们可以在getretrun obj[key],也就是get(){retrun obj[key]}

这样总没问题了吧,但是,少年,你还是太天真了。执行obj[key]时会自动执行get()方法,因此这样会出现无限递归的情形,也是不可取的。

解决方案

使用let声明变量,而不使用var



MVVM.prototype.observe = function (data,key,val){
    if(typeof data  !== 'object' ){
        return;
    }
    for(var key in data){
    
        Object.defineProperty(this,key,{
            configurable:false,
            enumerable:true,
            get(){
                return val
            },
            set(newVal){
                if(val === newVal){
                    return;
                }//如果新值和旧值相等,那就什么都不做
                val = newVal
                observe(val)
            }
        })
    }
}

那我们应该怎么做呢?数据代理帮我们很好的解决了这个问题。


数据代理

那么什么是数据代理呢?数据代理就是把data中的属性和属性值都复制到vm实例中。我们来看下面代码:

var vm = new Vue({
    el:'#app',
    data:{name:'XXX',age:18}
})
//在`vue`实例中,我们之所以可以通过`this.data`直接访问到data中的`name`,`age`属性,就是因为`vue`实例代理了`data`

那我们该怎么实现数据代理呢?其实也很简单,实现方式如下:

MVVM.prototype.dataProxy = function (data) {
  for (var key in data) {
    Object.defineProperty(this, key, {
      enumerable: true,
      get() {
        return this._data[key]
      },
      set(newVal) {
        this._data[key] = newVal
      }
    })
  }
}

数据劫持


上面的代码看似完美了,其实也存在一个问题,因为我们方法是以表达式的方式声明的,在递归调用时因为observe()还没有被赋值,会报错。
解决办法
1.在类的内部声明该方法
2.在类的外部声明改方法并传递this参数。
在这里,我们采用第一种方式

function MVVM(options={}){
    this.$options = options;// 将属性挂载到MVVM实例
    var data = this._data= this.$options.data;
    dataProxy(data)
     observe(data)
    // 数据劫持
    function observe(data,key,val){
        if(typeof data  !== 'object' ){
            return;
        }
        for(var key in data){
            Object.defineProperty(this,key,{
                configurable:false,
                enumerable:true,
                get(){
                    return val
                },
                set(newVal){
                    if(val === newVal){
                        return;
                    }//如果新值和旧值相等,那就什么都不做
                    this._data[key] = newVal
                    observe(this._data[key])
                }
            })
        }
    }
}


function MVVM(options={}){
    this.$options = options;// 将属性挂载到MVVM实例
    var data = this._data= this.$options.data;
    Observe(data)
    // 把data绑定到this-->MVVM实例上,实现数据代理
    dataProxy(data)
}
function compile(el,mvvm){
    var $el = document.querySelector(el);
    var fragment = 
}

createDocumentFragment() 用于创建一个文档片段作为容器,其中可以包含多个dom节点。这里有两点需要特别注意的地方:

当把文档片段插入DOM树的时候,只会把它的子节点插进去,它作为容器本身是不会进入DOM树的。 当把DOM树种的节点插入文档片段的时候,这些节点,会真的从DOM树种消失。我们也把这个过程叫做劫持。

实现发布订阅模式

所谓发布订阅模式,分为两个主体,一个是发布者,一个是订阅者。当发布者的内容发生变化后,会通知所有订阅者。

function Dep(){
  this.subs = []
}

Dep.prototype.addSub = function(sub){
  this.subs.push(sub)
}

Dep.prototype.notify = function(){
  this.subs.forEach(sub=>sub.update())
}

function Watcher(fn){
  this.fn = fn;
}
Watcher.prototype.update = function(){
  this.fn()
}

绑定数据和视图

何时实现绑定?

在前面的Compiler中的replace()中,我们已经实现了将{{express}}替换为我们自己的数据。同样的,在数据发生变化时(也就是{{express}}中的值),应该将旧值替换新值,然后再修改node.textContent
思路:

  • 匹配到{{express}}节点-->匹配结果
  • 之前我们已经进行了数据劫持,但我们我们仅仅是监听了数据的变化,而并没有采取其他措施。如果要绑定数据与视图,则必须在数据改变时加入一些处理逻辑。数据发生改变时自动执行set()方法,因此,我们需要在set方法中实现对页面的更新操作。
  • 页面更新操作其实也就是修改我们的匹配结果。
function fn(vm,exp,callback(){
    node.textContent = text.replace(exp,newVal)
})
....
 Object.defineProperty(data, key, {
      enumerable: true,
      get() {
        return val
      },
      set(newVal) {
        if (val === newVal) {
          return;
        }//如果新值和旧值相等,那就什么都不做
        val = newVal
        observe(val)
        fn()
      }
    })
  • 现在剩下的问题是将fnset方法挂钩,将他们联系起来。
  • 这就要用到我们前面实现的发布订阅模式了。
  • 所谓发布订阅模式,分为两个主体,一个是发布者,一个是订阅者。当发布者的内容发生变化后,会通知所有订阅者。
  • 数据本身就是发布者,而使用这些数据的地方就是订阅者。
  • 在代码中的体现就是谁get了数据,谁就是数据的订阅者。
  • 那么还有一个问题,那怎么知道谁get了数据呢?
  • 我们再回头来看一下我们实现的replace()方法
function replace(fragment,vm) {
  Array.prototype.slice.call(fragment.childNodes).forEach(function (node) {
    let text = node.textContent;
    let reg = /\{\{(.*)\}\}/
    if (node.nodeType === Node.TEXT_NODE && reg.test(text)) { // 是空白字符失配
      let arr = RegExp.$1.split('.')
      let val = vm; // 为什么不直接从vm中获取数据
      arr.forEach(function (key) {
        val = val[key]
      });
      new Watcher(vm,RegExp.$1,function(newVal){
        node.textContent = text.replace(reg, val)
      })
      node.textContent = text.replace(reg, val)
    }
    if (node.childNodes) {
      replace(node,vm)
    }
  })
}

我们在将插值表达式替换成值时,必定会调用get方法。

  • nice!知道了谁调用get方法,就知道谁是订阅者了。因此,我们在replace方法中,new了一个Watcher
  • 那么一切就好办了。因为发布者一定是数据源。于是我们的代码变成这个样子:
function observe(data) {
  console.log(data)
  if (!data || typeof data !== 'object') {
    return;
  }
  let dep = new Dep()
  for (let key in data) {
    let val = data[key]
    Object.defineProperty(data, key, {
      enumerable: true,
      get() {
        ....
        dep.addSub(watcher)//
        return val
      },
      set(newVal) {
        if (val === newVal) {
          return;
        }//如果新值和旧值相等,那就什么都不做
        val = newVal
        observe(newVal)
        ...// 执行watcher中所有的回调函数
        dep.notify()
      }
    })
  }
}
  • 那么现在的问题是watcher如何传递进去?
  • 一个简单的想法是直接在dep.addSub(new Watcher()),这样存在很大的问题,如果采用这种方式,我们必须深入代码内部,而且可维护性十分低下
  • 那么就想办法从外部传入喽...
  • 从外部传入的方式一般的话我们想到的是参数传递,但这里有一种十分巧妙的方法
function Watcher(vm,exp,fn){
  this.fn = fn;
  this.vm = vm;
  this.exp = exp;
  Dep.target = this; // 使用Dep对象的target属性指向this-->watcher实例
  this.mustache()
  Dep.target = null;
}
Watcher.prototype.mustache = function(){
  let val = this.vm
  let arr = this.exp.split('.')
  arr.forEach(function(key){ // 取出值
    val = val[key]
  })
  return val
}

为什么说这样很巧妙呢?Dep是一个全局变量,我们在任何地方都可以访问到它。最后一步Dep.target=null,是为了避免同一个watcher的回调被执行多次。

function observe(data) {
  if (!data || typeof data !== 'object') {
    return;
  }
  let dep = new Dep()
  for (let key in data) {
    let val = data[key]
    Object.defineProperty(data, key, {
      enumerable: true,
      get() {
        Dep.target&&dep.addSub(Dep.target)//
        return val
      },
      set(newVal) {
        if (val === newVal) {
          return;
        }//如果新值和旧值相等,那就什么都不做
        val = newVal
        observe(newVal)
        dep.notify()
      }
    })
  }
}

实现双向数据绑定

实现思路:

  • 实现双向绑定,则进行双向绑定的元素必定是input标签
  • 实现双向绑定需要绑定一个属性,在这里我定义为x-model,其属性值是vm上的定义的数据 也就是说其dom元素表现为以下形式(namedata中定义的数据)
<input type="text" x-model="name">
  • 判断是否存在双向绑定的标识,也就是是否存在x-model属性
  • 如果存在x-model属性,则将其属性值填到input中,
  • input里面的内容发生变化时,将inputvalue赋值给name属性
function parse(fragment,vm) {
  Array.prototype.slice.call(fragment.childNodes).forEach(function (node) {
    let text = node.textContent;
    let reg = /\{\{(.*)\}\}/
    if (node.nodeType === Node.TEXT_NODE && reg.test(text)) { // 是空白字符失配
      let arr = RegExp.$1.split('.')
      let val = vm; // 为什么不直接从vm中获取数据
      arr.forEach(function (key) {
        val = val[key]
      });
      new Watcher(vm,RegExp.$1,function(newVal){
        node.textContent = text.replace(reg, newVal)
      })
      node.textContent = text.replace(reg, val)
    }
    if(node.nodeType === Node.ELEMENT_NODE){
      let attrs = node.attributes;// 获取节点属性
      Array.prototype.slice.call(attrs).forEach(function(attr){
        let name = attr.name;
        let exp = attr.value;
        if(name.indexOf('x-')===0){//y因为node必定是`input`所以可以有`node.value`
          node.value = vm[exp]
        }
        new Watcher(vm,exp,function(newVal){
          node.value = newVal // 自动将内容放入到输入框内
        })
        node.addEventListener('input',function(e){
          let newVal = e.target.value;
          vm[exp] = newVal
        })
      })
    }
    if (node.childNodes) {
      parse(node,vm)
    }
  })
}

实现computed计算属性

计算属性的实现原理:因为计算属性是一个对象,遍历computedkey,将其定义在mvvm实例上.

if (node.nodeType === Node.TEXT_NODE && reg.test(text)) { // 是空白字符失配
      let arr = RegExp.$1.split('.')
      let val = vm; // 为什么不直接从vm中获取数据
      arr.forEach(function (key) {
        val = val[key]
      });
      new Watcher(vm,RegExp.$1,function(newVal){
        node.textContent = text.replace(reg, newVal)
      })
      node.textContent = text.replace(reg, val)
    }
MVVM.prototype.initComputed = function () {
  let vm = this;
  let computed = this.$options.computed
  Object.keys(computed).forEach(function(key){
    Object.defineProperty(vm,key,{
      get:typeof computed[key]==='function'?computed[key]:computed[key].get
    })
  })
}