Vue2.0源碼閱讀筆記--雙向綁定實現原理


  上一篇 文章 了解了Vue.js的生命周期。這篇分析Observe Data過程,了解Vue.js的雙向數據綁定實現原理。

一、實現雙向綁定的做法

  前端MVVM最令人激動的就是雙向綁定機制了,實現雙向數據綁定的做法大致有如下三種:

1.發布者-訂閱者模式(backbone.js)

思路:使用自定義的data屬性在HTML代碼中指明綁定。所有綁定起來的JavaScript對象以及DOM元素都將“訂閱”一個發布者對象。任何時候如果JavaScript對象或者一個HTML輸入字段被偵測到發生了變化,我們將代理事件到發布者-訂閱者模式,這會反過來將變化廣播並傳播到所有綁定的對象和元素。

2.臟值檢查(angular.js)

思路:angular.js 是通過臟值檢測的方式比對數據是否有變更,來決定是否更新視圖,最簡單的方式就是通過 setInterval() 定時輪詢檢測數據變動,angular只有在指定的事件觸發時進入臟值檢測,大致如下:

  • DOM事件,譬如用戶輸入文本,點擊按鈕等。( ng-click )

  • XHR響應事件 ( $http )

  • 瀏覽器Location變更事件 ( $location )

  • Timer事件( $timeout , $interval )

  • 執行 $digest() 或 $apply()

3.數據劫持(Vue.js)

思路: vue.js 則是采用數據劫持結合發布者-訂閱者模式的方式,通過Object.defineProperty()來劫持各個屬性的settergetter,在數據變動時發布消息給訂閱者,觸發相應的監聽回調。

由此可見,Object.defineProperty() 這個API是Vue實現雙向數據綁定的關鍵,我們先簡單了解下這個API,了解更多戳這里

二、Object.defineProperty()

簡單例子:

var obj = {};
    Object.defineProperty(obj, 'hello', {
        get: function() {
            console.log('get val:'+ val);
            return val;
       },
      set: function(newVal) {
            val = newVal;
            console.log('set val:'+ val);
        }
    });
obj.hello='111';
obj.hello;

結果:

如果去掉 obj.hello=‘111’ 這行代碼,則get的返回值val會報錯val is not defined。可見Object.defineProperty() 監控對數據的操作,可以自動觸發數據同步。下面我們先用Object.defineProperty()來實現一個非常簡單的雙向綁定。

三、實現簡單的雙向綁定

 最簡單例子:

<!DOCTYPE html>
 <head></head>
 <body>
  <div id="app">
    <input type="text" id="a">
    <span id="b"></span>
  </div>

  <script type="text/javascript">
   var obj = {};
   Object.defineProperty(obj, 'hello', {
       get: function() {
           console.log('get val:'+ val);
           return val;
       },
       set: function(newVal) {
            val = newVal;
            console.log('set val:'+ val);
            document.getElementById('a').value = val;
            document.getElementById('b').innerHTML = val;
       }
    });
    document.addEventListener('keyup', function(e) {
      obj.hello = e.target.value;
    });
   </script>
  </body>
</html>

實現效果如下:

上面例子直接用了dom操作改變了文本節點的值,而且是在我們知道是哪個id的情況下,通過document.getElementById 獲取到相應的文本節點,然后直接修改文本節點的值,這種做法是最簡單粗暴的。

封裝成一個框架,肯定不能是這種做法,所以我們需要一個解析dom,並能修改dom中相應的變量的模塊。

四、實現簡單Compile

首先我們需要獲取文本中真實的dom節點,然后再分析節點的類型,根據節點類型做相應的處理。

在上面例子我們多次操作了dom節點,為提高性能和效率,會先將所有的節點轉換城文檔碎片fragment進行編譯操作,解析操作完成后,再將fragment添加到原來的真實dom節點中。

