搞懂:MVVM模型以及VUE中的資料繫結資料劫持釋出訂閱模式

JJesson發表於2020-05-20

搞懂:MVVM模式和Vue中的MVVM模式

MVVM

  • MVVM : model - view - viewmodel的縮寫,說都能直接說出來 model:模型,view:檢視,view-Model:檢視模型
    • V:檢視,即瀏覽器最前端渲染的頁面
    • M:模型,資料模型,就是後端頁面渲染依賴的資料
    • VM:稍後再說,因為暫時還不知道怎麼工作,什麼場景,直接解釋有點沒用
  • 那就先說說前端場景:
    • 如果資料改變,想要前端頁面做出相應的改變,有幾種方法:
      • 1.使用原生js
            var dom = document.getElementById('xxx')
            dom.value = xxx;    // 直接修改值
            dom.innerHtml = xxx;    //改變開始 和 結束標籤中的html
        
      • 2.使用jquery
              $('#name').text('Homer').css('color', 'red');
        
    • 上面可以看出來,jquery確實在dom操作方面簡化了很多,鏈式呼叫和更加人性化的api在沒有mvvm模型出世之前,使用率極高
    • 但是,也可以看出來,資料和頁面檢視之間存在斷層,資料影響檢視,甚至是檢視中的節點改變資料,這都是極其頻繁的頁面操作,雖然一再簡化這個程式導向的邏輯操作,但是還是避免不了手動修改的弊端。
    • 有沒有一種更好的方式,可以實現這種檢視(view)和模型(model)之間的關係
  • VM:
    • 再看看現在VUE框架中怎麼做到這種檢視和模型的聯動

          //html
          <input v-model = 'val' placeholder = 'edit here'>
      
          //script
          export defaults{
              data:function(){
                  return {
                      val:''
                  }
              }
          }
      

      很簡單,很常用的v-model指令,那麼在input值修改的時候,data中的val變數值也會改變,直接在js中改變val的值的時候,input中的value也會改變??我們做了什麼,我們怎麼將資料和檢視聯絡起來的?自動會關聯這兩個東西

    • 可能,這就是VM吧~

      • vm:viewModel檢視模型,就是將資料model和檢視view關聯了起來,負責將model資料同步到view顯示,也同時把view修改的資料同步到model,我們無需關心中間的邏輯,開發者更多的是直接運算元據,至於更新檢視或者會寫model,都是我們寫好的檢視模型(viewModel)幫我們處理
    • 概念:檢視模型層,是一個抽象化的邏輯模型,連線檢視(view)和模型(model),負責:資料到檢視的顯示,檢視到資料的回寫

VUE中的MVVM(雙向繫結)

