Vue.js 是如何實現 MVVM 的?

dora_zc發表於2019-06-30

框架到底為我們做了什麼?

  • 資料和檢視分離,解耦(開放封閉原則)
    • 所有資料和檢視不分離的,都會命中開放封閉原則
    • Vue 資料獨立在 data 裡面,檢視在 template
  • 以資料驅動檢視,只關心資料變化,dom 操作被封裝
    • 使用原生js是直接通過操作dom來修改檢視,例如 ducument.getElementById('xx').innerHTML="xxx"
    • 以資料驅動檢視就是,我們只管修改資料,檢視的部分由框架去幫我們修改,符合開放封閉模式

如何理解 MVVM ?

  • MVC
    • Model 資料 → View 檢視 → Controller 控制器
  • MVVM
    • MVVM不算是一種創新
    • 但是其中的 ViewModel 是一種創新
    • ViewModel 是真正結合前端應用場景的實現
  • 如何理解MVVM
    • MVVM - Model View ViewModel,資料,檢視,檢視模型
    • 三者與 Vue 的對應:view 對應 templatevm 對應 new Vue({…})model 對應 data
    • 三者的關係:view 可以通過事件繫結的方式影響 modelmodel 可以通過資料繫結的形式影響到viewviewModel是把 modelview 連起來的聯結器

如何實現 MVVM - 以 Vue.js 為例

MVVM 框架的三大要素

  • 響應式:Vue 如何監聽到 data 的每個屬性變化
  • 模板引擎:Vue 的模板如何被解析,指令如何處理
  • 渲染:Vue 的模板如何被渲染成 html,渲染過程是怎樣的

Vue 如何實現響應式

  • 什麼是響應式
    • 修改 data 屬性之後,Vue 立刻監聽到,立刻渲染頁面
    • data 屬性被代理到 vm
  • Object.defineProperty
    • 將物件屬性的值的設定和訪問 (get,set) 都變成函式,可以在當中加入我們自己的邏輯(進行監聽)
    • 普通的 JavaScript 物件,做屬性修改,我們監聽不到,所以需要用到 Object.defineProperty
    • 既能get,又能set,才是雙向資料繫結

Vue 如何解析模板

  • 模板是什麼
    • 本質:模板就是字串
    • 與html格式很像,但是模板中是有邏輯的,可以嵌入JS變數,如v-if, v-for等
    • 檢視最終還是需要由模板生成 html 來顯示
    • 模板必須先要轉換成JS程式碼
      • 有邏輯(v-if, v-for),必須用JS才能實現(圖靈完備)
      • 轉換為html渲染頁面,必須用JS才能實現
      • 因此,模板要轉換成render函式
  • render函式
    • render函式包含了模板中所有的資訊,返回 vnode,解決了模板中的邏輯(v-if, v-for)問題
    • 如何找到最終生成的render函式
      • 找到vue原始碼,搜尋code.render,將code列印出來,就是生成的render函式
  • render函式與vdom
    • 模板生成 htmlvm._c
    • vm._csnabbdom 中的 h 函式的實現很像,都是傳入標籤,屬性,子元素作為引數
    • Vue.jsvdom 實現借鑑了 snabbdom
    • updateComponent 中實現了 vdompatch
    • 頁面首次渲染執行 updateComponent
    • data 中每次修改屬性,都會執行 updateComponent

Vue.js 執行機制

Vue.js 是如何實現 MVVM 的?

  • 第一步:解析模板成 render 函式
    • 因為在打包的時候就已經生成了render函式,所以編譯是第一步;響應式監聽是在程式碼執行的時候才開始監聽。
    • 模板中的所有資訊都被render函式包含
    • 模板中用到的data中的屬性,都變成了js變數
    • 模板中的 v-model v-for v-on都變成了js邏輯
    • render函式返回vnode
  • 第二步:響應式開始監聽
    • 通過Object.definedProperty監聽到物件屬性的get和set
    • 將data的屬性代理到vm上
  • 第三步:首次渲染,顯示頁面,且繫結依賴
    • 初次渲染,執行 updateComponent,執行 vm._render()
    • 執行 render 函式,會訪問到 data 中的值,訪問時會被響應式的 get 方法監聽到
    • 執行 updateComponent,會走到 vdompatch 方法
    • patchvnode 渲染成 dom,初次渲染完成
    • 疑問:為何要監聽 get,而不是直接監聽 set
      • 因為 data 中有很多屬性,有些被用到,有些可能不被用到
      • 只有被用到的才會走 get
      • 沒有走到 get 中的屬性,set 的時候我們也無需關心
      • 避免不必要的重新渲染
  • 第四步:data 屬性變化,觸發 re-render
    • 修改屬性,被響應式的 set 監聽到
    • set 中執行 updateComponent
    • updateComponent 重新執行 vm._render()
    • 生成的 vnodeprevVnode,通過 patch 進行對比
    • 渲染到 html

