JS核心理论之《Vue响应式原理及MVVM实现》

喜夏-厌秋 提交于 2020-07-28 17:42:31

MVVM

概念

MVVM表示的是 Model-View-ViewModel

  • Model:模型层,负责处理业务逻辑以及和服务器端进行交互
  • View:视图层:负责将数据模型转化为UI展示出来,可以简单的理解为HTML页面
  • ViewModel:视图模型层,用来连接Model和View,是Model和View之间的通信桥梁

View层和Model层并没有直接联系,而是通过ViewModel层进行交互。ViewModel层通过双向数据绑定将View层和Model层连接了起来,使得View层和Model层的同步工作完全是自动的。

image

实现数据绑定的方式及代表有:

  • 发布订阅模式(Backbone)
  • 数据劫持或代理(VueJS,AvalonJS) 通过Object.definePropertyProxy,前者不能监听数组变化必须遍历对象的每个属性嵌套对象必须深层遍历; 后者可以监听数组变化,仍然需要深层遍历嵌套对象,兼容性不如前者。
  • 数据脏检查(AngularJs,RegularJS) 在可能触发 UI 变更的时候进行脏检查,如DOM事件,XHR响应事件、定时器等。

实现

双向数据绑定需要实现以下三个类:

  • Observer 监听器:用来监听属性的变化,并通知订阅者
  • Watcher 订阅者:接受属性变化的通知,然后更新视图
  • Compile 解析器:解析指令,初始化模版,绑定订阅者

image

接下来,我们按照Vue的实现方式来实现一个简单的MVVM框架。

  1. 实现监听器Observer

利用Obeject.defineProperty()来监听属性变动,那么将需要observe的数据对象进行递归遍历,包括子属性对象的属性,都加上settergetter。 当给这个对象的某个值赋值时,就会触发setter,那么就能监听到了数据变化。

当我们监听到属性发生变化之后我们需要通知 Watcher 订阅者执行更新函数去更新视图,在这个过程中我们可能会有很多个订阅者 Watcher , 所以我们要创建一个容器 Dep 去做一个统一的管理。

function observer(data) {
  if(!data || typeof data !== 'object'){
    return;
  }
  Object.keys(data).forEach(key=>{
    defineReactive(data, key, data[key]);
  })
}

function defineReactive(obj,key,val){
  observer(val); //递归监听子属性
  var dep = new Dep(); //订阅者的依赖收集器

  Object.defineProperty(obj, key, {
     enumerable: true, 
     configurable: true,
     get: function getter(){
        if (Dep.target) {
          dep.addSub(Dep.target); //在getter中将每个属性添加进监听
        }
        return val;
     },
     set: function setter(newVal){
        if (newVal === val) {
          return;
        }
        val = newVal; //数据重新赋值

        console.log('监听到值变化了 ', val, ' --> ', newVal);
        dep.notify(); //通知订阅器
     }
  })
}

function Dep() {
  this.subs = [];
}
Dep.prototype.addSub = function (sub) {
  this.subs.push(sub);
}
Dep.prototype.notify = function () {
  console.log('属性变化, 通知 Watcher 执行更新视图的函数');
  this.subs.forEach(sub => {
    sub.update(); //这里调 Watcher 的视图更新函数
  })
}
Dep.target = null; //全局target
复制代码

上面创建了一个监听器 Observer,我们现在可以给一个对象添加监听,然后改变属性观察有何变化。

var person = {
  name: 'ben'
}
observer(person);
person.name = 'bob';
复制代码

通过控制台打印输出了:属性变化, 通知 Watcher 执行更新视图的函数,证明监听器 Observer生效了。

  1. 实现订阅器Watcher

既然已经监听到了属性变化,就需要 Watcher 去执行更新了。Watcher 主要是接受属性变化的通知,然后去执行更新函数去更新视图

function Watcher(vm, prop, callback) {
  this.vm = vm;
  this.prop = prop;
  this.callback = callback;
  this.value = this.getValue();//new Watcher后,自动添加进监听
}
Watcher.prototype.update = function(){
  const value = this.vm.$data[this.prop];
  const oldVal = this.value;
  if (value !== oldVal) {
    this.value = value;
    this.callback(value);
  }
}
Watcher.prototype.getValue = function(){
   Dep.target = this; //储存订阅器
   const value = this.vm.$data[this.prop]; //因为属性被监听,这一步会执行监听器里的 get方法
   Dep.target = null;
   return value;
}
复制代码

以上两步就已经实现了一个简单的双向绑定了,我们将二者结合起来,看下效果。

function MVVM(options){
   this.$options = options || {};
   this.$data = this.$options.data;
   this.$el = document.querySelector(this.$options.el);
   this.init();
}
MVVM.prototype.init = function(){
   var prop = 'name' //暂时写死属性名name
   observer(this.$data)
   this.$el.innerText = this.$data[prop] 
   new Watcher(this, prop, value => {
     this.$el.innerText = value
   })
}
复制代码

验证一下:

<!DOCTYPE html>
<html lang="en">
<head></head>
<body>
<div id="app">{{name}}</div>
</body>
<script src="test.js"></script>
<script>
  const vm = new MVVM({
    el: "#app",
    data: {
      name: "ben"
    }
  })
