Vue.js雙向綁定原理

Vue.js最核心的功能有兩個,一個是響應式的數據綁定系統,另外一個是組件系統。本文僅僅探究雙向綁定是怎樣實現的。先講涉及的知識點,再用簡化的代碼實現一個簡單的hello world示例。html

 

1、訪問器屬性node

訪問器屬性是對象中的一種特殊屬性,它不能直接在對象中設置,而必須經過defineProperty()方法單獨定義。git

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title></title>
  </head>
  <body>
    <script>
      var obj = {};
      Object.defineProperty(obj, 'hello', {
        get: function() {
          console.log('get方法被調用了');
        },
        set: function(val) {
          console.log('set方法被調用了,參數是' + val);
        }
      });
      obj.hello; //get方法被調用了
      obj.hello = 'abc'; //set方法被調用了,參數是abc
    </script>
  </body>
</html>
View Code

get和set方法內部的this都指向obj,這意味着get和set函數能夠操做對象內部的值。另外,訪問器屬性的會「覆蓋」同名的普通屬性,由於訪問器屬性會被優先訪問,與其同名的普通屬性則會被忽略。github

 

2、極簡的雙向綁定實現segmentfault

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title></title>
  </head>
  <body>
    <input type="text" id="a" />
    <span id="b"></span>
    <script>
      var obj = {};
      Object.defineProperty(obj, 'hello', {
        set: function(newval) {
          document.getElementById('a').value = newval;
          document.getElementById('b').innerHTML = newval
        }
      });
      document.addEventListener('keyup', function(e) {
        obj.hello = e.target.value;
      })
    </script>
  </body>
</html>
View Code

此例實現的效果是:隨着文本框輸入文字的變化,span中會同步顯示相同的內容。在js或者在控制檯上顯式的修改obj.hello的值,視圖會相應的更新。這樣就實現了model=>view以及view=>model的雙向綁定。瀏覽器

以上就是Vue實現雙向綁定的基本原理。app

 

3、分解任務dom

上述示例僅僅是爲了說明原理,咱們最終要實現的是:ide

<div id="app">
  <input type="text" v-model="text">
  {{ text }}
</div>

var vm = new Vue({
  el:'#app',
  data:{
    text:'hello world'
  }
})

首先將該任務分紅幾個子任務:函數

一、輸入框以及文本節點與data中的數據綁定;

二、輸入框內容變化時,data中的數據同步變化,即view =>model的變化;

三、data中的數據變化時,文本節點的內容同步變化,即model =>view的變化;

要實現任務1,須要對DOM進行編譯,這裏有一個知識點:DocumentFragment。

 

4、DocumentFragment

DocumentFragment(文檔片斷)能夠看作節點容器,它能夠包含多個子節點,當咱們將它插入到DOM中時,只有它的子節點會插入目標節點,因此把它看做一組節點的容器。使用DocumentFragment處理節點,速度和性能遠遠優於直接操做DOM。Vue進行編譯時,就是將掛載目標的全部子節點劫持(真的是劫持,經過append方法,DOM中的節點會被自動刪除)到DocumentFragment中,通過一番處理後,再將DocumentFragment總體返回插入掛載目標。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title></title>
  </head>
  <body>
    <div id="app">
      <input type="text" id="a" />
      <span id="b"></span>
    </div>
    <script>
      var dom = nodeToFragment(document.getElementById('app'));
      console.log(dom);

      function nodeToFragment(node) {
        var flag = document.createDocumentFragment();
        var child;
        while(child == node.firstChild) {
          flag.appendChild(child); //劫持node的全部子節點
        }
        return flag;
      }

      document.getElementById('app').appendChild(dom); //返回到app中
    </script>
  </body>
</html>
View Code

 