手寫一個 Vue.js

Vue.js 是如何實現 MVVM 的?

index.html

這是最終的測試程式碼,我們自己實現的 Vue 在 XVue.jscompile.js兩個檔案中,加起來大概200行程式碼左右,主要包括功能如下:

  • 資料響應式:頁面中能直接引用data中的變數 test,我們給data.test重新賦值時,頁面能隨test值改變
  • 雙向資料繫結:v-model
  • 模板解析,處理指令和事件繫結:v-text v-model @click
  • 渲染頁面:將模板轉化為 html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title></title>
  </head>
  <body>
    <div id="app">
      {{test}}
      <div v-text="test"></div>
      <p>
        <input type="text" v-model="test" />
      </p>
      <p v-html="html"></p>
      <p>
        <button @click="onClick">按鈕</button>
      </p>
    </div>
    <script src="./compile.js"></script>
    <script src="./XVue.js"></script>
    <script>
      const o = new XVue({
        el: '#app',
        data: {
          test: '123',
          foo: { bar: 'bar' },
          html: '<button>html test</button>'
        },
        methods: {
          onClick() {
            alert('按鈕點選了')
          }
        }
      })
      console.log(o.$data.test) //123
      o.$data.test = 'hello, Xvue!'
      console.log(o.$data.test) //hello, Xvue!
    </script>
  </body>
</html>

Mini Vue 的組成部分:

  • 監聽器 observe :資料劫持,實現響應式;屬性代理
  • 依賴管理器 Dep :負責將檢視中所有依賴收集管理,包括依賴新增和通知更新
  • 監聽器 Watcher :具體更新的執行者
  • 編譯器 Compile :掃描模板中所有依賴(指令、插值、繫結、事件等),建立更新函式和監聽器( Watcher )

XVue.js

class XVue {
  constructor(options) {
    this.$data = options.data;
    this.observe(this.$data);
    // 執行編譯
    new Compile(options.el, this);
  }

  observe(value) {
    if (!value || typeof value !== 'object') {
      return;
    }
    Object.keys(value).forEach(key => {
      this.defineReactive(value, key, value[key]);
      // 為vue的data做屬性代理
      this.proxyData(key);
    });
  }

  defineReactive(obj, key, val) {
    // 遞迴查詢巢狀屬性
    this.observe(val);

    // 建立Dep
    const dep = new Dep();

    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get() {
        // 收集依賴
        Dep.target && dep.addDep(Dep.target);
        // console.log(dep.deps);
        return val;
      },
      set(newVal) {
        if (newVal === val) {
          return;
        }
        val = newVal;
        dep.notify();
      },
    });
  }

  proxyData(key) {
    Object.defineProperty(this, key, {
      get() {
        return this.$data[key];
      },
      set(newVal) {
        this.$data[key] = newVal;
      },
    });
  }
}

// 依賴管理器:負責將檢視中所有依賴收集管理,包括依賴新增和通知
class Dep {
  constructor() {
    // deps裡面存放的是Watcher的例項
    this.deps = [];
  }
  addDep(dep) {
    this.deps.push(dep);
  }
  // 通知所有watcher執行更新
  notify() {
    this.deps.forEach(dep => {
      dep.update();
    });
  }
}

// Watcher: 具體的更新執行者
class Watcher {
  constructor(vm, key, cb) {
    this.vm = vm;
    this.key = key;
    this.cb = cb;
    // 將來 new 一個監聽器時,將當前 Watcher 例項附加到 Dep.target
    // 將來通過 Dep.target 就能拿到當時建立的 Watcher 例項
    Dep.target = this;
    // 讀取操作,主動觸發 get,當前 Watcher 例項被新增到依賴管理器中 
    this.vm[this.key];
    // 清空操作,避免不必要的重複新增(再次觸發 get 就不需要再新增 watcher 了)
    Dep.target = null;
  }
  update() {
    // console.log('from Watcher update: 檢視更新啦!!!');
    // 通知頁面做更新
    this.cb.call(this.vm, this.vm[this.key]);
  }
}

compile.js

