190行代碼實現mvvm模式

前言

網上講 vue 原理,mvvm 模式的實現,數據雙向綁定的文章一搜一大堆,無論寫的誰好誰壞,都是寫的本身的理解,我也發一篇文章記錄本身的理解,若是對看官有幫助,那也是我莫大的榮幸,不過看完以後,大家之後若是再被面試官問到 vue 的原理的時候,千萬不要只用一句【經過 javascrit 的 Object.defineProperty 將 data 進行劫持,發生改變的時候改變對應節點的值】這麼籠統的話來應付了。若是有不懂的,能夠問我。話很少說,上效果圖:javascript

效果

20180614-070630.gif

以及代碼html

<body>
    <div id="root">
       <h1>{{a}}</h1>
       <button v-on:click="changeA">changeA</button>
       <h2 v-html="b"></h2>
       <input type="text" v-model="b">
    </div>
</body>
<script src="./Watcher.js"></script>
<script src="./Compile.js"></script>
<script src="./Dep.js"></script>
<script src="./Observe.js"></script>
<script src="./MVVM.js"></script>
<script>
var vue = new MVVM({
    el: '#root',
    data: {
        a: 'hello',
        b: 'world'
    },
    methods: {
        changeA () {
            this.a = 'hi'
        }
    }
})
</script>

怎麼樣,是否是跟vue的寫法很像,跟着個人思路,大家也能夠的。前端

原理

talk is cheap, show you the picture
v2-3cbad9f6d8d17a89a0a6ecf56c818f35_hd.jpgvue

如圖,實現一個mvvm,須要幾個輔助工具,分別是 Observer, Compile, Dep, Watcher。每一個工具各司其職,再由 MVVM 統一掉配從而實現數據的雙向綁定,下面我分別介紹下接下來出場的幾位菇涼java

  1. Compile 可以將頁面中的頁面初始化,對指令進行解析,把 data 對應的值渲染上去的同時,new 一個 Watcher,並告訴它,當渲染的這個數據發生改變時告訴我,我好更新視圖。
  2. Observer 可以實現將 data 中的數據經過Object.defineProperty進行劫持,當獲取 data 中的值的時候,觸發get裏方法,把 Compile 新建的 Watcher 抓過來,關到 Dep(發佈訂閱者模式)的小黑屋裏狂...,當值修改的時候,觸發 set 裏的方法,通知小黑屋(Dep)裏全部 Watcher 菇涼們,大家解放啦。
  3. Dep 就是傳說中的小黑屋了,其內在原理是發佈訂閱者模式,不瞭解發佈訂閱者模式的話能夠看我 這篇文章
  4. Watcher 們從小黑屋裏逃出來以後就趕忙跑到對應的 Compile 那,告訴他開始更新視圖吧,看,我是愛你的。

哈哈,經過我很(lao)幽(si)默(ji)的講解。大家是否是都想下車了?
227960.jpgnode

嗯,知道大概是怎麼回事以後,我分別講他們的功能。不過話說前面,mvvm 模式以前有千絲萬縷的聯繫,必需要所有看完,才能真正理解 mvvm 的原理。git

Observe

個人 mvvm 模式中 Observe 的功能有兩個。1.對將data中的數據綁定到上下文環境上,2.對數據進行劫持,當數據變化的時候通知 Dep。下面用一個 demo 來看看,如何將數據綁定到環境中,並劫持數據github

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
</head>
<body>
</body>
<script>
  var data = {
    a: 'hello',
    b: 'world'
  }
  class Observer {
    constructor(obj, vm) {
      this.walk(obj, vm);
    }
    walk(obj, vm) {
      Object.keys(obj).forEach(key => {
        Object.defineProperty(vm, key, {
          configurable: true,
          enumerable: true,
          get () {
            console.log('獲取obj的值' + obj[key])
            return obj[key];
          },
          set(newVal) {
            var val = obj[key];
            if (val === newVal) return;
            console.log(`值更新啦`);
            obj[key] = newVal;
          }
        })
      })
    }
  }
  new Observer(data, window);
  console.log(window.a);
  window.a = 'hi';