5、數據初始化綁定

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Two-way-data-binding</title>
  </head>
  <body>
    <div id="app">
      <input type="text" v-model="text"> {{ text }}
    </div>

    <script>
      function compile(node, vm) {
        var reg = /\{\{(.*)\}\}/;
        // 節點類型爲元素
        if(node.nodeType === 1) {
          var attr = node.attributes;
          // 解析屬性
          for(var i = 0; i < attr.length; i++) {
            if(attr[i].nodeName == 'v-model') {
              var name = attr[i].nodeValue; // 獲取 v-model 綁定的屬性名
              node.value = vm[name]; // 將 data 的值賦給該 node
              node.removeAttribute('v-model');
            }
          };
        }
        // 節點類型爲 text
        if(node.nodeType === 3) {
          if(reg.test(node.nodeValue)) {
            var name = RegExp.$1; // 獲取匹配到的字符串
            name = name.trim();
            node.nodeValue = vm.data[name]; //將data的值賦給該node
          }
        }
      }

      function nodeToFragment(node, vm) {
        var flag = document.createDocumentFragment();
        var child;
        // 全部表達式必然會返回一個值,賦值表達式亦不例外
        // 理解了上面這一點,就能理解 while (child = node.firstChild) 這種用法
        // 其次,appendChild 方法有個隱蔽的地方,就是調用之後 child 會從原來 DOM 中移除
        // 因此,第二次循環時,node.firstChild 已經再也不是以前的第一個子元素了
        while(child = node.firstChild) {
          compile(child, vm);
          flag.appendChild(child); // 將子節點劫持到文檔片斷中
        }
        return flag;
      }

      function Vue(options) {
        this.data = options.data;
        var id = options.el;
        var dom = nodeToFragment(document.getElementById(id), this);
        // 編譯完成後,將 dom 返回到 app 中
        document.getElementById(id).appendChild(dom);
      }
      var vm = new Vue({
        el: 'app',
        data: {
          text: 'hello world'
        }
      });
    </script>
  </body>
</html>
View Code

以上代碼實現了任務一,咱們能夠看到,hello world已經呈如今輸入框和文本節點中。

 

6、響應式的數據綁定

再來看任務2的是實現思路:當咱們在輸入框輸入數據的時候,首先觸發input事件或者keyup、change事件,在相應的事件處理程序中,咱們獲取輸入框的value並賦值給vm實例的text屬性。咱們會利用defineProperty將data中的text設置爲vm的訪問器屬性,所以給vm.text賦值就會觸發set方法。在set方法中主要作兩件事,第一是更新屬性的值,第二留到任務3來講。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Two-way-data-binding</title>
  </head>
  <body>
    <div id="app">
      <input type="text" v-model="text"> {{ text }}
    </div>

    <script>
      function observe(obj, vm) {
        Object.keys(obj).forEach(function(key) {
          defineReactive(vm, key, obj[key]);
        })
      }

      function defineReactive(obj, key, val) {
        Object.defineProperty(obj, key, {
          get: function() {
            return val
          },
          set: function(newVal) {
            if(newVal === val) return
            val = newVal;
            console.log(val); //方便看效果
          }
        });
      }

      function nodeToFragment(node, vm) {
        var flag = document.createDocumentFragment();
        var child;
        // 全部表達式必然會返回一個值,賦值表達式亦不例外
        // 理解了上面這一點,就能理解 while (child = node.firstChild) 這種用法
        // 其次,appendChild 方法有個隱蔽的地方,就是調用之後 child 會從原來 DOM 中移除
        // 因此,第二次循環時,node.firstChild 已經再也不是以前的第一個子元素了
        while(child = node.firstChild) {
          compile(child, vm);
          flag.appendChild(child); // 將子節點劫持到文檔片斷中
        }
        return flag;
      }

      function compile(node, vm) {
        var reg = /\{\{(.*)\}\}/;
        // 節點類型爲元素
        if(node.nodeType === 1) {
          var attr = node.attributes;
          // 解析屬性
          for(var i = 0; i < attr.length; i++) {
            if(attr[i].nodeName == 'v-model') {
              var name = attr[i].nodeValue; // 獲取 v-model 綁定的屬性名
              node.addEventListener('input', function(e) {
                // 給相應的 data 屬性賦值,進而觸發該屬性的 set 方法
                vm[name] = e.target.value;
              });
              node.value = vm[name]; // 將 data 的值賦給該 node
              node.removeAttribute('v-model');
            }
          };
        }
        // 節點類型爲 text
        if(node.nodeType === 3) {
          if(reg.test(node.nodeValue)) {
            var name = RegExp.$1; // 獲取匹配到的字符串
            name = name.trim();
            node.nodeValue = vm[name]; //將data的值賦給該node
          }
        }
      }

      function Vue(options) {
        this.data = options.data;
        var data = this.data;
        observe(data, this);
        var id = options.el;
        var dom = nodeToFragment(document.getElementById(id), this);
        // 編譯完成後,將 dom 返回到 app 中
        document.getElementById(id).appendChild(dom);
      }
      var vm = new Vue({
        el: 'app',
        data: {
          text: 'hello world'
        }
      });
    </script>
  </body>
</html>
View Code

任務2也就完成了,text屬性值會與輸入框的內容同步變化(打開瀏覽器後臺進行查看)。

 

7、訂閱/發佈模式(subscribe&publish)

