Vue3將要使用Proxy做爲數據驅動,不想進來看看嗎?

尤大大在開發者大會上說新版的Vue會採用Proxy做爲數據驅動,來代替本來的 defineProperty。今天聊一聊使用Proxy的好處和Vue是怎樣實現數據驅動的。 最後會帶着你們手動實現一個Proxy版的MVVMhtml

  • 目前的 Vue 經過什麼實現數據驅動?

    • 目前官網使用的版本是Vue 2.5.Xnode

    • 這個版本的底層數據驅動是經過Object.defineProperty 方法來實現了getter/setter。那這個方法是作什麼的呢? 咱們都知道js對象 都會具有get/set方法,這個方法就是用來定義對象裏的get/set方法(雖然也能夠定義其餘屬性 好比是否遍歷 是否容許修改等)git

    • Object.defineProperty(obj, prop, descriptor)
        其中obj表明定義屬性的對象;prop表明定義的屬性名稱;descriptor表明定義的內容。舉個🌰你們就明白啦
        const obj = {
            name: 'jack',
            sex: 'male',
            age: 20
        }
        for(let key in obj) {
            let val = obj[key]
            Object.defineProperty(obj, key, {
                get() {
                    return val // 觸發get函數就返回對象裏的值
                    // others...
                },
                set(newVal) {
                    val = newVal // 觸發set函數就修改對象
                    // others...
                }
            })
        }
        console.log(obj.name) // getter
        obj.name = 'rose' // setter
        上述代碼就利用get/set完成一個簡單的訪問着,這個🌰也是MVVM中的核心內容
      
      複製代碼
    • 爲何要使用Proxy代替defineProperty?github

      • 經過上面的🌰你們能看出來要實現get/set就要遍歷到每個對象裏的屬性,在實際開發時咱們的數據結構都比較複雜並且嵌套層級不少時,要監聽到全部的數據就須要深層次的遞歸遍歷
      • 第二點就是該方法不支持數組的操做
      • 在Vue中對數組的方法進行了hack,當觸發數組方法時(push,pop)會手動觸發一下數據驅動,讓數組也具有數據驅動的能力
  • 在新版本中Vue使用什麼來實現數據驅動?

    • 在從此將要推出的Vue3.X版本中會使用ES6中新的API Proxy來做爲數據驅動的核心。相比於上一版的API它有以下好處:
      • let obj = new Proxy(target, handler)
          // 其中target是要用Proxy包裝的對象;handler是包裝時執行的操做。仍是舉個🌰
          let obj = {
              name: 'jack',
              sex: 'male',
              age: 20
          }
          let newObj = new Proxy(obj, {
              get: (target, property, receiver) => {
                  // get方法的參數,target:目標對象;property:獲取的屬性名;receiver:當前的Proxy
                  好比我訪問了 obj.name  這裏的target = obj;property = name
                  return obj[property]
                  // others...
              },
              set:(target, property, value, receiver) => {
                  // 參數同上,不一樣的是 value是新值
                  obj[property] = value
                  /// others...
              }
          })
        複製代碼
  • 若是咱們想要實現Vue的數據驅動須要作什麼事情?

    • 首先咱們來想一下一個Vue實例渲染都通過了哪些大的步驟:

      • 初始化Data數據,將data編譯爲可追蹤和可觀察的對象
      • 編譯dom,將data裏的數據渲染到dom上
      • 編譯完成,Vue實例渲染完成
      • 當觸發修改,觀察者將新數據同步至 Data 和 dom
      • re-render
    • 因此咱們只要將上述步驟所有編寫成代碼就能夠獲得一個數據驅動的源碼,事不宜遲下面就直接進入主題

  • Vue數據驅動實現步驟

    • 1.初始化Data數據,將data編譯爲可追蹤和可觀察的對象

      首先須要建立Vue類,並解構一些所需對象
          class Vue {
              constructor(options) {
                  const { data } = options 
                  this.$data = data
                  initObserve.call(this, this.$data)
              }
              function initObserve(data) {
                  // 初始化data對象,將data裏的對象轉化爲可觀察的對象
                  this.$data = observe(data);
              }
              function observe(data) {
                  // 判斷是否爲對象類型,不然則返回原對象
                  if(typeof data !== 'object') return data;
                  return new Observe(data);
              }
              class Observe {
                  //實現觀察者,遞歸監聽data裏的數據
                  constructor(data) {
                  // 使用 for in 作深層次遞歸,保證data裏嵌套格式也能正確爲轉化
                      for(let key in data) {
                          data[key] = observe(data[key]);
                      }
                      return this.proxy(data);
                  }
                  proxy(data) {
                      // 這裏就是觀察者的核心部分,對接收的data附加getter/setter
                      return new Proxy(data, {
                          get(target, property, receiver) {
                              return Reflect.get(target, property);
                          },
                          set(target, property, value, receiver) {
                              const result = Reflect.set(target, property, observe(value));
                              return result;
                          } 
                      })
                  }
              }
          }
          
          const mvvm = new Vue({
              data: {}
          })
      複製代碼
      咱們的第一步已經完成了,總結一句話:遍歷$data並使用Proxy函數對data進行加工處理
    • 2. 代理屬性到this上

      • 作完第一步咱們已經擁有了可觀察的數據驅動模型。可是隻能經過this.$data來觸發,咱們想要的效果是全部操做所有集中在this關鍵字上,因此咱們要將data代理到this上去
      // 爲了保持代碼整潔,每次只展現所須要的代碼,其餘代碼並非不須要了
          class Vue {
              constructor(options) {
                  const { data } = options 
                  this.$data = data
                  let vm =  initVm.call(this);            ++
                  // others code
                  return this.$vm                         ++
              }
              function initVm() {                         ++
                /* 
                  這一步主要是代理數據 
                  至關於將this.$data上的數據代理到this上 訪問this.name === 訪問 this.$data.name
                 */
                  this.$vm = new Proxy(this, {            
                      get: (target, property, receiver) => {
                          return this.$data[property]
                          },
                      set:(target, property, value, receiver) => {
                        return Reflect.set(this.$data, property, value);
                      }
                  });
                  return this.$vm;
              }
          }
          
          const mvvm = new Vue({
              data: {}
          })
      複製代碼
    • 3.編譯渲染,將data上的數據渲染到dom上

      • 咱們已經能夠在js裏操做這個數據模型了,可是光經過js操做不行,在mvvm裏面咱們忘記了最重要的 視圖。下一步就是把處理好的data渲染到dom中。能讓用戶看到
      class Vue {
              constructor(options) {
                  // 首先獲取指定渲染的dom根節點
                  let { data, el } = options;                 ++
                  this.$el = document.querySelector(el);      ++
                  new Compile(el, vm)                         ++
                  // others code
              }
              class Compile {                                 ++
                /* 
                  編譯數據
                  將data的數據渲染到頁面上
                 */
                constructor(el, vm) {
                  this.vm = vm;
                  let fragment = document.createDocumentFragment();
                  fragment.append(document.querySelector(el));
                  this.replace(fragment);
                  document.body.appendChild(fragment);
                }
                replace(arr) {
                  Array.from(arr.childNodes).forEach(node => {
                    const reg = /\{\{(.*?)\}\}/g;
                    let txt = node.textContent;
                    // nodeType === 3表示該節點爲文本節點
                    if(node.nodeType === 3 && reg.test(node.textContent)) {
                      let vm = this.vm;
                      updateTxt();
                      function updateTxt() {
                        // 去除首尾空格,把符合條件的目標替換爲data裏的對象
                        // 🌰:{{ name }} => {{name}} => $data.name => jack
                        // 使用reduce函數是爲了防止:$data.user.sex 這種嵌套狀況
                        const val = txt.replace(reg, (matched, arrs) => {
                          return arrs.split('.').map(el => el.trim()).reduce((obj, key) => {
                            return obj[key] === undefined? node.textContent : obj[key]; // 例如:去vm.makeUp.one對象拿到值
                          }, vm);
                        });
                        if(val != node.textContent) {
                          node.textContent = val;
                        }
                      }
                    }
                    // 遞歸遍歷dom節點
                    if(node.childNodes && node.childNodes.length > 0) {
                      this.replace(node);
                    }
                  });  
                }
              }
          }
      複製代碼
    • 4.修改$data並驅動觀察者更新頁面dom視圖

      • 寫到這裏咱們已經完成了大半工做,如今咱們在js層能夠修改data 在視圖層咱們能夠把data渲染到dom。可是這二者尚未聯繫起來。下一步咱們要作到更改data就能驅動dom發生改變
      // 還記得第一步實現的初始化data嗎?
          // 首先咱們來想一個問題:視圖怎麼才能知道我什麼時候要更新呢?
          // 每當用戶修改了$data時就應該更新視圖,否則的話視圖和$data就會不一致了。其實咱們已經知道如何解決了,咱們只要在$data所觸發的 getter/setter裏註冊函數就行了,在相應時刻調用函數就能更新視圖了
          function initObserve(data) {
              this.$data = observe(data);
          }
          
          function observe(data) {
              if(typeof data !== 'object') return data;
              return new Observe(data);
          }
          class Observe {
            constructor(data) {
              this.dep = new Dep();                       ++
              for(let key in data) {
                data[key] = observe(data[key]);
              }
              return this.proxy(data);
            }
            proxy(data) {
              let dep = this.dep;                         ++
              return new Proxy(data, {
                get(target, property, receiver) {
                  // 在getter裏註冊監聽方法
                  if(Dep.target) {                        ++
                    if(!dep.subs.includes(Dep.exp)) {
                      dep.addSub(Dep.exp);
                      dep.addSub(Dep.target);
                    }
                  }  
                  return Reflect.get(target, property);
                },
                set(target, property, value, receiver) {
                  // 觸發setter時一併觸發修改方法
                  const result = Reflect.set(target, property, observe(value));
                  dep.notify();                           ++
                  return result;
                } 
              });
            }
          }
          class Compile {
              // 找到編譯函數,在替換數據這個函數里加一行 Watcher的監聽
              // 這樣咱們就在全部須要編譯的地方實例了觀察函數,Watcher還接收了編譯的值
              replace() {
                  function updateTxt() {
                      new Watcher(vm, arrs, updateTxt);
                  }
              }
          }
          class Dep {
            /* 
              發佈訂閱
              監聽setter 當觸發setter會調用註冊過的函數 依次調用函數
             */
            constructor() {
              // 須要更新數據放在這個數組裏
              this.subs = [];
            }
            addSub(sub) {
              this.subs.push(sub);
            }
            notify() {
              // 當setter時遍歷數組執行全部函數
              this.subs.filter(fn => typeof fn !== 'string').forEach(sub => sub.update());
            }            
          }
          class Watcher {
            // 修改dom最核心的函數,接收了當前Vue實例、更新的字段和更新函數
            constructor(vm, exp, fn) {
              // 每一次的Watcher都能對應一次Dep
              // 因此說在getter函數裏的Dep都
              this.fn = fn;
              this.vm = vm;
              this.exp = exp;
              Dep.exp = exp;
              Dep.target = this;
              const arr = exp.split('.').map(el => el.trim());
              let val = vm;
              arr.forEach(key => {
                val = val[key] || val;
              });
              Dep.target = null;
            }
            update() {
              const arr = this.exp.split('.').map(el => el.trim());
              let val = this.vm;
              arr.forEach(key => {
                val = val[key];
              });
              this.fn(val);
            }
          }
      
      複製代碼

      請注意上述代碼中的 Dep和Watcher函數必定要結合起來看,每次觸發$data中的getter時都會相應觸發一次Dep和Wacther,又由於沒有異步的關係因此說Dep和Watcher中的變量必定是相互對應關聯的。

    • 到這爲止核心篇已經講完了,如今咱們知道如何更新數據和如何替換dom節點。知道這些仍是不夠的,下面講一講Vue中好用的功能點
  • v-model

    • 雙向綁定可謂是開發中使用頻率最最最高的api之一了,那她是如何實現的呢? 從官網咱們能夠知道,v-model是語法糖,即:
    <input value="val" onChange="(e) => val = e.target.value" />
    複製代碼
    知道v-model的原理咱們就很好實現了,首先找到 Compile類,在replace中加一行代碼
    replace(arr) {
        // 這行代碼首先判斷node是否是元素節點,由於只有元素上纔會有v-model
            if(node.nodeType === 1) {
                this.directives(node);      +++
            }
        }
        directives(node) {                  +++
        // 遍歷元素上是否存在叫v-model的名字。若是找到,就將v-model擴展成 value+onChange的形式
            const vm = this.vm;
            Array.prototype.slice.call(node.attributes).forEach(el => {
              if(el.name === 'v-model') {
                node.value = vm[el.value];
                node.addEventListener('input', e => {
                  vm[el.value] = e.target.value;
                });
              }
            });
        }
    複製代碼
  • v-bind

    • 這個其實和v-model是同樣的道理,只要相對應尋找到v-bind的名字
    • 不過有個弊端,爲了偷懶我只判斷了v-bind的簡寫形式 @
    • 有了v-bind,就能夠正常使用click這種事件了
    // 就像上面同樣,在v-model的下面增長相應代碼
        if(el.name.includes('@')) {         +++
            const eventName = el.name.split('@')[1];
            node.setAttribute(`v-bind:${ eventName }`, el.value);
            node.addEventListener(eventName, vm.$methods[el.value].bind(vm));
        }
    複製代碼
  • computed

    • 本文章最後介紹的是使用頻率很高的計算屬性
    • 代碼很簡潔,思路仍是給computed對象增長Watcher監聽
    class Vue {
            function initComputed() {       +++
              let computed = this.$options.computed;
              this.$computed = {};
              if(!computed) return;
              Object.keys(computed).forEach(key => {
                this.$computed[key] = computed[key].call(this.$vm);
                new Watcher(this.$vm, key, val => {
                  this.$computed[key] = computed[key].call(this.$vm);
                });
              });
            }
        }
    複製代碼
  • initVm

    • 還差最後一步,咱們已經在Vue內部封裝好了 v-model、v-bind、computed等等。應該如何經過this來訪問呢?
    • 首先來看initVm函數,這裏面使用proxy代理了this的訪問規則,增長一下訪問規則:
    function initVm() {
          this.$vm = new Proxy(this, {          +++
            get: (target, property, receiver) => {
              return this[property] || this.$data[property] || this.$computed[property] || this.$methods[property];     
            },
            set:(target, property, value, receiver) => {
              return Reflect.set(this.$data, property, value);
            }
          });
          return this.$vm;
        }
    複製代碼
  • 總結

    • 若是客觀大人看到這就證實一個可使用的Vue實例就建立好啦。這篇文章從年前開始寫磨磨唧唧寫到了如今。發現本身的文筆確實不行,若是有的地方講的不明白還請大人留言或移步個人github查看源碼哈~
    • 最最後想說一下全部代碼都沒有用到Vnode或diff的概念,性能上不能保證。不過也是拋磚引玉,能讓你們有一絲絲收穫我就知足了~

另附上github源碼~

謝謝觀看!api

相關文章
相關標籤/搜索