Vue雙向綁定原理,教你一步一步實現雙向綁定

當今前端天下以 Angular、React、vue 三足鼎立的局面,你不選擇一個陣營基本上沒法立足於前端,甚至是兩個或者三個陣營都要選擇,大勢所趨。前端

因此咱們要時刻保持好奇心,擁抱變化,只有在不斷的變化中你才能利於不敗之地,保守只能等死。vue

最近在學習 Vue,一直以來對它的雙向綁定只能算了解並不深刻,最近幾天打算深刻學習下,經過幾天的學習查閱資料,算是對它的原理有所認識,因此本身動手寫了一個雙向綁定的例子,下面咱們一步步看如何實現的。node

看完這篇文章以後我相信你會對 Vue 的雙向綁定原理有一個清楚的認識。也能幫助咱們更好的認識 Vue。git

先看效果圖 github

//代碼:
<div id="app">
    <input v-model="name" type="text">
    <h1>{{name}}</h1>
</div>
<script src="./js/observer.js"></script>
<script src="./js/watcher.js"></script>
<script src="./js/compile.js"></script>
<script src="./js/index.js"></script>
<script>
const vm = new Mvue({
    el: "#app",
    data: {
        name: "我是摩登"
    }
});
</script>

數據綁定

在正式開始以前咱們先來講說數據綁定的事情,數據綁定個人理解就是讓數據M(model)展現到 視圖V(view)上。咱們常見的架構模式有 MVC、MVP、MVVM模式,目前前端框架基本上都是採用 MVVM 模式實現雙向綁定,Vue 天然也不例外。可是各個框架實現雙向綁定的方法略有所不一樣,目前大概有三種實現方式。瀏覽器

  • 發佈訂閱模式
  • Angular 的髒查機制
  • 數據劫持

而 Vue 則採用的是數據劫持與發佈訂閱相結合的方式實現雙向綁定,數據劫持主要經過 Object.defineProperty 來實現。前端框架

Object.defineProperty

這篇文章咱們不詳細討論 Object.defineProperty 的用法,咱們主要看看它的存儲屬性 get 與 set。咱們來看看經過它設置的對象屬性以後有何變化。微信

var people = {
    name: "Modeng",
    age: 18
}
people.age; //18
people.age = 20;

上述代碼就是普通的獲取/設置對象的屬性,看不到什麼奇怪的變化。架構

var modeng = {}
var age;
Object.defineProperty(modeng, 'age', {
  get: function () {
    console.log("獲取年齡");
    return age;
  },
  set: function (newVal) {
    console.log("設置年齡");
    age = newVal;
  }
});
modeng.age = 18;
console.log(modeng.age);

你會發現經過上述操做以後,咱們訪問 age 屬性時會自動執行 get 函數,設置 age 屬性時,會自動執行 set 函數,這就給咱們的雙向綁定提供了很是大的方便。app

分析

咱們知道 MVVM 模式在於數據與視圖的保持同步,意思是說數據改變時會自動更新視圖,視圖發生變化時會更新數據。

因此咱們須要作的就是如何檢測到數據的變化而後通知咱們去更新視圖,如何檢測到視圖的變化而後去更新數據。檢測視圖這個比較簡單,無非就是咱們利用事件的監聽便可。

那麼如何才能知道數據屬性發生變化呢?這個就是利用咱們上面說到的 Object.defineProperty 當咱們的屬性發生變化時,它會自動觸發 set 函數從而可以通知咱們去更新視圖。

實現

經過上面的描述與分析咱們知道 Vue 是經過數據劫持結合發佈訂閱模式來實現雙向綁定的。咱們也知道數據劫持是經過 Object.defineProperty 方法,當咱們知道這些以後,咱們就須要一個監聽器 Observer 來監聽屬性的變化。得知屬性發生變化以後咱們須要一個 Watcher 訂閱者來更新視圖,咱們還須要一個 compile 指令解析器,用於解析咱們的節點元素的指令與初始化視圖。因此咱們須要以下:

  • Observer 監聽器:用來監聽屬性的變化通知訂閱者
  • Watcher 訂閱者:收到屬性的變化,而後更新視圖
  • Compile 解析器:解析指令,初始化模版,綁定訂閱者

順着這條思路咱們一步一步去實現。

監聽器 Observer

監聽器的做用就是去監聽數據的每個屬性,咱們上面也說了使用 Object.defineProperty 方法,當咱們監聽到屬性發生變化以後咱們須要通知 Watcher 訂閱者執行更新函數去更新視圖,在這個過程當中咱們可能會有不少個訂閱者 Watcher 因此咱們要建立一個容器 Dep 去作一個統一的管理。

function defineReactive(data, key, value) {
  //遞歸調用,監聽全部屬性
  observer(value);
  var dep = new Dep();
  Object.defineProperty(data, key, {
    get: function () {
      if (Dep.target) {
        dep.addSub(Dep.target);
      }
      return value;
    },
    set: function (newVal) {
      if (value !== newVal) {
        value = newVal;
        dep.notify(); //通知訂閱器
      }
    }
  });
}

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

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();
  })
}
Dep.target = null;

以上咱們就建立了一個監聽器 Observer,咱們如今能夠嘗試一下給一個對象添加監聽而後改變屬性會有何變化。

var modeng = {
  age: 18
}
observer(modeng);
modeng.age = 20;

咱們能夠看到瀏覽器控制檯打印出 「屬性變化通知 Watcher 執行更新視圖函數」 說明咱們實現的監聽器沒毛病,既然監聽器有了,咱們就能夠通知屬性變化了,那確定是須要 Watcher 的時候了。