<!DOCTYPE html>
  <head></head>
  <body>
  <div id="app">
    <input type="text" id="a" v-model="text">
    {{text}}
  </div>
 <script type="text/javascript">
  function Compile(node, vm) {
      if(node) {this.$frag = this.nodeToFragment(node, vm);
        return this.$frag;
      }
    }
    Compile.prototype = {
      nodeToFragment: function(node, vm) {
        var self = this;
        var frag = document.createDocumentFragment();
        var child;

        while(child = node.firstChild) {
          self.compileElement(child, vm);
          frag.append(child); // 將所有子節點添加到fragment中,child是指向元素首個子節點的引用。將child引用指向的對象append到父對象的末尾,原來child引用的對象就跳到了frag對象的末尾,而child就指向了本來是排在第二個的元素對象。如此循環下去,鏈接就逐個往后跳了
        }
        return frag;
      },
      compileElement: function(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.data[name]= e.target.value;
              });
              node.value = vm.data[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 Vue(options) { this.data = options.data; var data = this.data; var id = options.el; var dom =new Compile(document.getElementById(id),this); // 編譯完成后,將dom返回到app中 document.getElementById(id).appendChild(dom); } var vm = new Vue({ el: 'app', data: { text: 'hello world' } }); </script> </body> </html>

結果:

到這,我們做到了獲取文本中真實的dom節點,然后分析節點的類型,並能處理節點中相應的變量如上面代碼中的{{text}},最后渲染到頁面中。接着我們需要和雙向綁定聯系起來,實現{{text}}響應式的數據綁定。

五、實現簡單observe

簡單的observe定義如下:

需要監控data的屬性值,這個對象的某個值賦值,就會觸發setter,這樣就能監聽到數據變化。然后注意vm.data[name]屬性將改為vm[name]

完整代碼如下:

<!DOCTYPE html>
  <head></head>
  <body>
  <div id="app">
    <input type="text" id="a" v-model="text">
    {{text}}
  </div>
<script type="text/javascript">
  function Compile(node, vm) {
      if(node) {
        this.$frag = this.nodeToFragment(node, vm);
        return this.$frag;
      }
    }
    Compile.prototype = {
      nodeToFragment: function(node, vm) {
        var self = this;
        var frag = document.createDocumentFragment();
        var child;

        while(child = node.firstChild) {
          self.compileElement(child, vm);
          frag.append(child); // 將所有子節點添加到fragment中
        }
        return frag;
      },
      compileElement: function(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
            // new Watcher(vm, node, name);
          }
        }
      },
    }
    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 observe(obj, vm) {
      Object.keys(obj).forEach(function(key) {
        defineReactive(vm, key, obj[key]);
      })
    }
   function Vue(options) {
      this.data = options.data;
      var data = this.data;
      observe(data, this);
      var id = options.el;
      var dom =new Compile(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

結果:

到這,雖然set方法觸發了,但是文本節點{{text}}的內容沒有變化,要讓綁定的文本節點同步變化,我們需要引入訂閱發布模式。

六、訂閱發布模式

訂閱發布模式(又稱觀察者模式)定義了一種一對多的關系,讓多個觀察者同時監聽某一個主題對象,這個主題對象的狀態發生改變時就會通知所有觀察者對象。

     發布者發出通知 => 主題對象收到通知並推送給訂閱者 => 訂閱者執行相應操作

 首先我們要一個收集訂閱者的容器,定義一個Dep作為主題對象

然后定義訂閱者Watcher

添加訂閱者Watcher到主題對象Dep,發布者發出通知放到屬性監聽里面

最后需要訂閱的地方

至此,比較簡單地實現了我們第三步用dom操作實現的雙向綁定效果,代碼:

<!DOCTYPE html>
  <head></head>
  <body>
  <div id="app">
    <input type="text" id="a" v-model="text">
    {{text}}
  </div>
  <script type="text/javascript">
  function Compile(node, vm) {
      if(node) {
        this.$frag = this.nodeToFragment(node, vm);
        return this.$frag;
      }
    }
    Compile.prototype = {
      nodeToFragment: function(node, vm) {
        var self = this;
        var frag = document.createDocumentFragment();
        var child;

        while(child = node.firstChild) {
          self.compileElement(child, vm);
          frag.append(child); // 將所有子節點添加到fragment中
        }
        return frag;
      },
      compileElement: function(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
              new Watcher(vm, node, name, 'value');
            }
          };
        }
        //節點類型為text
        if(node.nodeType === 3) {
          if(reg.test(node.nodeValue)) {
            var name = RegExp.$1; // 獲取匹配到的字符串
            name = name.trim();
            // node.nodeValue = vm[name]; // 將data的值賦給該node
            new Watcher(vm, node, name, 'nodeValue');
          }
        }
      },
    }
    function Dep() {
      this.subs = [];
    }
    Dep.prototype = {
      addSub: function(sub) {
        this.subs.push(sub);
      },
      notify: function() {
        this.subs.forEach(function(sub) {
          sub.update();
        })
      }
    }
    function Watcher(vm, node, name, type) {
      Dep.target = this;
      this.name = name;
      this.node = node;
      this.vm = vm;
      this.type = type;
      this.update();
      Dep.target = null;
    }

    Watcher.prototype = {
      update: function() {
        this.get();
        this.node[this.type] = this.value; // 訂閱者執行相應操作
      },
      // 獲取data的屬性值
      get: function() {
        this.value = this.vm[this.name]; //觸發相應屬性的get
      }
    }
    function defineReactive (obj, key, val) {
      var dep = new Dep();
      Object.defineProperty(obj, key, {
        get: function() {
           //添加訂閱者watcher到主題對象Dep
          if(Dep.target) {
            // JS的瀏覽器單線程特性,保證這個全局變量在同一時間內,只會有同一個監聽器使用
            dep.addSub(Dep.target);
          }
          return val;
        },
        set: function (newVal) {
          if(newVal === val) return;
          val = newVal;
          console.log(val);
          // 作為發布者發出通知
          dep.notify();
        }
      })
    }
    function observe(obj, vm) {
      Object.keys(obj).forEach(function(key) {
        defineReactive(vm, key, obj[key]);
      })
    }

   function Vue(options) {
      this.data = options.data;
      var data = this.data;
      observe(data, this);
      var id = options.el;
      var dom =new Compile(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

七、總結

關於雙向綁定的實現,看了網上很多資料,開始看到是對Vue源碼的解析,看的過程似懂非懂。后來找到參考資料1,然后自己跟着實現一遍,才理解許多。感謝這篇文章的作者,寫的由淺入深,比較好理解。為了加深自己的理解,於是自己順着這個思路寫下這個筆記。本文主要了解了幾種雙向綁定的做法,然后先用原生JS,dom操作實現一個最簡單雙向綁定,在這個基礎上進行改裝,為減少dom操作,實現簡單的Compile(編譯HTML);接着為了實現數據監聽,實現observe;最后為了實現數據的雙向綁定實現訂閱發布模式。

雖然實現的比較簡單,有很多功能沒有考慮,不過這個過程還是可以理解到Vue實現雙向綁定的原理。過程中,有思考:

1. Vue的源代碼中,用了文檔碎片fragment作為真實節點的存儲嗎?

之前有聽說用VDOM,在Vue源代碼中,也找過是否有創建文檔碎片,結果沒找到。看了參考資料4中,VDOM的介紹,好像是把節點用JS對象模擬。類似:

;模板
<ul id='list'>
  <li class='item'>Item 1</li>
  <li class='item'>Item 2</li>
  <li class='item'>Item 3</li>
</ul>

;js對象
var element = {
  tagName: 'ul', // 節點標簽名
  props: { // DOM的屬性,用一個對象存儲鍵值對
    id: 'list'
  },
  children: [ // 該節點的子節點
    {tagName: 'li', props: {class: 'item'}, children: ["Item 1"]},
    {tagName: 'li', props: {class: 'item'}, children: ["Item 2"]},
    {tagName: 'li', props: {class: 'item'}, children: ["Item 3"]},
  ]
}

恩,這就又牽扯出模板了。先收住,我先盡量把簡單的搞懂。

2.Compile模塊對v-model節點的解析,事件的綁定,我只實現簡單的,特定的v-model,還有其它事件綁定如v-on等沒有分析,看了別人的代碼,情況一多起來,看得就有些吃力,希望后面自己會再來完善,給自己定一個這樣的框架在這,代碼github:戳這里

參考資料:

1.http://www.cnblogs.com/kidney/p/6052935.html?utm_source=gold_browser_extension

2.https://segmentfault.com/a/1190000006599500

3.https://github.com/fwing1987/MyVue

4.http://www.kancloud.cn/zmwtp/vue2/149485

5.http://blog.cgsdream.org/2016/11/05/vue-source-analysis-1/


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM