手写Vue中MVVM专题三

专题一和专题二中已经实现模板解析和数据绑定。
还需要实现的是数据劫持及数据和视图的绑定。

1. 准备工作

js目录下新建Observer.js和Watcher.js文件,并在index.html中引入新建文件。

目录结构如下:

mvvm6.png

2. 双向绑定效果实现

1. 编写Vue.js文件

Vue.js 实例化Observer构造函数,并传入需要的参数data。

function Vue (opt) {
  this.$el = opt.el;
  this.$data = opt.data;
  this.init();
}

Vue.prototype = {
  init: function () {
    new Observer(this.$data);
    new Compile(this.$el, this);
  }
}

2. 编写Observer.js文件

function Observer (data) {
  this.data = data;
  this.init();
}

Observer.prototype = {
  init: function () {
    
  }
}

3. 数据劫持处理

对数据递归遍历处理,利用Object.defineProperty()方法对属性进行定义,添加setter和getter方法,从而实现数据劫持的效果。

编写observe数据处理方法。该方法主要用于遍历数据,并为每个属性都定义getter和setter。

/**
 * 数据处理
 * 
 * @param {*} data 
 */
observe (data) {
  if (!data || typeof data != 'object') {
    return;
  }

  var _self = this;

  Object.keys(data).forEach(function (key) {
    _self.defineReactive(data, key, data[key]);
    _self.observe(data[key]); // 处理嵌套属性问题
  });
}

编写用于数据劫持的defineReactive方法,方式主要用于监听数据变化。
对数据进行赋值或者取值操作时,会触发get和set方法。

/**
 * 数据劫持
 * 
 * @param {*} obj 
 * @param {*} key 
 * @param {*} value 
 */
defineReactive (obj, key, value) {
  var _self = this;

  Object.defineProperty (obj, key, {
    enumerable: true,
    configurable: true,
    get: function () {
      return value;
    },
    set: function (newValue) {
      if (newValue !== value) {
        _self.observe(newValue); // 处理重新赋值问题 vm.$data.message = {}
        value = newValue;
      }
    }
  });
}

init函数中调用observer方法。

init: function () {
  this.observe(this.data);
}

现在已经完成数据劫持,当数据变化的时候可以在get和set方法中检测到。
还需要做的是如何在数据改变的时候通知视图渲染。

4. 优化compileTools.js文件

compileTools文件用来处理页面模板编译后的赋值。

旧代码

var CompileTools = {
  /**
   * 处理v-model指令
   * 
   * @param {*} node 
   * @param {*} vm 
   * @param {*} exp 
   */
  model: function (node, vm, exp) {
    var updaterFn = this.updater['modelUpdater'];
    // value vm.$data[exp]
    updaterFn && updaterFn(node, this.getVal(vm, exp)); 
  },

  /**
   * 处理text节点
   * 
   * @param {*} node 
   * @param {*} vm 
   * @param {*} exp 
   */
  text: function (node, vm, exp) {
    var updaterFn = this.updater['textUpdater'],
        reg = /\{\{([^}]+)\}\}/g;
    // {{ message.a }} -> message.a
    var value = exp.replace(reg, function (node, key) {
      return key;
    });
    updaterFn && updaterFn(node, this.getVal(vm, trimSpace(value))); 
  },

  updater: {
    modelUpdater: function (node, value) {
      node.value = value;
    },
   
    textUpdater: function (node, value) {
      node.textContent = value;
    }
  },

  /**
   * 对象取值
   * 
   * @param {*} vm 
   * @param {*} exp 
   */
  getVal: function (vm, exp) {
    exp = exp.split('.');
    return exp.reduce(function (prev, next) {
      return prev[next];
    }, vm.$data);
  }
}

将getVal方法和处理文本节点属性值的代码抽象成方法放入tools.js文件中。

/**
 * 获取对象属性值
 * 
 * @param {*} vm 
 * @param {*} exp 
 */
function getVal (vm, exp) {
  exp = exp.split('.'); // [x, y, z] 
  return exp.reduce(function (prev, next) {
    return prev[next];
  }, vm.$data);
}

/**
 * 处理文本节点属性值 {{ message.a }} -> message.a
 * 
 * @param {*} exp 
 */
function getTextValue (exp) {
  var reg = /\{\{([^}]+)\}\}/g;
  var value = exp.replace(reg, function (node, key) {
    return key;
  });
  return trimSpace(value);
}

在compileTools.js文件中调用上述方法。

新代码:

var CompileTools = {
  /**
   * 处理v-model指令
   * 
   * @param {*} node 
   * @param {*} vm 
   * @param {*} exp 
   */
  model: function (node, vm, exp) {
    var updaterFn = this.updater['modelUpdater'];
    // value vm.$data[exp]
    updaterFn && updaterFn(node, getVal(vm, exp)); 
  },

  /**
   * 处理text节点
   * 
   * @param {*} node 
   * @param {*} vm 
   * @param {*} exp 
   */
  text: function (node, vm, exp) {
    var updaterFn = this.updater['textUpdater'],
        value = getTextValue(exp);
   
    updaterFn && updaterFn(node, getVal(vm, value)); 
  },

  updater: {
    modelUpdater: function (node, value) {
      node.value = value;
    },
   
    textUpdater: function (node, value) {
      node.textContent = value;
    }
  }
}

5. 编写Watcher.js文件

Watcher中定义get和update方法,用于获取修改之前的数据和更新数据。
Watcher构造函数有3个参数。

1. 操作的虚拟节点
2. 获取属性值的表达式
3. 更新时触发的函数
function Watcher (vm, exp, cb) {
  this.vm = vm;
  this.exp = exp;
  this.cb = cb;
  this.value = this.get();
}

