Vue 响应式原理 & 如何实现MVVM双向绑定

前言

众所周知,Vue.js的响应式就是用了数据劫持 + 发布-订阅模式,然而深其意,身为小白,往往感受本身能回答上来,最后去有欲言又止以失败了结;做为经典的面试题之一,大多数状况下,也都只能答到“用Object.defineProperty...”这种地步html

因此写下这篇来为本身梳理一下响应式的思路vue

什么是MVVM

Model,View,View-Model就是mvvm的的含义;
imagenode

  • View 经过View-ModelDOM Listeners 将事件绑定到 Model
  • Model 则经过 Data Bindings 来管理 View 中的数据
  • View-Model 从中起到一个链接桥的做用

响应式

依照mvvm模型说的,当model(data)改变时,对应的view也会自动改变,这就是响应式
举个🌰git

// html

<div id="app">
  <input type="text" v-model='c'>
  <p>{{a.b}}</p>
  <div>my message is {{c}}</div>
</div>
// js

let mvvm = new Mvvm({
  el: '#app',
  data: {
    a: {
      b: '这是个例子'
    },
    c: 10,
  }
});

原理

当一个 Vue 实例建立时, vue 会遍历 data 选项的属性,用 Object.defineProperty 将它们转为 getter/setter 而且在内部追踪相关依赖,在属性被访问和修改时通知变化。
每一个组件实例 / 元素都有相应的 watcher 程序实例,它会在组件渲染的过程当中把属性记录为依赖,以后当依赖项的 setter 被调用时,会通知 watcher 从新计算,从而导致它关联的组件得以更新


总结,最重要就是三个步骤github

  • 数据劫持: 用 Object.defineProperty 为每一个数据设置 getter/setter
  • 数据渲染: 为页面使用到数据的每一个组件都添加一个观察者(依赖) watcher
  • 发布订阅: 为每一个数据添加订阅者(依赖收集器)dep,并将对应的观察者添加进依赖列表,每当数据更新时,订阅者(依赖收集器)通知全部对应观察者(依赖)自动更新对应页面

实现一个MVVM

思路

经过以上,咱们知道了大概的mvvm运做原理,对应以上分别实现其功能便可
一、一个数据监听Observer,对数据的全部属性进行监听,若有变更就通知订阅者dep
二、一个指令解析/渲染Compile,对每一个元素节点的指令进行扫描和解析,对应替换数据,以及绑定相应的更新函数
三、一个依赖 Watcher类和一个依赖收集器 dep
四、一个mvvm
image面试

Mvvm

咱们要打造一个Mvvm,根据以前咱们mvvm的例子数组

class Mvvm {
  constructor(option) {
    this.$option = option;
    // 初始化
    this.init();
  }

  init() {
    // 数据监控
    observe(this.$option.data);
    // 编译
    new Compile(this.$option.el);
  }
}

这里我只写了一个函数,用类写也是能够的app

/* observe监听函数,监听data中的全部数据并进行数据劫持
 * @params
 * $data - mvvm实例中的data
 */
function observe(data) {
  // 判断是否是对象
  if (typeof data !== 'object') return
  // 循环数据
  Object.keys(data).forEach(key => {
    defineReactive(data, key, data[key]);
  })

  /* 数据劫持 defineReactive
   * @param
   * obj - 监听对象; key - 遍历对象的key; val - 遍历对象的val
   */
  function defineReactive(obj, key, val) {
    // 递归子属性
    observe(val);
    // 数据劫持
    Object.defineProperty(obj, key, {
      enumerable: true, // 可枚举
      configurable: true, // 可修改
      // 设置getter 和 setter 函数来对数据劫持
      get() {
        console.log('get!', key, val);
        return val
      },
      set(newVal) {
        // 监听新数据
        observe(newVal);
        console.log('set!', key, newVal);
        val = newVal; // 赋值
      },
    })
  }
}

然而单纯这样写是不够的,由于有数组这样的特例:
Object.defineProperty严格上来讲是能够监听数组的变化, 但对于数组增长length而形成的的变化(原型方法)没法监听到的;
简单来讲就是当使用数组原型方法来改写数组的时候,虽然数据被改写了,可是咱们没法监听到数组自己的改写;
因此,在Vue中重写了数组的原型方法;
咱们也来实现这个改写:框架

