【JS】508- MVVM原理介紹

作者:chenhongdong https://juejin.im/post/5abdd6f6f265da23793c4458

今天花上 10 分鐘,針對 MVVM 這個面試必考點,簡簡單單的來給大家劃一下重難點

劃重點

MVVM 雙向數據綁定 在Angular1.x版本的時候通過的是臟值檢測來處理

而現在無論是React還是Vue還是最新的Angular,其實實現方式都更相近了

那就是通過數據劫持+發布訂閱模式

真正實現其實靠的也是ES5中提供的Object.defineProperty,當然這是不兼容的所以Vue等只支援了IE8+

為什麼是它

Object.defineProperty()說實在的我們大家在開發中確實用的不多,多數是修改內部特性,不過就是定義對象上的屬性和值么?幹嘛搞的這麼費勁(純屬個人想法)

But在實現框架or庫的時候卻發揮了大用場了,這個就不多說了,只不過輕舟一片而已,還沒到寫庫的實力

知其然要知其所以然,來看看如何使用

let obj = {};  let song = '發如雪';  obj.singer = '周杰倫';    Object.defineProperty(obj, 'music', {      // 1. value: '七里香',      configurable: true,     // 2. 可以配置對象,刪除屬性      // writable: true,         // 3. 可以修改對象      enumerable: true,        // 4. 可以枚舉      // ☆ get,set設置時不能設置writable和value,它們代替了二者且是互斥的      get() {     // 5. 獲取obj.music的時候就會調用get方法          return song;      },      set(val) {      // 6. 將修改的值重新賦給song          song = val;      }  });    // 下面列印的部分分別是對應程式碼寫入順序執行  console.log(obj);   // {singer: '周杰倫', music: '七里香'}  // 1    delete obj.music;   // 如果想對obj里的屬性進行刪除,configurable要設為true  2  console.log(obj);   // 此時為  {singer: '周杰倫'}    obj.music = '聽媽媽的話';   // 如果想對obj的屬性進行修改,writable要設為true  3  console.log(obj);   // {singer: '周杰倫', music: "聽媽媽的話"}    for (let key in obj) {      // 默認情況下通過defineProperty定義的屬性是不能被枚舉(遍歷)的      // 需要設置enumerable為true才可以      // 不然你是拿不到music這個屬性的,你只能拿到singer      console.log(key);   // singer, music    4  }    console.log(obj.music);   // '發如雪'  5  obj.music = '夜曲';       // 調用set設置新的值  console.log(obj.music);   // '夜曲'    6  

以上是關於Object.defineProperty的用法

下面我們來寫個實例看看,這裡我們以Vue為參照去實現怎麼寫MVVM

// index.html  <body>      <div id="app">          <h1>{{song}}</h1>          <p>《{{album.name}}》是{{singer}}2005年11月發行的專輯</p>          <p>主打歌為{{album.theme}}</p>          <p>作詞人為{{singer}}等人。</p>          為你彈奏肖邦的{{album.theme}}      </div>      <!--實現的mvvm-->      <script src="mvvm.js"></script>      <script>          // 寫法和Vue一樣          let mvvm = new Mvvm({              el: '#app',              data: {     // Object.defineProperty(obj, 'song', '發如雪');                  song: '發如雪',                  album: {                      name: '十一月的蕭邦',                      theme: '夜曲'                  },                  singer: '周杰倫'              }          });      </script>  </body>  

上面是html里的寫法,相信用過Vue的同學並不陌生

那麼現在就開始實現一個自己的MVVM吧

打造MVVM

// 創建一個Mvvm構造函數  // 這裡用es6方法將options賦一個初始值,防止沒傳,等同於options || {}  function Mvvm(options = {}) {      // vm.$options Vue上是將所有屬性掛載到上面      // 所以我們也同樣實現,將所有屬性掛載到了$options      this.$options = options;      // this._data 這裡也和Vue一樣      let data = this._data = this.$options.data;        // 數據劫持      observe(data);  }  

數據劫持

為什麼要做數據劫持?

  • 觀察對象,給對象增加Object.defineProperty
  • vue特點是不能新增不存在的屬性 不存在的屬性沒有get和set
  • 深度響應 因為每次賦予一個新對象時會給這個新對象增加defineProperty(數據劫持)

多說無益,一起看程式碼

// 創建一個Observe構造函數  // 寫數據劫持的主要邏輯  function Observe(data) {      // 所謂數據劫持就是給對象增加get,set      // 先遍歷一遍對象再說      for (let key in data) {     // 把data屬性通過defineProperty的方式定義屬性          let val = data[key];          observe(val);   // 遞歸繼續向下找,實現深度的數據劫持          Object.defineProperty(data, key, {              configurable: true,              get() {                  return val;              },              set(newVal) {   // 更改值的時候                  if (val === newVal) {   // 設置的值和以前值一樣就不理它                      return;                  }                  val = newVal;   // 如果以後再獲取值(get)的時候,將剛才設置的值再返回去                  observe(newVal);    // 當設置為新值後,也需要把新值再去定義成屬性              }          });      }  }    // 外面再寫一個函數  // 不用每次調用都寫個new  // 也方便遞歸調用  function observe(data) {      // 如果不是對象的話就直接return掉      // 防止遞歸溢出      if (!data || typeof data !== 'object') return;      return new Observe(data);  }  

以上程式碼就實現了數據劫持,不過可能也有些疑惑的地方比如:遞歸

再來細說一下為什麼遞歸吧,看這個栗子

    let mvvm = new Mvvm({          el: '#app',          data: {              a: {                  b: 1              },              c: 2          }      });  

我們在控制台里看下

被標記的地方就是通過遞歸observe(val)進行數據劫持添加上了getset,遞歸繼續向a裡面的對象去定義屬性,親測通過可放心食用

接下來說一下observe(newVal)這裡為什麼也要遞歸

還是在可愛的控制台上,敲下這麼一段程式碼 mvvm._data.a = {b:'ok'}

然後繼續看圖說話

通過observe(newVal)加上了

現在大致明白了為什麼要對設置的新值也進行遞歸observe了吧,哈哈,so easy

數據劫持已完成,我們再做個數據代理

數據代理

數據代理就是讓我們每次拿data里的數據時,不用每次都寫一長串,如mvvm._data.a.b這種,我們其實可以直接寫成mvvm.a.b這種顯而易見的方式

下面繼續看下去,+號表示實現部分

function Mvvm(options = {}) {      // 數據劫持      observe(data);      // this 代理了this._data  +   for (let key in data) {          Object.defineProperty(this, key, {              configurable: true,              get() {                  return this._data[key];     // 如this.a = {b: 1}              },              set(newVal) {                  this._data[key] = newVal;              }          });  +   }  }    // 此時就可以簡化寫法了  console.log(mvvm.a.b);   // 1  mvvm.a.b = 'ok';  console.log(mvvm.a.b);  // 'ok'  

寫到這裡數據劫持和數據代理都實現了,那麼接下來就需要編譯一下了,把{{}}裡面的內容解析出來

數據編譯

function Mvvm(options = {}) {      // observe(data);        // 編譯  +   new Compile(options.el, this);  }    // 創建Compile構造函數  function Compile(el, vm) {      // 將el掛載到實例上方便調用      vm.$el = document.querySelector(el);      // 在el範圍里將內容都拿到,當然不能一個一個的拿      // 可以選擇移到記憶體中去然後放入文檔碎片中,節省開銷      let fragment = document.createDocumentFragment();        while (child = vm.$el.firstChild) {          fragment.appendChild(child);    // 此時將el中的內容放入記憶體中      }      // 對el裡面的內容進行替換      function replace(frag) {          Array.from(frag.childNodes).forEach(node => {              let txt = node.textContent;              let reg = /{{(.*?)}}/g;   // 正則匹配{{}}                if (node.nodeType === 3 && reg.test(txt)) { // 即是文本節點又有大括弧的情況{{}}                  console.log(RegExp.$1); // 匹配到的第一個分組 如:a.b, c                  let arr = RegExp.$1.split('.');                  let val = vm;                  arr.forEach(key => {                      val = val[key];     // 如this.a.b                  });                  // 用trim方法去除一下首尾空格                  node.textContent = txt.replace(reg, val).trim();              }              // 如果還有子節點,繼續遞歸replace              if (node.childNodes && node.childNodes.length) {                  replace(node);              }          });      }        replace(fragment);  // 替換內容        vm.$el.appendChild(fragment);   // 再將文檔碎片放入el中  }  

看到這裡在面試中已經可以初露鋒芒了,那就一鼓作氣,做事做全套,來個一條龍

現在數據已經可以編譯了,但是我們手動修改後的數據並沒有在頁面上發生改變

下面我們就來看看怎麼處理,其實這裡就用到了特別常見的設計模式,發布訂閱模式

發布訂閱

發布訂閱主要靠的就是數組關係,訂閱就是放入函數,發布就是讓數組裡的函數執行

// 發布訂閱模式  訂閱和發布 如[fn1, fn2, fn3]  function Dep() {      // 一個數組(存放函數的事件池)      this.subs = [];  }  Dep.prototype = {      addSub(sub) {          this.subs.push(sub);      },      notify() {          // 綁定的方法,都有一個update方法          this.subs.forEach(sub => sub.update());      }  };  // 監聽函數  // 通過Watcher這個類創建的實例,都擁有update方法  function Watcher(fn) {      this.fn = fn;   // 將fn放到實例上  }  Watcher.prototype.update = function() {      this.fn();  };    let watcher = new Watcher(() => console.log(111));  //  let dep = new Dep();  dep.addSub(watcher);    // 將watcher放到數組中,watcher自帶update方法, => [watcher]  dep.addSub(watcher);  dep.notify();   //  111, 111  

數據更新視圖

  • 現在我們要訂閱一個事件,當數據改變需要重新刷新視圖,這就需要在replace替換的邏輯里來處理
  • 通過new Watcher把數據訂閱一下,數據一變就執行改變內容的操作
function replace(frag) {      // 省略...      // 替換的邏輯      node.textContent = txt.replace(reg, val).trim();      // 監聽變化      // 給Watcher再添加兩個參數,用來取新的值(newVal)給回調函數傳參  +   new Watcher(vm, RegExp.$1, newVal => {          node.textContent = txt.replace(reg, newVal).trim();  +   });  }    // 重寫Watcher構造函數  function Watcher(vm, exp, fn) {      this.fn = fn;  +   this.vm = vm;  +   this.exp = exp;      // 添加一個事件      // 這裡我們先定義一個屬性  +   Dep.target = this;  +   let arr = exp.split('.');  +   let val = vm;  +   arr.forEach(key => {    // 取值  +      val = val[key];     // 獲取到this.a.b,默認就會調用get方法  +   });  +   Dep.target = null;  }  

當獲取值的時候就會自動調用get方法,於是我們去找一下數據劫持那裡的get方法

function Observe(data) {  +   let dep = new Dep();      // 省略...      Object.defineProperty(data, key, {          get() {  +           Dep.target && dep.addSub(Dep.target);   // 將watcher添加到訂閱事件中 [watcher]              return val;          },          set(newVal) {              if (val === newVal) {                  return;              }              val = newVal;              observe(newVal);  +           dep.notify();   // 讓所有watcher的update方法執行即可          }      })  }  

set修改值的時候執行了dep.notify方法,這個方法是執行watcherupdate方法,那麼我們再對update進行修改一下

Watcher.prototype.update = function() {      // notify的時候值已經更改了      // 再通過vm, exp來獲取新的值  +   let arr = this.exp.split('.');  +   let val = this.vm;  +   arr.forEach(key => {  +       val = val[key];   // 通過get獲取到新的值  +   });      this.fn(val);   // 將每次拿到的新值去替換{{}}的內容即可  };  

現在我們數據的更改可以修改視圖了,這很good,還剩最後一點,我們再來看看面試常考的雙向數據綁定吧

雙向數據綁定

    // html結構      <input v-model="c" type="text">        // 數據部分      data: {          a: {              b: 1          },          c: 2      }        function replace(frag) {          // 省略...  +       if (node.nodeType === 1) {  // 元素節點              let nodeAttr = node.attributes; // 獲取dom上的所有屬性,是個類數組              Array.from(nodeAttr).forEach(attr => {                  let name = attr.name;   // v-model  type                  let exp = attr.value;   // c        text                  if (name.includes('v-')){                      node.value = vm[exp];   // this.c 為 2                  }                  // 監聽變化                  new Watcher(vm, exp, function(newVal) {                      node.value = newVal;   // 當watcher觸發時會自動將內容放進輸入框中                  });                    node.addEventListener('input', e => {                      let newVal = e.target.value;                      // 相當於給this.c賦了一個新值                      // 而值的改變會調用set,set中又會調用notify,notify中調用watcher的update方法實現了更新                      vm[exp] = newVal;                  });              });  +       }          if (node.childNodes && node.childNodes.length) {              replace(node);          }      }  

大功告成,面試問Vue的東西不過就是這個罷了,什麼雙向數據綁定怎麼實現的,問的一點心意都沒有,差評!!!

大官人請留步,本來應該收手了,可臨時起意(手癢),再寫點功能吧,再加個computed(計算屬性)和mounted(鉤子函數)吧

computed(計算屬性) && mounted(鉤子函數)      // html結構      <p>求和的值是{{sum}}</p>        data: { a: 1, b: 9 },      computed: {          sum() {              return this.a + this.b;          },          noop() {}      },      mounted() {          setTimeout(() => {              console.log('所有事情都搞定了');          }, 1000);      }        function Mvvm(options = {}) {          // 初始化computed,將this指向實例  +       initComputed.call(this);          // 編譯          new Compile(options.el, this);          // 所有事情處理好後執行mounted鉤子函數  +       options.mounted.call(this); // 這就實現了mounted鉤子函數      }        function initComputed() {          let vm = this;          let computed = this.$options.computed;  // 從options上拿到computed屬性   {sum: ƒ, noop: ƒ}          // 得到的都是對象的key可以通過Object.keys轉化為數組          Object.keys(computed).forEach(key => {  // key就是sum,noop              Object.defineProperty(vm, key, {                  // 這裡判斷是computed里的key是對象還是函數                  // 如果是函數直接就會調get方法                  // 如果是對象的話,手動調一下get方法即可                  // 如:sum() {return this.a + this.b;},他們獲取a和b的值就會調用get方法                  // 所以不需要new Watcher去監聽變化了                  get: typeof computed[key] === 'function' ? computed[key] : computed[key].get,                  set() {}              });          });      }  

寫了這些內容也不算少了,最後做一個形式上的總結吧

總結

通過自己實現的mvvm一共包含了以下東西

  • 通過Object.defineProperty的get和set進行數據劫持
  • 通過遍歷data數據進行數據代理到this上
  • 通過{{}}對數據進行編譯
  • 通過發布訂閱模式實現數據與視圖同步
  • 通過通過通過,收了,感謝大官人的留步了

補充

針對以上程式碼在實現編譯的時候還是會有一些小bug,再次經過研究和高人指點,完善了編譯,下面請看修改後的程式碼

修復:兩個相鄰的{{}}正則匹配,後一個不能正確編譯成對應的文本,如{{album.name}} {{singer}}

function Compile(el, vm) {      // 省略...      function replace(frag) {          // 省略...          if (node.nodeType === 3 && reg.test(txt)) {              function replaceTxt() {                  node.textContent = txt.replace(reg, (matched, placeholder) => {                      console.log(placeholder);   // 匹配到的分組 如:song, album.name, singer...                      new Watcher(vm, placeholder, replaceTxt);   // 監聽變化,進行匹配替換內容                        return placeholder.split('.').reduce((val, key) => {                          return val[key];                      }, vm);                  });              };              // 替換              replaceTxt();          }      }  }  

上面程式碼主要實現依賴的是reduce方法,reduce 為數組中的每一個元素依次執行回調函數

如果還有不太清楚的,那我們單獨抽出來reduce這部分再看一下

    // 將匹配到的每一個值都進行split分割      // 如:'song'.split('.') => ['song'] => ['song'].reduce((val, key) => val[key])      // 其實就是將vm傳給val做初始值,reduce執行一次回調返回一個值      // vm['song'] => '周杰倫'        // 上面不夠深入,我們再來看一個      // 再如:'album.name'.split('.') => ['album', 'name'] => ['album', 'name'].reduce((val, key) => val[key])      // 這裡vm還是做為初始值傳給val,進行第一次調用,返回的是vm['album']      // 然後將返回的vm['album']這個對象傳給下一次調用的val      // 最後就變成了vm['album']['name'] => '十一月的蕭邦'        return placeholder.split('.').reduce((val, key) => {          return val[key];      }, vm);  

reduce的用處多多,比如計算數組求和是比較普通的方法了,還有一種比較好用的妙處是可以進行二維數組的展平(flatten),各位不妨來看最後一眼

let arr = [    [1, 2],    [3, 4],    [5, 6]  ];    let flatten = arr.reduce((previous, current) => {    return previous.concat(current);  });    console.log(flatten); // [1, 2, 3, 4, 5, 6]    // ES6中也可以利用...展開運算符來實現的,實現思路一樣,只是寫法更精簡了  flatten = arr.reduce((a, b) => [...a, ...b]);  console.log(flatten); // [1, 2, 3, 4, 5, 6]

再次感謝父老鄉親,兄弟姐妹們的觀看了!這回真的是最後一眼了,已經到底了!