Vue 雙向綁定的剖析

前言

本文章純爲學習中的經驗總結,並不是教程,如有疑問歡迎討論。javascript


學習過程

最近準備研究了一下Vue的一些原理的實現方法,首先來了解一下雙向綁定的實現原理。html

看了網上不少不少的教程和講解,沒怎麼看明白,由於教程中的剖析,大多寫出幾個部分,這個部分作什麼,感受這一部分耦合度較高,由於沒有總體認知,因此看起來很難受。vue

後來本身思考了下,準備本身着手寫一下,把代碼一點一點拷貝下來,完成獨立的功能,再進行拼接,而後總結概念和思路,最後觸類旁通,若是讀者看了這篇文章,也不妨跟着寫寫,更有收穫。java

另外如下代碼使用到 es6 的語法 class 聲明語法,能夠先去複習下,以避免看起來難以理解。node

分解後的模塊

將雙向綁定分解後有如下實現過程:es6

  • 實現觀察者對數據的觀察
  • 實現訂閱者對數據的訂閱
  • 實現對HTML模板的解析和渲染
  • 實現上述的關聯,即雙向綁定

總的一看,感受不是很難理解,一步一步來看。數組


實現觀察者對數據的觀察、實現訂閱者對數據的訂閱

何爲觀察者和訂閱者

首先理解觀察者和訂閱者的關係,先舉個生活中的例子:瀏覽器

有一個食品倉庫裏面放着肉、蔬菜、奶製品等不一樣類型的食物,他們都有着本身的數目。
有一天,A 被任命爲倉庫管理員,拿到了物品數目清單,B 和 C 兩人被任命管理相應的食物:B - 肉,C - 蔬菜
與此同時,B、C 兩人向諮詢 A 並拿到了各自負責的食物的數目,而且對 A 說:若是我負責的數目改變了,立刻告訴我。
在這之後,A 在管理倉庫的時候,就開始注意肉和蔬菜的變化,肉的增減就給 B 發短信,蔬菜的增減就給 C 發短信
複製代碼

上面的例子中,A 就是觀察者,而 B、C 就是訂閱者,下面分析下:bash

觀察者,顧名思義去觀察,代碼中,就是是去觀察某個對象,假設有對象以下:app

var data = {
  name: '張三',
  age: 20
};
複製代碼

那麼觀察者就是觀察這個 data ,而訂閱者是訂閱這個對象的某個屬性,好比 data.name ,當 data.name 有變更時,觀察者就會告訴訂閱者,你訂閱的數據更新了。

實現

假設咱們有個數據 data ,那麼接下來的步驟是:

  • 生成觀察者
  • 生成訂閱者
  • 讓訂閱者去訂閱數據以便觀察者通知

生成觀察者

對象的觀察,其核心是使用 Object.defineProperty() 對字段進行數據劫持,我稱這個類爲:\color{red}{觀察者生成器}

代碼以下:

// 訂閱者生成器
class Observer {
  constructor(data) {
    this.data = data;
    Object.keys(data).forEach(key => {
      let value = data[key];

      Object.defineProperty(data, key, {
        get() {
          console.log('get value', value);
          return value;
        },
        set(newVal) {
          if (newVal !== value) {
            console.log('set value', newVal);
            value = newVal;
          }
        }
      });
    });
  }
}
複製代碼

代碼很簡單,就是使用 Object.defineProperty() 對數據進行了劫持,固然了,這並非完整的代碼,後面會根據實現一步步增長代碼,先試試效果吧。

數據的讀取觸發 get,設置觸發 set

生成訂閱者

訂閱者的功能,也很簡單,這個類類似的,我稱之爲:\color{red}{訂閱者生成器}

代碼以下:

// 訂閱者生成器
class Watcher {
  constructor(data, key, cb) {
    this.data = data;
    this.cb = cb;
    this.key = key;
    this.value = data[key];
  }