// 先获取原型上的方法, 而后创造原型重写
let methods = ['pop', 'shift', 'unshift', 'sort', 'reverse', 'splice', 'push'];
let arrProto = Array.prototype;
let newArrProto = Object.create(arrProto);
methods.forEach(method => {
  newArrProto[method] = function (...args) {
    console.log('arr change!')
    // 用 function 定义该函数使得 this 指向调用的数组;若是用箭头函数 this 会指向 window
    arrProto[method].call(this, ...args)
  }
})

// 数据劫持
function observe(data) {
  // 判断是不是数组类型
+ if (Array.isArray(data)) {
+   // 将数组数据原型指针指向本身定义好的原型对象
+   data.__proto__ = newArrProto;
+   return
+ }
  ...
}

然而,这样还存在限制,那就是Vue没法检测到对象属性的添加或删除;
因此在Vue中使用了Vue.setVue.delete来弥补响应式;
这个咱们就略过了,之后有空再补dom

指令解析

/* Compile类,解析dom中全部节点上的指令
 * @params
 * $el - 须要渲染的标签
 * $vm - mvvm实例
 */
class Compile {
  constructor(el, vm) {
    this.vm = vm;
    this.$el = document.querySelector(el); // 挂载到编译实例方便操做
    this.frag = document.createDocumentFragment(); // 运用fragment类进行dom操做以节省开销
    this.reg = /\{\{(.*?)\}\}/g;

    // 将全部dom节点移入frag中
    while (this.$el.firstChild) {
      let child = this.$el.firstChild;
      this.frag.appendChild(child);
    }
    // 编译元素节点
    this.compile(this.frag);
    this.$el.appendChild(this.frag);
  }
}

这样一个编译函数框架就写好了,而后须要对里面的详细函数功能进行补充;
由于咱们须要在循环节点的时候识别文字节点上的{{xxx}}插值。。。

class Compile {
  ...
  // 编译
  compile(frag) {
    // 遍历 frag node节点
    Array.from(frag.childNodes).forEach(node => {
      let txt = node.textContent;
      
      // 编译文本 {{}}
      if (node.nodeType === 3 && this.reg.test(txt)) {
        this.compileTxt(node, RegExp.$1);
      }

      // 递归子节点
      if (node.childNodes && node.childNodes.length) this.compile(node)
    })
  }

  // 编译文字节点
  compileTxt(node, key) {
    node.textContent = typeof val === 'undefined' ? '' : val;
  }
  ...
}

到这里,初次渲染页面的时候,mvvm已经能够把实例里面的数据渲染出来了,可是还不够,由于咱们须要她能够实时自动更新

发布订阅

当一个数据在node上有多个节点/组件同时引用的时候,该数据更新时,咱们如何一个个的去自动更新页面?这就须要用到发布订阅模式了;
咱们能够在编译的时候为页面使用到数据的每一个组件都添加一个观察者(依赖) watcher
再为每一个数据添加一个订阅者(依赖收集器)dep,并将对应的观察者(依赖) watcher添加进依赖列表,每当数据更新时,订阅者(依赖收集器)通知全部对应观察者(依赖)自动更新对应页面
因此须要建立一个Dep,它能够用来收集依赖、删除依赖和向依赖发送消息

Dep

class Dep {
  constructor() {
    // 建立一个数组,用来保存全部的依赖的路径
    this.subs = [];
  }
  // 添加依赖 @sub - 依赖(watcher实例)
  addSub(sub) {
    this.subs.push(sub);
  }
  // 提醒发布
  notify() {
    this.subs.forEach(el => el.update())
  }
}

Watcher

// 观察者 / 依赖
class Watcher {
  constructor(vm, key, cb) {
    this.vm = vm;
    this.key = key;
    this.cb = cb;
    // 初始化时获取当前数据值
    this.value = this.get(); 
  }
  /* 获取当前值
   * @param $boolean: true - 数据更新 / false - 初始化
   * @return 当前的 vm[key]
   */
  get(boolean) {
    Dep.target = boolean ? null : this; 
    // 触发getter,将本身添加到 dep 中
    let value = UTIL.getVal(this.vm, this.key);
    Dep.target = null;
    return value;
  }
  update() {
    // 取得最新值; // 只有初始化的时候触发,更新的时候不触发getter
    let nowVal = this.get(true);
    // 对比旧值
    if (this.value !== nowVal) {
      console.log('update')
      this.value = nowVal;
      this.cb(nowVal);
    }
  }
}

再回到Compile中,咱们须要在第一遍渲染的时候还将为该组件建立一个wacther实例;
而后再将渲染更新的函数放到watchercb中;

