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 🌟

相關文章
相關標籤/搜索