  update(newVal) {
    let oldVal = this.value;
    if (newVal !== oldVal) {
      this.value = newVal;
      this.cb(newVal, oldVal);
    }
  }
}
複製代碼

代碼也很簡單,構造函數接收 data、訂閱的 key、回調函數,存在一個 update 的方法,當值不一樣時進行回調的更新。

另外這個工具暫時只能訂閱一個屬性,實際中,訂閱者會訂閱 N 個屬性,這裏只是供學習一下。

固然這也並非完整的代碼,後面訂閱者和觀察者會有一些互動。

讓訂閱者去訂閱數據以便觀察者通知

生成好了觀察者和訂閱者,二者互動起來,那麼咱們再回到那個生活中的例子:

有一個食品倉庫裏面放着肉、蔬菜、奶製品等不一樣類型的食物,他們都有着本身的數目。
有一天,A 被任命爲倉庫管理員,拿到了物品數目清單,B 和 C 兩人被任命管理相應的食物:B - 肉,C - 蔬菜
與此同時,B、C 兩人向諮詢 A 並拿到了各自負責的食物的數目,而且對 A 說:若是我負責的數目改變了,立刻告訴我。
在這之後,A 在管理倉庫的時候,就開始注意肉和蔬菜的變化,肉的增減就給 B 發短信,蔬菜的增減就給 C 發短信
複製代碼

這個例子只是列舉了兩個訂閱者分別訂閱了對象的一個屬性的簡單狀況,實際上,可能有X個訂閱人訂閱了對象的Y個屬性,好比在例子中,5 我的對 A 說我訂閱了某某某、某某某,A 表示大家等一下,我記不住,因此 A 想了一個辦法。

假設倉庫的食物種類爲 6,那麼 A 買了 6 個筆記本,一一對應每一個食物,以便記錄誰訂閱了這個食物。
而後有一個辦公室,A 坐在辦公室裏,一次只能進一我的。
而後,就開始等着別人進來,別人說我訂閱了某某某,A 就記載那個食物對應的本子上,XX
最後本子大概就是這樣的:
蔬菜筆記本:B、C、D、F、J
豬肉筆記本:B、C、J、K
牛肉筆記本:D、F、J、P
複製代碼

上面是舉例,因此在代碼中,咱們也是相似的實現:

  • 筆記本 -> 訂閱庫
  • 辦公室 -> 當前要訂閱的人的一個標識

訂閱庫來記錄每一個訂閱者,既然有訂閱庫,那麼天然就有\color{red}{訂閱庫生成器}

代碼以下:

// 訂閱庫生成器
class Dep {
  constructor() {
    this.subs = [];
  }

  add(sub) {
    this.subs.push(sub);
  }

  notify(newVal) {
    this.subs.forEach(sub => {
      sub.update(newVal);
    });
  }
}
複製代碼

訂閱庫生成器的代碼很簡單,便是一個數組,add 方法往庫裏添加訂閱者,而 notify 去通知這個庫裏面的全部訂閱者而,即調用訂閱的者 update 方法。

而另一個關鍵 - 辦公室,簡單一點,其實就是一個變量,默認爲 null,若是有人進來了,就 = 這個訂閱者,離開了,就還原爲 null,簡單一點,掛載到訂閱庫生成器上面吧:

// 其實也能夠:let target = null;
Dep.target = null;
複製代碼

好的,如今工具已經齊全,那麼來更新一下觀察者生成器的代碼了,看完代碼看下方的解釋:

// 訂閱者生成器
class Observer {
  constructor(data) {
    this.data = data;
    Object.keys(data).forEach(key => {
      let value = data[key];
      let dep = new Dep(); // add1

      Object.defineProperty(data, key, {
        get() {
          Dep.target && dep.add(Dep.target); // add2
          return value;
        },
        set(newVal) {
          if (newVal !== value) {
            value = newVal;
            dep.notify(newVal); // add3
          }
        }
      });
    });
  }
}
複製代碼

