本身動手寫一個 SimpleVue

最近看到一句話頗有感觸 —— 有人問 35 歲以後你還會在寫代碼嗎?各類中年程序員的言論充斥的耳朵,好像中年就不應寫代碼了,可是我想說,若干年之後,有人問你閒來無事你會幹什麼,我想我會說,寫代碼,我想這個答案就夠了,年齡不是你不愛的理由。javascript

理論基礎

雙向綁定是 MVVM 框架最核心之處,那麼雙向綁定的核心是什麼呢?核心就是 Object.defineProperty 這個 API,關於這個 API 的具體內容,請移步 MDN - Object.defineProperty ,裏面有更詳細的說明。html

接下來咱們來看一下 Vue 是怎麼設計的:前端

圖中有幾個重要的模塊:vue

  • 監聽者(Observer): 這個模塊的主要功能是給 data 中的數據增長 gettersetter,以及往觀察者列表中增長觀察者,當數據變更時去通知觀察者列表。
  • 觀察者列表(Dep): 這個模塊的主要做用是維護一個屬性的觀察者列表,當這個屬性觸發 getter 時將觀察者添加到列表中,當屬性觸發 setter 形成數據變化時通知全部觀察者。
  • 觀察者(Watcher): 這個模塊的主要功能是對數據進行觀察,一旦收到數據變化的通知就去改變視圖。

咱們簡化一下 Vue 裏的各類代碼,只關注咱們剛剛說的那些東西,實現一個簡單版的 Vue。java

Coding Time

咱們就拿 Vue 的一個例子來檢驗成果。node

<body>
  <div id="app">
    <p>{{ message }}</p>
    <button v-on:click="reverseMessage">逆轉消息</button>
  </div>
</body>
<script src="vue/index.js"></script>
<script src="vue/observer.js"></script>
<script src="vue/compile.js"></script>
<script src="vue/watcher.js"></script>
<script src="vue/dep.js"></script>
<script> const vm = new Vue({ el: '#app', data: { message: 'Hello Vue.js!' }, methods: { reverseMessage: function () { this.message = this.message.split('').reverse().join('') } }, mounted: function() { setTimeout(() => { this.message = 'I am changed after mounte'; }, 2000); }, }); </script>
複製代碼

new Vue()

首先,看 Vue 的源碼咱們就能知道,在 Vue 的構造函數中咱們完成了一系列的初始化工做,以及生命週期鉤子函數的設置。那咱們的簡易版 Vue 該怎麼寫呢?咱們在使用 Vue 的時候是經過一個構造函數來開始使用,因此咱們的簡易代碼也從構造函數開始。git

class Vue {
  constructor(options) {
    this.data = options.data;
    this.methods = options.methods;
    this.mounted = options.mounted;
    this.el = options.el;

    this.init();
  }

  init() {
    // 代理 data
    Object.keys(this.data).forEach(key => {
      this.proxy(key);
    });
    // 監聽 data
    observe(this.data, this);
    // 編譯模板
    const compile = new Compile(this.el, this);
    // 生命週期其實就是在完成一些操做後調用的函數,
    // 因此有些屬性或者實例在一些 hook 裏其實尚未初始化,
    // 也就拿不到相應的值
    this.callHook('mounted');
  }

  proxy(key) {
    Object.defineProperty(this, key, {
      enumerable: false,
      configurable: true,
      get: function() {
        return this.data[key]
      },
      set: function(newVal) {
        this.data[key] = newVal;
      }
    });
  }

  callHook(lifecycle) {
    this[lifecycle]();
  }
}
複製代碼

能夠看到咱們在構造函數中實例化了 Vue,而且對 data 進行代理,爲何咱們要進行代理呢?緣由是經過代理咱們就可以直接經過 this.message 操做 message,而不須要 this.data.message,代理的關鍵也是咱們上面所說的 Object.defineProperty。而生命週期其實在代碼中也是在特定時間點調用的函數,因此咱們作一些操做的時候也要去想一想,它初始化完成沒有,新手常常犯的錯誤就是在沒有完成初始化的時候去進行操做,因此對生命週期的理解是很是重要的。程序員

好了,完成了初始化,下面咱們就要開始寫如何監聽這些數據的變化了。github

Observer