// 掃描模板中所有依賴(指令、插值、繫結、事件等)建立更新函式和watcher
class Compile {
  // el是宿主元素或其選擇器
  // vm當前Vue例項
  constructor(el, vm) {
    this.$el = document.querySelector(el);
    this.$vm = vm;
    if (this.$el) {
      // 將dom節點轉換為Fragment提高執行效率
      this.$fragment = this.node2Fragment(this.$el);
      // 執行編譯,編譯完成以後所有的依賴已經替換成真正的值
      this.compile(this.$fragment);
      // 將生成的結果追加至宿主元素
      this.$el.appendChild(this.$fragment);
    }
  }
  node2Fragment(el) {
    // 建立一個新的Fragment
    const fragment = document.createDocumentFragment();
    let child;
    // 將原生節點移動至fragment
    while ((child = el.firstChild)) {
      // appendChild 是移動操作,移動一個節點,child 就會少一個,最終結束迴圈
      fragment.appendChild(child);
    }
    return fragment;
  }
  // 編譯指定片段
  compile(el) {
    let childNodes = el.childNodes;
    Array.from(childNodes).forEach(node => {
      // 判斷node型別,做相應處理
      if (this.isElementNode(node)) {
        // 元素節點要識別v-xx或@xx
        this.compileElement(node);
      } else if (
        this.isTextNode(node) &&
        /\{\{(.*)\}\}/.test(node.textContent)
      ) {
        // 文字節點,只關心{{msg}}格式
        this.compileText(node, RegExp.$1); // RegExp.$1匹配{{}}之中的內容
      }
      // 遍歷可能存在的子節點
      if (node.childNodes && node.childNodes.length) {
        this.compile(node);
      }
    });
  }

  compileElement(node) {
    // console.log('編譯元素節點');
    // <div v-text="test" @click="onClick"></div>
    const attrs = node.attributes;
    Array.from(attrs).forEach(attr => {
      const attrName = attr.name; // 獲取屬性名 v-text
      const exp = attr.value; // 獲取屬性值 test
      if (this.isDirective(attrName)) {
        // 指令
        const dir = attrName.substr(2); // text
        this[dir] && this[dir](node, this.$vm, exp);
      } else if (this.isEventDirective(attrName)) {
        // 事件
        const dir = attrName.substr(1); // click
        this.eventHandler(node, this.$vm, exp, dir);
      }
    });
  }

  compileText(node, exp) {
    // console.log('編譯文字節點');
    this.text(node, this.$vm, exp);
  }

  isElementNode(node) {
    return node.nodeType == 1; //元素節點
  }

  isTextNode(node) {
    return node.nodeType == 3; //元素節點
  }

  isDirective(attr) {
    return attr.indexOf('v-') == 0;
  }

  isEventDirective(dir) {
    return dir.indexOf('@') == 0;
  }

  // 文字更新
  text(node, vm, exp) {
    this.update(node, vm, exp, 'text');
  }

  // 處理html
  html(node, vm, exp) {
    this.update(node, vm, exp, 'html');
  }

  // 雙向繫結
  model(node, vm, exp) {
    this.update(node, vm, exp, 'model');

    let val = vm.exp;
    // 雙綁還要處理檢視對模型的更新
    node.addEventListener('input', e => {
      vm[exp] = e.target.value; // 這裡相當於執行了 set
    });
  }

  // 更新
  // 能夠觸發這個 update 方法的時機有兩個:1-編譯器初始化檢視時觸發;2-Watcher更新檢視時觸發
  update(node, vm, exp, dir) {
    let updaterFn = this[dir + 'Updater'];
    updaterFn && updaterFn(node, vm[exp]); // 立即執行更新;這裡的 vm[exp] 相當於執行了 get
    new Watcher(vm, exp, function (value) {
      // 每次建立 Watcher 例項,都會傳入一個回撥函式,使函式和 Watcher 例項之間形成一對一的掛鉤關係
      // 將來資料發生變化時, Watcher 就能知道它更新的時候要執行哪個函式
      updaterFn && updaterFn(node, value);
    });
  }

  textUpdater(node, value) {
    node.textContent = value;
  }

  htmlUpdater(node, value) {
    node.innerHTML = value;
  }

  modelUpdater(node, value) {
    node.value = value;
  }

  eventHandler(node, vm, exp, dir) {
    let fn = vm.$options.methods && vm.$options.methods[exp];
    if (dir && fn) {
      node.addEventListener(dir, fn.bind(vm), false);
    }
  }
}

相關文章