添加的3行代碼意義爲:

  • add1:生成訂閱庫(爲這個食物類別買個筆記本)
  • add2:若是當前指向了訂閱者並要訂閱這個,就加入訂閱庫。(若是辦公室有人而且告訴A說要訂閱這個,就把這我的寫到筆記本上)
  • add3:通知訂閱庫裏的訂閱者們

完成了對觀察者生成器的改造,一樣的的,要對訂閱者生成器進行更新:

// 訂閱者生成器
class Watcher {
  constructor(data, key, cb) {
    this.data = data;
    this.cb = cb;
    this.key = key;

    Dep.target = this; // add1
    this.value = data[key];
    Dep.target = null; // add2
  }

  update(newVal) {
    let oldVal = this.value;
    if (newVal !== oldVal) {
      this.value = newVal;
      this.cb(newVal, oldVal);
    }
  }
}
複製代碼
  • add1:指向當前的訂閱者(進入辦公室)
  • add2:銷燬指向(離開辦公室)

add1 和 add2 中間的賦值操做,可以觸發 data 屬性的 get() 方法,進而將該訂閱者加入到該屬性的訂閱庫中。

至此,完成了全部工具的開發,總結一下:

  • 觀察者生長期:Observer
  • 訂閱者生成器:Watcher
  • 訂閱庫生成器:Dep
  • 訂閱者的指向:Dep.target

如今來試一下手動訂閱數據吧:

// 四個工具
class Observer {}
class Watcher {}
class Dep {}
Dep.target = null;

// 數據
var data = {
  name: '張三',
  age: 20
}

// 觀察該對象
new Observer(data);

// 生成兩個訂閱者
new Watcher(data, 'name', function(newVal) {
  console.log('A的更新操做,name的新值爲:', newVal);
});
new Watcher(data, 'age', function(newVal) {
  console.log('B的更新操做,age的新值爲:', newVal);
});

data.name = '李四'; // A的更新操做,name的新值爲: 李四
data.age =  30; // B的更新操做,age的新值爲: 30
複製代碼

實現對HTML模板的解析和渲染

實現了前面的功能,再回過頭來,實現對HTML模板的解析和渲染,既然要解析HTML,那麼須要一個HTML文檔吧,仿照vue,假設文檔片斷爲:

<div id="app">
  <input v-model="name" />
  <h1>{{ name }}</h1>
  <h1>{{ age }}</h1>
  <button v-on:click="addAge">過年了</button>
  <button v-on:click="changeName">我叫李四</button>
</div>