vue框架中雙向繫結是最常用的一個實用功能。實現的方式也網上很多文章,vue2.x是Object.DefineProperty,vue3.x是Es6語法的proxy代理語法

  • 具體是怎麼做到的

    ps:暫時先看vue2.x

    • Object.setProperty(),設定和修改Javascript中物件屬性值,定義物件屬性的get和set方法,可以在物件獲取值和修改值時觸發回撥函式,實現資料劫持,並且拿到新的改變後的值
    • 需要根據初始化物件值和修改之後拿到改變後的值,對已繫結模板節點進行資料更新。
  • 第一步:監聽物件所有屬性值變化(Observer)

    var data = {test: '1'};
      observe(data);
      data.test = '2'; // changed 1 --> 2
    
      function observe(data) {
          if (!data || typeof data !== 'object') {
              return;
          }
          // 取出所有屬性遍歷
          Object.keys(data).forEach(function(key) {
              defineReactive(data, key, data[key]);
          });
      };
    
      function defineReactive(data, key, val) {
          observe(val); // 監聽子屬性
          Object.defineProperty(data, key, {
              enumerable: true, // 可列舉
              configurable: false, // 防止重複定義或者衝突
              get: function() {
                  return val;
              },
              set: function(newVal) {
                  console.log('changed ', val, ' --> ', newVal);
                  val = newVal;
              }
          });
      }
    
    
    • 第二步:怎麼做到對有繫結關係的節點進行更新和初始化值呢?如果一個資料物件繫結了多個dom節點,怎麼統一通知所有dom節點呢,這就需要用到釋出者-訂閱者模式
      • 這裡是Observer作為一個察覺資料變化的釋出者,發現資料變化時,觸發所有訂閱者(Watcher)的更新update事件,首先要擁有一個能儲存所有訂閱者佇列,並且能通知所有訂閱者的中介軟體(訊息訂閱器Dep

            function Dep () {
                // 訂閱者陣列
                this.subs = [];
            }
            Dep.prototype = {
                addSub: function(sub) {
                    this.subs.push(sub);
                },
                notify: function() {
                    //通知所有訂閱者
                    this.subs.forEach(function(sub) {
                        sub.update();
                    });
                }
            };
        
      • 並且在觀察者Observer中修改當Object物件屬性發生變化時,觸發Dep中的notify事件,所有訂閱者可以接收到這個改變

            function defineReactive(data, key, val) {
                observe(val); 
                var dep = new Dep(); 
                Object.defineProperty(data, key, {
                    enumerable: true,
                    configurable: false, 
                    get: function() {
                        return val;
                    },
                    set: function(newVal) {
                        //修改的在這裡
                        if(newVal === val){
                            return
                        }
                        // 如果新值不等於舊值發生變化,觸發所有訂閱中介軟體的notice方法,所有訂閱者發生變化
                        val = newVal
                        console.log('changed ', val, ' --> ', newVal);
                        dep.notify();
                        
                    }
                });
            }
        
      • 但是有沒有發現還有一個問題,Dep訂閱中介軟體中的訂閱者陣列一直是空的,什麼時候把訂閱者新增進來我們的訂閱中介軟體中間,哪些訂閱者需要新增到我們的中介軟體陣列中

        • 1.我們希望的是訂閱者Watcher在例項化的時候自動新增到Dep中
        • 2.有且僅有在第一次例項化的時候新增進去,不允許重複新增
        • 3.由於Dep在釋出者資料變化時會觸發所有訂閱則的update事件,所以Watcher例項(訂閱者)能夠觸發update事件,並進行相關操作
        • 怎麼能讓Watcher在例項化的時候自動新增到Dep訂閱者陣列中
              function Watcher(vm, exp, cb) {
                  this.cb = cb;       // 建構函式中執行,只有可能在例項化的時候執行一遍
                  this.vm = vm;
                  this.exp = exp;
                  this.value = this.get();  // 將自己新增到訂閱器的操作---HACK開始
                  // 在建構函式中呼叫了一個get方法
              }
              
              Watcher.prototype = {
                  update: function() {
                      this.run();
                  },
                  run: function() {
                      var value = this.vm.data[this.exp];
                      var oldVal = this.value;
                      if (value !== oldVal) {
                          this.value = value;
                          this.cb.call(this.vm, value, oldVal);
                      }
                  },
                  get: function() {
                      //get方法中首先快取了自己本身到target屬性
                      Dep.target = this;  
                      // 獲取了一下Observer中的值,相當於呼叫了一下get方法
                      var value = this.vm.data[this.exp]  
                      // get 完成之後清除了自己的target屬性???
                      Dep.target = null;  
                      return value;
                  }
                  //很明顯,get方法只在例項化的時候呼叫了,滿足了只有在Watcher例項化第一次的時候呼叫
                  //update方法接收了釋出者的notice 釋出訊息,並且執行回撥函式,這裡的回撥函式還是通過外部定義(簡化版)
                  //但是,好像在get方法中有一個很神奇的操作,快取自己,然後呼叫Observer的getter,然後清除自己
                  //這裡其實是一步巧妙地操作把自己新增到Dep訂閱者陣列中,當然Observer 的getter方法也要變化如下
              };
          
              //Observer.js
              function defineReactive(data, key, val) {
                  observe(val); 
                  var dep = new Dep(); 
                  Object.defineProperty(data, key, {
                      enumerable: true,
                      configurable: true,
                      get: function() {
                          if (Dep.target) {.  
                              dep.addSub(Dep.target); // 關鍵的在這裡,當第一次例項化時,呼叫Watcher的get方法,get方法內部會獲取Object的屬性,會觸發這個get方法,在這裡將Watcher 新增到Dep的訂閱者陣列中
                          }
                          return val;
                      },
                      set: function(newVal) {
                          if (val === newVal) {
                              return;
                          }
                          val = newVal;
                          dep.notify(); 
                      }
                  });
              }
              Dep.target = null;
          
      • 看似好像釋出者訂閱者模式實現了,資料劫持也實現了,在資料改變的時候,觸發Object.setProperty中定義的set函式,set函式觸發Dep訂閱者中介軟體的notice方法,觸發所有訂閱者的update方法,並且訂閱者在例項化的時候就加入到了Dep訂閱者的陣列內部,讓我們來看看怎麼用

        • html部分,
              <body>
                  <!-- 這裡其實還是會直接顯示{{name}} -->
                  <h1 id="name">{{name}}</h1>
              </body>
          
        • 封裝一個方法(類)將Observer,Watcher,關聯起來
              function SelfVue (data, el, exp) {
                  //初始化data屬性
                  this.data = data;
                  //將其設定為觀察者
                  observe(data);
                  //手動設定初始值
                  el.innerHTML = this.data[exp]; 
                  //初始化watcher,新增到訂閱者陣列中,並且回撥函式是重新渲染頁面,觸發update方法時通過回撥函式重寫html節點
                  new Watcher(this, exp, function (value) {
                      el.innerHTML = value;
                  });
                  return this;
              }
          
        • 使用:
              var ele = document.querySelector('#name');
              var selfVue = new SelfVue({
                  name: 'hello world'
              }, ele, 'name');
          
              //設定延時函式,直接修改資料值,看能否繫結到頁面檢視節點
              window.setTimeout(function () {
                  console.log('name值改變了');
                  selfVue.data.name = 'canfoo';
              }, 2000);
          
      • 到上面為止:基本實現了資料(model)到檢視(view)層的單向資料繫結,只有v-model是使用到了雙向繫結,很多vue的資料繫結的理解,和難點也就在上面的單向繫結

      • 那麼:model->view單向繫結似乎已經成功了,那麼view -> model呢?

        • 這個在於如果檢視層的value改變了,如何修改已經繫結的model層的物件屬性呢?
        • 這個指令在vue中是:v-model,指令部分會在之後的學習中繼續講解
        • 但是,檢視view節點在value屬性改變時,一般會觸發change或者input事件,而且也一般是一些可輸入檢視節點,直接將事件寫在change事件或者input事件裡面,並且修改Object裡面的值
              var dom = document.getElementById('xx')
              dom.addEventListener('input',function(e){
                  selfVue.data.xxx = e.target.value
              })
          
        • 具體input事件和v-model指令這種用法怎麼聯絡起來,之後會慢慢學習

總結:

  • MVVM其實是現在很多前端框架的實現基礎,除了vue 的資料劫持和觀察訂閱模式,其他框架的例如髒資料檢測,或者直接使用觀察者訂閱者模式,都是一些很巧妙的實現方式,使程式設計師能夠更多的關注資料層面或者邏輯層面的程式碼,而不需要手動去做更新兩者之間關係的繁瑣操作
  • vue的資料劫持和釋出者訂閱者模式理解起來一開始看起來理解有點費勁,大概瞭解如何做的,學習其方法,當然手寫完全流程的寫出來,我也很難
  • 學習的路上,大家一起加油,多問一個為什麼

非常感謝:下面的文章給了我很多的幫助,感謝各位前行者的辛苦付出,可以點選查閱更多資訊

廖雪峰老師的MVVM講解

由淺入深講述MVVM

很詳細的講解vue的雙向繫結

相關文章