經過上面的認識,咱們知道,Observer 主要是給 data 的每一個屬性都加上 gettersetter,以及在觸發相應的 getset 的時候執行的功能。算法

class Observer {
  constructor(data) {
    this.data = data;
    this.init();
  }

  init() {
    this.walk();
  }

  walk() {
    Object.keys(this.data).forEach(key => {
      this.defineReactive(key, this.data[key]);
    });
  }
  
  defineReactive(key, val) {
    const dep = new Dep();
    const observeChild = observe(val);
    Object.defineProperty(this.data, key, {
      enumerable: true,
      configurable: true,
      get() {
        if(Dep.target) {
          dep.addSub(Dep.target);
        }
        return val;
      },
      set(newVal) {
        if(newVal === val) {
          return;
        }
        val = newVal;
        dep.notify();
        observe(newVal);
      }
    });
  }
}

function observe(value, vm) {
  if(!value || typeof value !== 'object') {
    return;
  }
  return new Observer(value);
}
複製代碼

在上面,咱們完成了對 data 的監聽,經過遞歸調用實現了對每一個屬性值的監聽,給每一個數據都添加了 setter 和 getter,在咱們對數據進行取值或者是賦值操做的時候都會觸發這兩個方法,基於這兩個方法,咱們就可以作更多的事了。

如今咱們知道了怎麼監聽數據,那麼咱們如何去維護觀察者列表呢?我相信有些朋友和我同樣,看到 get 中的 Dep.target 有點懵逼,這究竟是個啥,怎麼用的,帶着這個疑問,咱們來看看觀察者列表是如何實現的。

Dep

class Dep {
  constructor() {
    this.subs = [];
  }

  addSub(sub) {
    this.subs.push(sub);
  }

  notify() {
    this.subs.forEach(sub => sub.update());
  }
}

Dep.target = null;
複製代碼

在 Dep 中咱們維護一個觀察者列表(subs),有兩個基礎的方法,一個是往列表中添加觀察者,一個是通知列表中全部的觀察者。能夠看到咱們最後一行的 Dep.target = null;,可能你們會好奇,這東西是幹什麼用的,其實很好理解,咱們定義了一個全局的變量 Dep.target,又由於 JavaScript 是單線程的,同一時間只可能有一個地方對其進行操做,那麼咱們就可以在觀察者觸發 getter 的時候,將本身賦值給 Dep.target,而後添加到對應的觀察者列表中,這也就是上面的 Observergetter 中有個對 Dep.target 的判斷的緣由,而後當 Watcher 被添加到列表中,這個全局變量又會被設置成 null。固然了這裏面有些東西還須要在 Watcher 中實現,咱們接下來就來看看 Watcher 如何實現。

Watcher

在寫代碼以前咱們先分析一下,Watcher 須要一些什麼基礎功能,Watcher 須要訂閱 Dep,同時須要更新 View,那麼在代碼中咱們實現兩個函數,一個訂閱,一個更新。那麼咱們如何作到訂閱呢?看了上面的代碼咱們應該有個初步的認識,咱們須要在 getter 中去將 Watcher 添加到 Dep 中,也就是依靠咱們上面說的 Dep.target,而更新咱們使用回調就能作到,咱們看代碼。

class Watcher {
  constructor(vm, exp, cb) {
    this.vm = vm;
    this.exp = exp;
    this.cb = cb;
    this.value = this.get();
  }

  get() {
    Dep.target = this;
    const value = this.vm.data[this.exp.trim()];
    Dep.target = null;
    return value;
  }

  update() {
    const newVal = this.vm.data[this.exp.trim()];
    if(this.value !== newVal) {
      this.value = newVal;
      this.cb.call(this.vm, newVal);
    }
  }
}
複製代碼

那麼,咱們有了 Watcher 以後要在什麼地方去調用它呢?想這個問題以前,咱們要思考一下,咱們如何拿到你在 template 中寫的各類 {{message}}v-text等等指令以及變量。對,咱們還有一個模版編譯的過程,那麼咱們是否是能夠在編譯的時候去觸發 getter,而後咱們就完成了對這個變量的觀察者的添加,好了說了那麼多,咱們來看下下面的模塊如何去作。

Compile