</script>

</html>

能夠看到將 data 數據綁定到 window 上,當數據變化時候,會打印 '值更新啦',那麼 data 變化 是如何通知 Dep 的呢?首先咱們要明白,observe 只執行一遍,將數據綁定到 mvvm 實例上,Dep也只有一個,以前說把全部的 Watcher 抓過來,全放在這個 Dep 裏,仍是看代碼說話把。面試

function observe (obj, vm) {
    if (!obj || typeof obj !== 'object') return;
    return new Observer(obj, vm)
}
class Observer {
    constructor(obj, vm) {
        // vm 表明上下文環境,也是指向 mvvm 的實例 (調用的時候會傳入)
        this.walk(obj, vm);
        // 實例化一個 Dep;
        this.dep = new Dep();
    }
    walk (obj, vm) {
        var self = this;
        Object.keys(obj).forEach(key => {
            Object.defineProperty(vm, key, {
                configurable: true,
                enumerable: true,
                get () {
                    // 當獲取 vm 的值的時候,若是 Dep 有 target 時執行,目的是將 Watcher 抓過來,後面還會說明
                    if (Dep.target) {
                        self.dep.depend();
                    }
                    return obj[key];
                },
                set (newVal) {
                    var val = obj.key;
                    if (val === newVal) return;
                    obj[key] = newVal;
                    // 當 劫持的值發生變化時候觸發,通知 Dep
                    self.dep.notify();
                }
            })
        })
    }
}

Dep

接下來說講 Dep 的實現,Dep 功能很簡單,難點是如何將 watcher 聯繫起來,先看代碼吧。segmentfault

class Dep {
  constructor (props) {
    this.subs = [];
    this.uid = 0;
  }
  addSub (sub) {
    this.subs.push(sub);
    this.uid++;
  }
  notify () {
    this.subs.forEach(sub => {
      sub.update();
    })
  }
  depend (sub) {
    Dep.target.addDep(this, sub);
  }
}
Dep.target = null;

subs 是一個數組,用來存儲 Watcher 的,當數據更新時候(由Observer告知),會觸發 Dep 的 notify 方法,調用 subs 裏全部 Watcher 的 update 方法。
接下來是否是火燒眉毛的想知道 Dep 是如何將 Watcher 抓過來的吧(污污污),彆着急咱們先看看 Watcher 是如何誕生的。

Compile

我以爲 Compile 是 mvvm 中最勞苦功高的一個了,它的任務是頁面過來時候,初始化視圖,將頁面中的{{.*}}解析成對應的值,還有指令解析,如綁定值的 v-text、v-html 還有綁定的事件 v-on,還有創造 Watcher 去監聽值的變化,當值變化的時候又要更新節點的視圖。
咱們先看看 Compile 是如何初始化視圖的

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
</head>
<body>
  <div id="root">
    <h1>{{a}}</h1>
    <div v-html="b"></div>
  </div>