<script> new MyVue({ el: '#app', data: { name: '張三', age: 20 }, methods: { addAge() { console.log(this); // this.age++; }, changeName() { this.name = '李四'; } } }); </script>
複製代碼

其中,Compile 被稱之爲:\color{red}{模板解析器},這裏直接就解析了,語法和 vue 類似,但 vue 的 class vue 並不是模板解析器,其包含 Compile,相似這種的:

class Vue {
  constructor(config) {
    // some code ...

    new Compile(config);
  }
}
複製代碼

因此,也寫一個框架的入口,起名爲:MyVue:

// MyVue
class MyVue {
  constructor({ el, data, methods }) {
    this.$el = el;
    this.$data = data;
    this.$methods = methods;

    new Compile(this);
  }
}
複製代碼

就簡單作解析節點的操做,這樣初始化以後,就能顯示頁面中的 data 數據了。

如今來 Compile 的代碼以下:

// 片斷解析器
class Compile {
  constructor(vm) {
    this.vm = vm;

    let el = document.querySelector(this.vm.$el);
    let fragment = document.createDocumentFragment();

    if (el) {
      while (el.firstChild) {
        fragment.appendChild(el.firstChild);
      }

      // 編譯片斷
      this.compileElement(fragment);

      el.appendChild(fragment);
    } else {
      console.log('掛載元素不存在!');
    }
  }

  compileElement(el) {
    for (let node of el.childNodes) {
      /* node.nodeType 1:元素節點 3:文本節點 */
      if (node.nodeType === 1) {
        for (let attr of node.attributes) {
          let { name: attrName, value: exp } = attr;

          // v- 表明存在指令
          if (attrName.indexOf('v-') === 0) {
            /* <div v-xxx=""> 元素上,能夠用不少指令,這裏僅作學習,因此不判斷太多了 on 事件綁定 model 表單綁定 */
            let [dir, value] = attrName.substring(2).split(':');
            if (dir === 'on') {
              // 取 vm.methods 相應的含稅,進行綁定
              let fn = this.vm.$methods[exp];
              fn && node.addEventListener(value, fn.bind(this.vm), false);
            } else if (dir === 'model') {
              // 取 vm.data 進行 input 的賦值,而且在 input 的時候更新 vm.data 上的值
              let value = this.vm.$data[exp];
              node.value = typeof value === 'undefined' ? '' : value;

              node.addEventListener('input', e => {
                if (e.target.value !== value) {
                  this.vm.$data[exp] = e.target.value;
                }
              });
            }
          }
        }
      } else if (node.nodeType === 3) {
        let reg = /\{\{(.*)\}\}/;
        if (reg.test(node.textContent)) {
          // 這裏文本里也許會有多個 {{}} ,{{}} 內或許會有表達式,這裏簡單處理,就取一個值
          let exp = reg.exec(node.textContent)[1].trim();
          let value = this.vm.$data[exp];

          node.textContent = typeof value === 'undefined' ? '' : value;
        }
      }

      if (node.childNodes && node.childNodes.length) {
        this.compileElement(node);
      }
    }
  }
}
複製代碼

代碼看似很長,實際上很好理解。

  • 構造函數傳入 vm, el 即掛載的dom節點,this.fragment 爲臨時建立的片斷。
  • 將 el 的節點所有移入 fragment 進行編譯,編譯以後又移回

主要說明下 compileElement 這個方法。

compileElement

此操做對 fragment 進行子節點遍歷,每一個子節點進行以下操做:

    1. 若是是元素節點,那麼獲取這個元素的屬性值一一匹配 v- 指令,不一樣的指令則進行不一樣的操做
    1. 若是是文本節點,那麼獲取這個文本內容匹配 {{ }} 語法,獲得使用的屬性,爲這個屬性建立一個訂閱者。
    1. 若是節點存在子節點,那麼再次使用 compileElement 方法。

而在上述整個代碼裏,其實節點的操做已經很清楚了,也有註釋,看一下代碼:

  • myvue.js
class Observer {}
class Watcher {}
class Dep {}
Dep.target = null;
class Compile {}
class MyVue {}
複製代碼
  • index.html
<!DOCTYPE html>
<html lang="en">
  <body>
    <div id="app">
      <input v-model="name" />
      <h1>{{ name }}</h1>
      <h1>{{ age }}</h1>
      <button v-on:click="addAge">過年了,大一歲</button>
      <button v-on:click="changeName">我叫李四</button>
    </div>

    <script src="./myvue.js"></script>
    <script> new MyVue({ el: '#app', data: { name: '張三', age: 20 }, methods: { addAge() { this.age++; }, changeName() { this.name = '李四'; } } }); </script>
  </body>
</html>
複製代碼

瀏覽器執行後:

Good


實現雙向綁定

讓數據被觀察

改寫 MyVue 的代碼,讓其數據可觀察:

// 訂閱者生成器
class Observer {
  constructor(data) {
    this.data = data;
    Object.keys(data).forEach(key => {
      let value = data[key];
      let dep = new Dep();

      Object.defineProperty(data, key, {
        get() {
          Dep.target && dep.add(Dep.target);
          return value;
        },
        set(newVal) {
          if (newVal !== value) {
            value = newVal;
            dep.notify(newVal);
          }
        }
      });
    });
  }
}

// 訂閱者生成器
class Watcher {
  constructor(data, key, cb) {
    this.data = data;
    this.cb = cb;
    this.key = key;

    Dep.target = this;
    this.value = data[key];
    Dep.target = null;
  }

  update(newVal) {
    let oldVal = this.value;
    if (newVal !== oldVal) {
      this.value = newVal;
      this.cb(newVal, oldVal);
    }
  }
}

// 訂閱庫生成器
class Dep {
  constructor() {
    this.subs = [];
  }

  add(sub) {
    this.subs.push(sub);
  }

  notify(newVal) {
    this.subs.forEach(sub => {
      sub.update(newVal);
    });
  }
}
Dep.target = null;

// 片斷解析器
class Compile {
  constructor(vm) {
    this.vm = vm;

    let el = document.querySelector(this.vm.$el);
    let fragment = document.createDocumentFragment();

    if (el) {
      while (el.firstChild) {
        fragment.appendChild(el.firstChild);
      }

      // 編譯片斷
      this.compileElement(fragment);

      el.appendChild(fragment);
    } else {
      console.log('掛載元素不存在!');
    }
  }

  compileElement(el) {
    for (let node of el.childNodes) {
      /* node.nodeType 1:元素節點 3:文本節點 */
      if (node.nodeType === 1) {
        for (let attr of node.attributes) {
          let { name: attrName, value: exp } = attr;

          // v- 表明存在指令
          if (attrName.indexOf('v-') === 0) {
            /* <div v-xxx=""> 元素上,能夠用不少指令,這裏僅作學習,因此不判斷太多了 on 事件綁定 model 表單綁定 */
            let [dir, value] = attrName.substring(2).split(':');
            if (dir === 'on') {
              // 取 vm.methods 相應的含稅,進行綁定
              let fn = this.vm.$methods[exp];
              fn && node.addEventListener(value, fn.bind(this.vm), false);
            } else if (dir === 'model') {
              // 取 vm.data 進行 input 的賦值,而且在 input 的時候更新 vm.data 上的值
              let value = this.vm.$data[exp];
              node.value = typeof value === 'undefined' ? '' : value;

              node.addEventListener('input', e => {
                if (e.target.value !== value) {
                  this.vm.$data[exp] = e.target.value;
                }
              });

              new Watcher(this.vm.$data, exp, newVal => {
                node.value = typeof newVal === 'undefined' ? '' : newVal;
              });
            }
          }
        }
      } else if (node.nodeType === 3) {
        let reg = /\{\{(.*)\}\}/;
        if (reg.test(node.textContent)) {
          // 這裏文本里也許會有多個 {{}} ,{{}} 內或許會有表達式,這裏簡單處理,就取一個值
          let exp = reg.exec(node.textContent)[1].trim();
          let value = this.vm.$data[exp];

          node.textContent = typeof value === 'undefined' ? '' : value;

          new Watcher(this.vm.$data, exp, newVal => {
            node.textContent = typeof newVal === 'undefined' ? '' : newVal;
          });
        }
      }

      if (node.childNodes && node.childNodes.length) {
        this.compileElement(node);
      }
    }
  }
}

class MyVue {
  constructor({ el, data, methods }) {
    let obs = new Observer(data);

    this.$el = el;
    this.$data = obs.data;
    this.$methods = methods;

    Object.keys(this.$data).forEach(i => {
      this.proxyKeys(i);
    });

    new Compile(this);
  }

  proxyKeys(key) {
    let _this = this;
    Object.defineProperty(_this, key, {
      enumerable: false,
      configurable: true,
      get() {
        return _this.$data[key];
      },
      set(newVal) {
        _this.$data[key] = newVal;
      }
    });
  }
}
複製代碼
相關文章
相關標籤/搜索