Compile 主要要完成的工做就是把 template 中的模板編譯成 HTML,在編譯的時候拿到變量的過程也就觸發了這個數據的 getter,這時候就會把觀察者添加到觀察者列表中,同時也會在數據變更的時候,觸發回調去更新視圖。咱們下面就來看看關於 Compile 這個模塊該怎麼去完成。

// 判斷節點類型
const nodeType = {
  isElement(node) {
    return node.nodeType === 1;
  },
  isText(node) {
    return node.nodeType === 3;
  },
};

// 更新視圖
const updater = {
  text(node, val) {
    node.textContent = val;
  },
  // 還有 model 啥的,但實際都差很少
};

class Compile {
  constructor(el, vm) {
    this.vm = vm;
    this.el = document.querySelector(el);
    this.fragment = null;
    this.init();
  }

  init() {
    if(this.el) {
      this.fragment = this.nodeToFragment(this.el);
      this.compileElement(this.fragment);
      this.el.appendChild(this.fragment);
    }
  }

  nodeToFragment(el) {
    // 使用 document.createDocumentFragment 的目的就是減小 Dom 操做
    const fragment = document.createDocumentFragment();
    let child = el.firstChild;

    // 將原生節點轉移到 fragment
    while(child) {
      fragment.appendChild(child);
      child = el.firstChild;
    }
    return fragment;
  }

  // 根據節點類型不一樣進行不一樣的編譯
  compileElement(el) {
    const childNodes = el.childNodes;
    
    [].slice.call(childNodes).forEach((node) => {
      const reg = /\{\{(.*)\}\}/;
      const text = node.textContent;

      // 根據不一樣的 node 類型,進行編譯,分別編譯指令以及文本節點
      if(nodeType.isElement(node)) {
        this.compileEl(node);
      } else if(nodeType.isText(node) && reg.test(text)) {
        this.compileText(node, reg.exec(text)[1]);
      }

      // 遞歸的對元素節點進行深層編譯
      if(node.childNodes && node.childNodes.length) {
        this.compileElement(node);
      }
    });
  }

  // 在這裏咱們就完成了對 Watcher 的添加
  compileText(node, exp) {
    const value = this.vm[exp.trim()];
    updater.text(node, value);
    new Watcher(this.vm, exp, (val) => {
      updater.text(node, val);
    });
  }

  compileEl(node) {
    const attrs = node.attributes;
    Object.values(attrs).forEach(attr => {
      var name = attr.name;
      if(name.indexOf('v-') >= 0) {
        const exp = attr.value;
        // 只作事件綁定
        const eventDir = name.substring(2);
        if(eventDir.indexOf('on') >= 0) {
          this.compileEvent(node, eventDir, exp);
        }
      }
    });
  }

  compileEvent(node, dir, exp) {
    const eventType = dir.split(':')[1];
    const cb = this.vm.methods[exp];

    if(eventType && cb) {
      node.addEventListener(eventType, cb.bind(this.vm));
    }
  }
}
複製代碼

這就是 Compile 完成的部分工做,固然了這個模塊不會這麼簡單,這裏只是簡單的實現了一點功能,現在 Vue 2.0 引入了 Virtual DOM,對元素的操做也不像這麼簡單了。

最後實現的功能因爲我比較懶,你們能夠本身寫一寫或者在個人 GitHub 倉庫裏能夠看到。

總結

上面的代碼也借鑑了前人的想法,但因爲時間比較久了,因此我也沒找到,感謝大佬提供思路。

Vue 的設計頗有意思,在學習之中也能有不少不同的感覺,同時,在讀源碼的過程當中,不要過多的追求讀懂每個變量,每個句子。第一遍代碼,先讀懂程序是怎麼跑起來的,大概是怎麼走的,通讀一遍,第二遍再去深究,扣一扣當時不清楚的東西,這是我看源碼的一些心得,可能每一個人的方法不同,但願你能有所收穫。

最後,由於 Vue 2.0 已經出來一段時間了,源碼也有不少的變更,生命週期的變化、Virtual DOM 等等,還有比較感興趣的 diff 算法,這些後續會繼續研究的,謝謝。


關注微信公衆號:創宇前端(KnownsecFED),碼上獲取更多優質乾貨!

相關文章
相關標籤/搜索