text屬性變化了,set方法觸發了,可是文本節點的內容沒有變化。如何讓一樣綁定到text的文本節點也同步變化呢?這裏又有一個知識點:訂閱發佈模式。

訂閱發佈模式(又稱觀察者模式)定義了一種一對多的關係,讓多個觀察者同時監聽某一個主題對象,這個主題對象的狀態發生改變時就會通知全部觀察者對象。發佈者發出通知 =>主題對象收到通知並推送給訂閱者 =>訂閱者執行相應操做。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Two-way-data-binding</title>
  </head>
  <body>
    <script>
      //一個發佈者publisher
      var pub = {
        publish: function() {
          dep.notify();
        }
      }
      
      //三個訂閱者subscribers
      var sub1 = {
        update: function() {
          console.log(1)
        }
      };
      var sub2 = {
        update: function() {
          console.log(2)
        }
      };
      var sub3 = {
        update: function() {
          console.log(3)
        }
      };
      
      //一個主題對象
      function Dep() {
        this.subs = [sub1, sub2, sub3];
      }
      Dep.prototype.notify = function() {
        this.subs.forEach(function(sub) {
          sub.update();
        })
      }
      
      //發佈者發佈消息,主題對象執行notify方法,進而觸發訂閱者執行update方法
      var dep = new Dep();
      pub.publish(); //1,2,3
    </script>
  </body>
</html>
View Code

以前提到的,當set方法觸發後作的第二件事就是做爲發佈者發出通知:「我是屬性text,我變了」。文本節點則是做爲訂閱者,在收到消息後執行相應的更新操做。

 

8、雙向綁定的實現

回顧一下,每當 new 一個 Vue,主要作了兩件事:第一個是監聽數據:observe(data),第二個是編譯 HTML:nodeToFragement(id)。

在監聽數據的過程當中,會爲data中的每個屬性生成一個主題對象dep。

在編譯HTML的過程當中,會爲每一個與數據綁定相關的節點生成一個訂閱者watcher,watcher會將本身添加到相應屬性的dep中。

咱們已經實現:修改輸入框內容 =>在事件回調函數中修改屬性值 =>觸發屬性的set方法。接下來咱們要實現的是:發出通知dep.notify() =>觸發訂閱者的update方法 =>更新視圖。

這裏的關鍵邏輯時:如何將watcher添加到關聯屬性的dep中。

 function compile(node, vm) {
   var reg = /\{\{(.*)\}\}/;
   // 節點類型爲元素
   if(node.nodeType === 1) {
     var attr = node.attributes;
     // 解析屬性
     for(var i = 0; i < attr.length; i++) {
       if(attr[i].nodeName == 'v-model') {
         var name = attr[i].nodeValue; // 獲取 v-model 綁定的屬性名
         node.addEventListener('input', function(e) {
           // 給相應的 data 屬性賦值,進而觸發該屬性的 set 方法
           vm[name] = e.target.value;
         });
         node.value = vm[name]; // 將 data 的值賦給該 node
         node.removeAttribute('v-model');
       }
     };
     new Watcher(vm, node, name, 'input');
   }
   // 節點類型爲 text
   if(node.nodeType === 3) {
     if(reg.test(node.nodeValue)) {
       var name = RegExp.$1; // 獲取匹配到的字符串
       name = name.trim();
       new Watcher(vm, node, name, 'text');
     }
   }
 }
View Code

在編譯HTML過程當中,爲每一個與data關聯的節點生成一個watcher,watcher函數中發生了什麼呢?

function Watcher(vm, node, name, nodeType) {
  Dep.target = this;
  this.name = name;
  this.node = node;
  this.vm = vm;
  this.update();
  Dep.target = null;
}
Watcher.prototype = {
  update: function() {
    this.get();
    this.node.nodeValue = this.value;
  },
  // 獲取 data 中的屬性值
  get: function() {
    this.value = this.vm[this.name]; // 觸發相應屬性的 get
  }
}
View Code

首先,將本身賦給了一個全局變量Dep.target;

其次,執行了update方法,進而執行了get方法,get的方法讀取了vm的訪問器屬性,從而觸發了訪問器屬性的get方法,get方法中將該watcher添加到了對應訪問器屬性的dep中;

接着,獲取屬性的值,而後更新視圖。

最後,將Dep.target設爲空,由於它是全局變量,也是watcher與dep關聯的惟一橋樑,任什麼時候刻都必須保證Dep.target只有一個值。