</body>
<script>
  var data = {
    a: 'hello',
    b: 'world'
  }
  class Compile {
      constructor(el, vm) {
        this.$el = this.isElementNode(el) ? el : document.querySelector(el);
        this.$vm = vm;
        if (this.$el) {
          this.compileElement(this.$el);
        }
      }
      compileElement(el) {
        // 將全部的最小節點拿過來,循環判斷是文本節點就看看是否是 {{}} 包裹的字符串,是元素節點就看看是否是v-html喝v-text
        var childNodes = Array.from(el.childNodes);
        if (childNodes.length > 0) {
          childNodes.forEach(child => {
            var childArr = Array.from(child.childNodes);
            // 匹配{{}}裏面的內容
            var reg = /\{\{((?:.)+?)\}\}/;
            if (childArr.length > 0) {
              this.compileElement(child)
            }
            if (this.isTextNode(child)) {
              var text = child.textContent.trim();
              var matchTextArr = reg.exec(text);
              var matchText;
              if (matchTextArr && matchTextArr.length > 1) {
                matchText = matchTextArr[1];
                this.compileText(child, matchText);
              }
            } else if (this.isElementNode(child)) {
              this.compileNode(child);
            }
          })
        }

      }
      compileText(node, exp) {
        this.bind(node, this.$vm, exp, 'text');
      }
      compileNode(node) {
        var attrs = Array.from(node.attributes);
        attrs.forEach(attr => {
          if (this.isDirective(attr.name)) {
            var directiveName = attr.name.substr(2);
            if (directiveName.includes('on')) {
              // 綁定事件
              node.removeAttribute(attr.name);
              var eventName = directiveName.split(':')[1];
              this.addEvent(node, eventName, attr.value);
            } else {
              // v-text v-html 綁定值
              node.removeAttribute(attr.name);
              this.bind(node, this.$vm, attr.value, directiveName);
            }
          }
        })
      }
      addEvent(node, eventName, exp) {
        node.addEventListener(eventName, this.$vm.$options.methods[exp].bind(this.$vm));
      }
      bind(node, vm, exp, dir) {
        if (dir === 'text') {
          node.textContent = vm[exp];
        } else if (dir === 'html') {
          node.innerHTML = vm[exp];
        } else if (dir === 'value') {
          node.value = vm[exp];
        }
      }
      // 是不是指令
      isDirective(attr) {
        if (typeof attr !== 'string') return;
        return attr.includes('v-');
      }
      // 元素節點
      isElementNode(node) {
        return node.nodeType === 1;
      }
      // 文本節點
      isTextNode(node) {
        return node.nodeType === 3;
      }
    }
    new Compile('#root', data);
</script>
</html>

額,感受還好理解吧,這裏只是講了 Compile 是如何將data中的值渲染到視圖上,買了個關子,沒有說如何建立 Watcher 的,思考一下,若是要建立 Watcher ,應該在哪一個位置建立比較好呢?
答案是渲染值的同時,同時創造一個 Watcher 來監聽,上代碼:

class Compile {
  constructor (el, vm) {
    this.$el = this.isElementNode(el) ? el : document.querySelector(el);
    this.$vm = vm;
    if (this.$el) {
      this.$fragment = this.nodeFragment(this.$el);
      this.compileElement(this.$fragment);
      this.$el.appendChild(this.$fragment);
    }
  }
  nodeFragment (el) {
    let fragment = document.createDocumentFragment();
    let child;
    while (child = el.firstChild) {
      fragment.appendChild(child);
    }
    return fragment;
  }
  compileElement (el) {
    var childNodes = Array.from(el.childNodes);
    if (childNodes.length > 0) {
      childNodes.forEach(child => {
        var childArr = Array.from(child.childNodes);
        // 匹配{{}}裏面的內容
        var reg = /\{\{((?:.)+?)\}\}/;
        if (childArr.length > 0) {
          this.compileElement(child)
        } 
        if (this.isTextNode(child)) {
          var text = child.textContent.trim();
          var matchTextArr = reg.exec(text);
          var matchText;
          if (matchTextArr && matchTextArr.length > 1) {
            matchText = matchTextArr[1];
            this.compileText(child, matchText);
          }
        } else if (this.isElementNode(child)) {
          this.compileNode(child);
        }
      })
    }

  }
  compileText(node, exp) {
    this.bind(node, this.$vm, exp, 'text');
  }
  compileNode (node) {
    var attrs = Array.from(node.attributes);
    attrs.forEach(attr => {
      if (this.isDirective(attr.name)) {
        var directiveName = attr.name.substr(2);
        if (directiveName.includes('on')) {
          node.removeAttribute(attr.name);
          var eventName = directiveName.split(':')[1];
          this.addEvent(node, eventName, attr.value);
        } else if (directiveName.includes('model')) {
          // v-model
          this.bind(node, this.$vm, attr.value, 'value');
          node.addEventListener('input', (e) => {
            this.$vm[attr.value] = e.target.value;
          })
        }else{
          // v-text v-html
          node.removeAttribute(attr.name);
          this.bind(node, this.$vm, attr.value, directiveName);
        }
      }
    })
  }
  addEvent(node, eventName, exp) {
    node.addEventListener(eventName, this.$vm.$options.methods[exp].bind(this.$vm));
  }
  bind (node, vm, exp, dir) {
    if (dir === 'text') {
      node.textContent = vm[exp];
    } else if (dir === 'html') {
      node.innerHTML = vm[exp];
    } else if (dir === 'value') {
      node.value = vm[exp];
    }
    new Watcher(exp, vm, function () {
      if (dir === 'text') {
        node.textContent = vm[exp];
      } else if (dir === 'html') {
        node.innerHTML = vm[exp];
      }
    })
  }
  hasChildNode (node) {
    return node.children && node.children.length > 0;
  }
  // 是不是指令
  isDirective (attr) {
    if (typeof attr !== 'string') return;
    return attr.includes('v-');
  }
  // 元素節點
  isElementNode (node) {
    return node.nodeType === 1;
  }
  // 文本節點
  isTextNode (node) {
    return node.nodeType === 3;
  }
}