Watcher.prototype = {
  /**
   * 获取修改前的数据
   */
  get () {
    var value = getVal(this.vm, this.exp);
    return value;
  },

  /**
   * 更新数据
   * 
   */
  update () {
    var newValue = getVal(this.vm, this.exp),
        oldValue = this.value;
    
    if (newValue != oldValue) {
      this.cb(newValue);
    }
  }
}

get用于获取之前的数据,保存到value属性中,当数据更新时,update方法会比较新旧数据,如果数据不相等,就触发更新的回调函数。

6. 在compileTools.js中使用Watcher

在处理元素节点和文本节点的方法中使用Watcher。
当模板编译时,会给解析出来的值逐一添加Watcher。

/**
 * 处理v-model指令
 * 
 * @param {*} node 
 * @param {*} vm 
 * @param {*} exp 
 */
model: function (node, vm, exp) {
  var updaterFn = this.updater['modelUpdater'];

  new Watcher(vm, exp, function (value) {
    updaterFn && updaterFn(node, value);
  });

  // value vm.$data[exp]
  updaterFn && updaterFn(node, getVal(vm, exp)); 
},

/**
 * 处理text节点
 * 
 * @param {*} node 
 * @param {*} vm 
 * @param {*} exp 
 */
text: function (node, vm, exp) {
  var updaterFn = this.updater['textUpdater'],
      value = getTextValue(exp);
  
  new Watcher(vm, value, function (value) {
    updaterFn && updaterFn(node, value); 
  });

  updaterFn && updaterFn(node, getVal(vm, value)); 
}

7. 连接数据和视图

上述已经实现Watcher的初始化,也实现数据劫持,现在需要把两者结合起来。

Observer.js中定义Dep构造函数。
Dep用于管理多个Watcher,并且提供数据更新的操作。

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

Dep.prototype = {
  addSub: function (watcher) {
    this.subs.push(watcher);
  },

  notify: function () {
    this.subs.forEach(function (watcher) {
      watcher.update();
    });
  }
}

在Watcher.js中使用Dep。由于每个数据都有自己的Watcher,我们可以给其设置自己的Dep。

/**
 * 获取修改之前的数据
 */
get () {
  Dep.target = this;
  var value = getVal(this.vm, this.exp);
  Dep.target = null;
  return value;
}

在自定义的defineReactive的get和set中,定义Watcher的收集和Wachter的更新。

/**
 * 数据劫持
 * 
 * @param {*} obj 
 * @param {*} key 
 * @param {*} value 
 */
defineReactive (obj, key, value) {
  var _self = this,
      dep = new Dep();

  Object.defineProperty (obj, key, {
    enumerable: true,
    configurable: true,
    get: function () {
      Dep.target && dep.addSub(Dep.target);
      return value;
    },
    set: function (newValue) {
      if (newValue !== value) {
        _self.observe(newValue);
        value = newValue;
        dep.notify();
      }
    }
  });
}

现在实际上已经实现了数据的双向绑定。可以通过以下案例测试。
将之前的实例化的message数据提取到全局,在开发者工具中修改数据查看。

index.html

var message = {
  a: 'HelloWorld'
}

new Vue({
  el: '#app',
  data: {
    message: message
  }
});

效果如下:

修改前:

mvvm7.png

修改后:

mvvm8.png

可以看到,当修改message.a的数据时,页面会重新渲染,展示新的数据。

8. 处理input输入框

数据双向绑定已经实现,但是还需要监听input的操作。
当数据变化时,同步修改数据,实现页面重新渲染。

tools.js 定义addEvent兼容性函数,用于添加事件处理函数。

/**
 * 添加监听事件
 * 
 * @param {*} el 
 * @param {*} type 
 * @param {*} fn 
 */
function addEvent (el, type, fn) {
  if (el.addEventListener) {
    el.addEventListener(type, fn, false);
  } else if (el.attachEvent) {
    el.attachEvent('on' + type, function(){
      handle.call(el);  
    })
  } else {
    el['on' + type] = fn;
  }
}

tools.js 定义设置对象属性值的方法,用于input的赋值操作。

/**
 * 设置对象属性值
 * 
 * @param {*} vm 
 * @param {*} exp 
 * @param {*} value 
 */
function setVal (vm, exp, value) {
  exp = exp.split('.'); // [x, y, z] 
  return exp.reduce(function (prev, next, index) {
    if (index === exp.length - 1) {
      return prev[next] = value;
    }
    return prev[next];
  }, vm.$data);
}

compileTools.js中的model方法中增加对input的处理。

/**
 * 处理v-model指令
 * 
 * @param {*} node 
 * @param {*} vm 
 * @param {*} exp 
 */
model: function (node, vm, exp) {
  var updaterFn = this.updater['modelUpdater'];

  new Watcher(vm, exp, function (value) {
    updaterFn && updaterFn(node, value);
  });

  addEvent(node, 'input', function (event) {
    var e = event || window.event,
        target = e.target || e.srcElement,
        newValue = target.value;
        
    setVal(vm, exp, newValue);
  });

  // value vm.$data[exp]
  updaterFn && updaterFn(node, getVal(vm, exp)); 
},

监听input事件,并绑定事件处理函数。当数据变化时,更改数据,进而触发页面渲染。

3. 总结

通过自己实现MVVM的整个过程,让我更加理解Vue数据双向绑定的实现原理,以及理解MVVM这种架构模式带来的好处。最后,希望这篇文章对大家也有所帮助。

代码链接:ES5手写MVVM