訂閱者 Watcher

Watcher 主要是接受屬性變化的通知,而後去執行更新函數去更新視圖,因此咱們作的主要是有兩步:

  1. 把 Watcher 添加到 Dep 容器中,這裏咱們用到了 監聽器的 get 函數
  2. 接收到通知,執行更新函數。
function Watcher(vm, prop, callback) {
  this.vm = vm;
  this.prop = prop;
  this.callback = callback;
  this.value = this.get();
}
Watcher.prototype = {
  update: function () {
    const value = this.vm.$data[this.prop];
    const oldVal = this.value;
    if (value !== oldVal) {
      this.value = value;
      this.callback(value);
    }
  },
  get: function () {
    Dep.target = this; //儲存訂閱器
    const value = this.vm.$data[this.prop]; //由於屬性被監聽,這一步會執行監聽器裏的 get方法
    Dep.target = null;
    return value;
  }
}

這一步咱們把 Watcher 也給弄了出來,到這一步咱們已經實現了一個簡單的雙向綁定了,咱們能夠嘗試把二者結合起來看下效果。

function Mvue(options, prop) {
    this.$options = options;
    this.$data = options.data;
    this.$prop = prop;
    this.$el = document.querySelector(options.el);
    this.init();
}
Mvue.prototype.init = function () {
    observer(this.$data);
    this.$el.textContent = this.$data[this.$prop];
    new Watcher(this, this.$prop, value => {
        this.$el.textContent = value;
    });
}

這裏咱們嘗試利用一個實例來把數據與須要監聽的屬性傳遞進來,經過監聽器監聽數據,而後添加屬性訂閱,綁定更新函數。

<div id="app">{{name}}</div>
const vm = new Mvue({
    el: "#app",
    data: {
        name: "我是摩登"
    }
}, "name");

咱們能夠看到數據已經正常的顯示在頁面上,那麼咱們在經過控制檯去修改數據,發生變化後視圖也會跟着修改。

到這一步咱們咱們基本上已經實現了一個簡單的雙向綁定,可是不難發現咱們這裏的屬性都是寫死的,也沒有指令模板的解析,因此下一步咱們來實現一個模板解析器。

Compile 解析器

Compile 的主要做用一個是用來解析指令初始化模板,一個是用來添加添加訂閱者,綁定更新函數。

由於在解析 DOM 節點的過程當中咱們會頻繁的操做 DOM, 因此咱們利用文檔片斷(DocumentFragment)來幫助咱們去解析 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;
  }
}

而後咱們就須要對整個節點和指令進行處理編譯,根據不一樣的節點去調用不一樣的渲染函數,綁定更新函數,編譯完成以後,再把 DOM 片斷添加到頁面中。

Compile.prototype = {
  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);
      }
    });
  },
  //省略。。。
}

由於代碼比較長若是所有貼出來會影響閱讀,咱們主要是講整個過程實現的思路,文章結束我會把源碼發出來,有興趣的能夠去查看所有代碼。

到這裏咱們的整個的模板編譯也已經完成,不過這裏咱們並無實現過多的指令,咱們只是簡單的實現了 v-model 指令,本意是經過這篇文章讓你們熟悉與認識 Vue 的雙向綁定原理,並非去創造一個新的 MVVM 實例。因此並無考慮不少細節與設計。

如今咱們實現了 Observer、Watcher、Compile,接下來就是把三者給組織起來,成爲一個完整的 MVVM。

建立 Mvue

這裏咱們建立一個 Mvue 的類(構造函數)用來承載 Observer、Watcher、Compile 三者。

function Mvue(options) {
  this.$options = options;
  this.$data = options.data;
  this.$el = document.querySelector(options.el);
  this.init();
}
Mvue.prototype.init = function () {
  observer(this.$data);
  new Compile(this);
}

而後咱們就去測試一下結果,看看咱們實現的 Mvue 是否是真的能夠運行。

<div id="app">
    <h1>{{name}}</h1>
</div>
<script src="./js/observer.js"></script>
<script src="./js/watcher.js"></script>
<script src="./js/compile.js"></script>
<script src="./js/index.js"></script>
<script>
    const vm = new Mvue({
        el: "#app",
        data: {
            name: "徹底沒問題,看起來是否是很酷!"
        }
    });
</script>

咱們嘗試去修改數據,也徹底沒問題,可是有個問題就是咱們修改數據時時經過 vm.$data.name 去修改數據,而不是想 Vue 中直接用 vm.name 就能夠去修改,那這個是怎麼作到的呢?其實很簡單,Vue 作了一步數據代理操做。

數據代理

咱們來改造下 Mvue 添加數據代理功能,咱們也是利用 Object.defineProperty 方法進行一步中間的轉換操做,間接的去訪問。

function Mvue(options) {
  this.$options = options;
  this.$data = options.data;
  this.$el = document.querySelector(options.el);
  //數據代理
  Object.keys(this.$data).forEach(key => {
    this.proxyData(key);
  });

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

到這裏咱們就能夠像 Vue 同樣去修改咱們的屬性了,很是完美。徹底本身動手實現,你也來試試把,體驗下本身動手寫代碼的樂趣。

總結

  1. 本文主要是對 Vue 雙向綁定原理的學習與實現。
  2. 主要是對整個思路的學習,並無考慮到太多的實現與設計的細節,因此還存在不少問題,並不完美。
  3. 源碼地址,整個過程的所有代碼,但願對你有所幫助。
  4. 若是你以爲本文對你有幫助,歡迎轉發,點贊。

關注微信公衆號:六小登登。領取全套學習資源

相關文章
相關標籤/搜索