這裏比上面演示的demo多建立一個文檔碎片,能夠加快解析速度,另外在 80 行建立了 Watcher,當數據變化時,執行回調函數,從而更新視圖。

Watcher

期待已久的 Watcher 終於出來了,咱們先看看它長什麼樣:

class Watcher {
  constructor (exp, vm, cb) {
    this.$vm = vm;
    this.$exp = exp;
    this.depIds = {};
    this.getter = this.parseGetter(exp);
    this.value = this.get();
    this.cb = cb;
  }
  update () {
    let newVal = this.get();
    let oldVal = this.value;
    if (oldVal === newVal) return;
    this.cb.call(this.vm, newVal);
    this.value = newVal;
  }
  get () {
    Dep.target = this;
    var value = this.getter.call(this.$vm, this.$vm);
    Dep.target = null;
    return value;
  }
  parseGetter (exp) {
    if (/[^\w.$]/.test(exp)) return;
    return function (obj) {
      if (!obj) return;
      obj = obj[exp];
      return obj;
    }
  }
  addDep (dep) {
    if (!this.depIds.hasOwnProperty(dep.id)) {
      this.depIds[dep.id] = dep;
      dep.subs.push(this);
    }
  }
}

也不怎麼樣嘛,只有30多行代碼,接下來睜大眼睛啦,看看它是怎麼被 Dep 抓過來的。

  1. 當 Compile 建立 Watcher 出來的時候,也將 Dep.target 指向了 Watcher。同時獲取了該節點要渲染的值,觸發了 Observer 中的 get 方法,Dep.target 有值了,就執行 self.dep.depend();
  2. depend 方法裏執行 Dep.target.addDep(this); 而如今 Dep.target 指向 Watcher,因此執行的是 Watcher 裏的 addDep 方法 同時把 Dep 實例傳過去。
  3. Watcher 裏的 addDep 方法是將 Watcher 放在的 Dep實例的 subs 數組裏。
  4. 當vm裏的值放生變化時,觸發 Observer 的 set 方法,觸發全部 subs 裏的 Watcher 執行 Watcher 裏的 update 方法。
  5. update 方法裏有 Compile 的回調,從而更新視圖。

好吧,真想大白了,原來 Watcher 是引誘 Dep 把本身裝進小黑屋的。哈哈~
源碼已放在我本身的git庫裏,點擊這裏獲取源碼
講了半天,正主該出來了,mvvm 是如何將上面四個小夥伴給本身打工的呢,其實很簡單,上代碼

class MVVM {
  constructor (options) {
    this.$options = options;
    var data = this._data = this.$options.data;
    observe(data, this);
    new Compile(options.el || document.body, this);
  }
}

就是實例 MVVM 的時候,調用數據劫持,和 Compile 初始化視圖。到此就所有完成了mvvm模式。

kuawo.png

參考

  1. 合格前端系列第三彈-實現一個屬於咱們本身的簡易MVVM庫
  2. vue.js 權威指南
相關文章
相關標籤/搜索