function defineReactive(obj, key, val) {
  var dep = new Dep();
  Object.defineProperty(obj, key, {
    get: function() {
      // 添加訂閱者 watcher 到主題對象 Dep
      if(Dep.target) dep.addSub(Dep.target);
      return val;
    },
    set: function(newVal) {
      if(newVal === val) return
      val = newVal;
      // 做爲發佈者發出通知
      dep.notify();
    }
  });
}

function Dep() {
  this.subs = []
}
Dep.prototype = {
  addSub: function(sub) {
    this.subs.push(sub);
  },
  notify: function() {
    this.subs.forEach(function(sub) {
      sub.update();
    });
  }
}
View Code

 

至此,hello world雙向綁定就基本實現了。文本內容會隨輸入框內容同步變化,在控制器中修改vm.text的值,會同步反映到文本內容中。如下是完整代碼:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Two-way-data-binding</title>
  </head>
  <body>

    <div id="app">
      <input type="text" v-model="text"> {{ text }}
    </div>

    <script>
      function observe(obj, vm) {
        Object.keys(obj).forEach(function(key) {
          defineReactive(vm, key, obj[key]);
        })
      }

      function defineReactive(obj, key, val) {
        var dep = new Dep();
        Object.defineProperty(obj, key, {
          get: function() {
            // 添加訂閱者 watcher 到主題對象 Dep
            if(Dep.target) dep.addSub(Dep.target);
            return val
          },
          set: function(newVal) {
            if(newVal === val) return
            val = newVal;
            // 做爲發佈者發出通知
            dep.notify();
          }
        });
      }

      function nodeToFragment(node, vm) {
        var flag = document.createDocumentFragment();
        var child;
        // 全部表達式必然會返回一個值,賦值表達式亦不例外
        // 理解了上面這一點,就能理解 while (child = node.firstChild) 這種用法
        // 其次,appendChild 方法有個隱蔽的地方,就是調用之後 child 會從原來 DOM 中移除
        // 因此,第二次循環時,node.firstChild 已經再也不是以前的第一個子元素了
        while(child = node.firstChild) {
          compile(child, vm);
          flag.appendChild(child); // 將子節點劫持到文檔片斷中
        }
        return flag
      }

      function compile(node, vm) {
        var reg = /\{\{(.*)\}\}/;
        // 節點類型爲元素
        if(node.nodeType === 1) {
          var attr = node.attributes;
          // 解析屬性
          for(var i = 0; i < attr.length; i++) {
            if(attr[i].nodeName == 'v-model') {
              var name = attr[i].nodeValue; // 獲取 v-model 綁定的屬性名
              node.addEventListener('input', function(e) {
                // 給相應的 data 屬性賦值,進而觸發該屬性的 set 方法
                vm[name] = e.target.value;
              });
              node.value = vm[name]; // 將 data 的值賦給該 node
              node.removeAttribute('v-model');
            }
          };
          new Watcher(vm, node, name, 'input');
        }
        // 節點類型爲 text
        if(node.nodeType === 3) {
          if(reg.test(node.nodeValue)) {
            var name = RegExp.$1; // 獲取匹配到的字符串
            name = name.trim();
            new Watcher(vm, node, name, 'text');
          }
        }
      }

      function Watcher(vm, node, name, nodeType) {
        Dep.target = this;
        this.name = name;
        this.node = node;
        this.vm = vm;
        this.nodeType = nodeType;
        this.update();
        Dep.target = null;
      }
      Watcher.prototype = {
        update: function() {
          this.get();
          if(this.nodeType == 'text') {
            this.node.nodeValue = this.value;
          }
          if(this.nodeType == 'input') {
            this.node.value = this.value;
          }
        },
        // 獲取 data 中的屬性值
        get: function() {
          this.value = this.vm[this.name]; // 觸發相應屬性的 get
        }
      }

      function Dep() {
        this.subs = []
      }
      Dep.prototype = {
        addSub: function(sub) {
          this.subs.push(sub);
        },
        notify: function() {
          this.subs.forEach(function(sub) {
            sub.update();
          });
        }
      }

      function Vue(options) {
        this.data = options.data;
        var data = this.data;
        observe(data, this);
        var id = options.el;
        var dom = nodeToFragment(document.getElementById(id), this);
        // 編譯完成後,將 dom 返回到 app 中
        document.getElementById(id).appendChild(dom);
      }
      var vm = new Vue({
        el: 'app',
        data: {
          text: 'hello world'
        }
      })
    </script>
  </body>
</html>
View Code

 

參考文章1:https://github.com/DDFE/DDFE-blog/issues/7

參考文章2:http://www.javashuo.com/article/p-brudryzf-hq.html

原文地址

相關文章
相關標籤/搜索