</script>
</html>
复制代码

页面上显示ben,当我们在浏览的控制台中输出 vm.$data.name = 'jack',页面上会瞬时显示jack。于是一个简单的双向数据绑定就这样实现了。 但是属性name是写死的,而且el.innerText也不符合扩展性,所以接下来我们来实现一个模板解析器。

  1. 实现编译器Compile

Compile 的主要作用是用来解析指令初始化模板,并且添加订阅者,绑定更新函数。 因为在解析 DOM 节点的过程中我们会频繁的操作 DOM, 所以我们利用文档片段(DocumentFragment)来帮助我们去解析 DOM 优化性能。 首先对整个节点和指令进行处理编译,根据不同的节点去调用不同的渲染函数,绑定更新函数,编译完成之后,再把 DOM 片段添加到页面中。

function Compile(vm) {
  this.vm = vm;
  this.el = vm.$el;
  this.fragment = null;
  this.init();
}
Compile.prototype = {
  init: function () {
    this.fragment = this.nodeFragment(this.el);
  },
  nodeFragment: function (el) {
    const fragment = document.createDocumentFragment();
    let child = el.firstChild;
    //将子节点,全部移动文档片段里
    while (child) {
      fragment.appendChild(child);
      child = el.firstChild;
    }
    return fragment;
  },
  compileNode: function (fragment) {
      let childNodes = fragment.childNodes;
      [...childNodes].forEach(node => {
        let reg = /\{\{(.*)\}\}/;
        let text = node.textContent;
        if (this.isElementNode(node)) {
          this.compile(node); //渲染指令模板
        } else if (this.isTextNode(node) && reg.test(text)) {
          let prop = RegExp.$1;
          this.compileText(node, prop); //渲染{{}} 模板
        }
  
        //递归编译子节点
        if (node.childNodes && node.childNodes.length) {
          this.compileNode(node);
        }
      });
    },
    compile: function (node) {
      let nodeAttrs = node.attributes;
      [...nodeAttrs].forEach(attr => {
        let name = attr.name;
        if (this.isDirective(name)) {
          let value = attr.value;
          if (name === "v-model") {
            this.compileModel(node, value);
          }
          node.removeAttribute(name);
        }
      });
    },
    //省略...
}
复制代码

此时MVVM函数就变成了这样

function MVVM(options){
   this.$options = options || {};
   this.$data = this.$options.data;
   this.$el = document.querySelector(this.$options.el);
   this.init();
}
MVVM.prototype.init = function(){
   observer(this.$data)
   new Compile(this);
}
复制代码
  1. 添加数据代理

当前, 我们修改数据时时只能通过 vm.$data.name = 'jack'这样去修改数据,而不是直接通过 vm.name = 'jack' 去修改,那么有没有办法做到呢? 答案就是添加一层数据代理。

function MVVM(options){
   this.$options = options || {};
   this.$data = this.$options.data;
   this.$el = document.querySelector(this.$options.el);
   //数据代理
   Object.keys(this.$data).forEach(key => {
      this.proxyData(key);
   });

   this.init();
}
MVVM.prototype.init = function(){
   observer(this.$data)
   new Compile(this);
}
MVVM.prototype.proxyData = function (key) {
   Object.defineProperty(this, key, {
       get: function () {
         return this.$data[key]
       },
       set: function (value) {
         this.$data[key] = value;
       }
   });
}
复制代码

Vue总结

  • 任何一个 Vue Component 都有一个与之对应的 Watcher 实例。
  • Vue 的 data 上的属性会被添加 getter 和 setter 属性。
  • 当 Vue Component render 函数被执行的时候, data 上会被 触碰(touch), 即被读, getter 方法会被调用, 此时 Vue 会去记录此 Vue component 所依赖的所有 data。(这一过程被称为依赖收集)
  • data 被改动时(主要是用户操作), 即被写, setter 方法会被调用, 此时 Vue 会去通知所有依赖于此 data 的组件去调用他们的 render 函数进行更新。
  • Vue 无法检测 property 的添加或移除。由于 Vue 会在初始化实例时对 property 执行 getter/setter 转化,所以 property 必须在 data 对象上存在才能让 Vue 将它转换为响应式的。
var vm = new Vue({
  data:{
    a:1
  }
})

// `vm.a` 是响应式的

vm.b = 2
// `vm.b` 是非响应式的

Vue.set(vm.someObject, 'b', 2) //可以通过Vue.set(object, propertyName, value) 方法向嵌套对象添加响应式 property
复制代码
  • Vue 不能检测以下数组的变动:
  1. 当你利用索引直接设置一个数组项时,例如:vm.items[indexOfItem] = newValue
  2. 当你修改数组的长度时,例如:vm.items.length = newLength
var vm = new Vue({
  data: {
    items: ['a', 'b', 'c']
  }
})
vm.items[1] = 'x' // 不是响应性的
vm.items.length = 2 // 不是响应性的

Vue.set(vm.items, indexOfItem, newValue) //可以通过Vue.set(vm.items, indexOfItem, newValue) 触发响应式状态更新
复制代码
  • Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。 如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。 然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!