class Compile{
  ...
  // 编译文字节点
  compileTxt(node, key) {
+   this.bind(node, this.vm, key, 'text');
  }

+ // 绑定依赖
+ bind(node, vm, key, dir) {
+   let updateFn = this.update(dir);
+   // 第一次渲染
+   updateFn && updateFn(node, UTIL.getVal(vm, key));
+   // 设置观察者
+   new Watcher(vm, key, (newVal) => {
+     // cb 之后的渲染
+     updateFn && updateFn(node, newVal);
+   });
+ }

+ // 更新
+ update(dir) {
+   switch (dir) {
+     case 'text': // 文本更新
+       return (node, val) => node.textContent = typeof val === 'undefined' ? '' : val;
+       break;
+   }
+ }
  ...
}

完成这些,回到原来defineReactive中,对其进行修改,为每一个数据都增添一个dep实例;
并在getter中为dep实例添加依赖;在setter中添加dep实例的发布函数;

function observe(data) {
  ...
  function defineReactive(obj, key, val) {
    // 递归子属性
    observe(val);
    // 添加依赖收集器
+   let dep = new Dep();
    // 数据劫持
    Object.defineProperty(obj, key, {
      enumerable: true, // 可枚举
      configurable: true, // 可修改
      get() {
        console.log('get!', key, val);
        // 添加订阅
+       Dep.target && dep.addSub(Dep.target);
        return val
      },
      set(newVal) {
        observe(newVal);
        console.log('set!', key, newVal);
        val = newVal;
        // 发布更新
+       dep.notify(); // 触发更新
      },
    })
  }
}

至此,一个简易的响应式Mvvm已经实现了,每当咱们修改数据的时候,其对应的页面内容也会自动从新渲染更新;
那么双向绑定又是如何实现的呢?

双向绑定

双向绑定就是在Compile的时候,对node的元素节点进行识别,若是有v-model指令,则对该元素的value值和响应数据进行绑定,并在update函数中添加对应的value更新方法

class Compile {
  // 编译
  compile(frag) {
    // 遍历 frag node节点
    Array.from(frag.childNodes).forEach(node => {
      let txt = node.textContent;

      // 编译元素节点
+     if (node.nodeType === 1) {
+       this.compileEl(node);
+     // 编译文本 {{}}
      } else if (node.nodeType === 3 && this.reg.test(txt)) {
        this.compileTxt(node, RegExp.$1);
      }

      // 递归子节点
      if (node.childNodes && node.childNodes.length) this.compile(node)
    })
  }
  ...
+ compileEl(node) {
+   // 查找指令 v-xxx
+   let attrList = node.attributes;
+   if (!attrList.length) return;
+   [...attrList].forEach(attr => {
+     let attrName = attr.name;
+     let attrVal = attr.value;
+     // 判断是否带有 ‘v-’ 指令
+     if (attrName.includes('v-')) {
+       // 编译指令 / 绑定 标签value和对应data
+       this.bind(node, this.vm, attrVal, 'model');
+       let oldVal = UTIL.getVal(this.vm, attrVal); // 获取 vm实例 当前值
+       // 增添input事件监听
+       node.addEventListener('input', e => {
+         let newVal = e.target.value; // 获取输入的新值
+         if (newVal === oldVal) return;
+         UTIL.setVal(this.vm, attrVal, newVal);
+         oldVal = newVal;
+       })
+     }
+   });
+ }
  ...
  // 更新
  update(dir) {
    switch (dir) {
      case 'text': // 文本更新
        return (node, val) => node.textContent = typeof val === 'undefined' ? '' : val;
        break;
+     case 'model': // model指令更新
+       return (node, val) => node.value = typeof val === 'undefined' ? '' : val;
+       break;
    }
  }
}

简单来讲,双向数据绑定就是给有v-xxx指令组件添加addEventListner的监听函数,一旦事件发生,就调用setter,从而调用dep.notify()通知全部依赖watcher调用watcher.update()进行更新

总结

动手实现Mvvm的过程以下

  • 利用Object.definePropertygetset进行数据劫持
  • 利用observe遍历data数据来进行监听,并为数据建立dep实例来收集依赖
  • 利用Compiledom中的全部节点进行编译,并为组件添加wathcer实例
  • 经过dep&watcher发布订阅模式实现数据与视图同步

项目源码

欢迎移步项目源码

最后

感谢阅读欢迎指正、探讨😀 各位喜欢的看官,欢迎 star 🌟

相关文章
相关标签/搜索