瞭解vue源碼,從0到1實現本身的mvvm框架

武學之道,切勿貪多嚼不爛,博兒不精不如 一招鮮吃遍天,編程亦是如此,源碼就是內力的修煉。這裏咱們根據對vue源碼的分析和理解來實現一個自定義的mvvm框架:KVue。html

實現目標:vue

一、數據劫持:defineProperty。
二、依賴收集:Dep && Watcher。
三、編譯:插值綁定{{name}},指令綁定(v-text),雙向綁定(v-model),事件處理(@click),html解析。node

下面這張圖簡單介紹了 mvvm實現的各部分細節。 git

主要包括三個部分:響應式原理 --> 依賴收集與追蹤 --> 編譯complie。
其中依賴收集是與響應式和編譯原理相關的,因此咱們能夠分兩部分來實現一個mvvm框架,即:響應式原理和編譯實現。

響應式原理

經過響應式在修改數據的時候更新視圖。Vue.js的響應式原理依賴於Object.defineProperty,Vue經過設定對象屬性的 setter/getter 方法來監聽數據的變化,經過getter進行依賴收集,而每一個setter方法就是一個觀察者,在數據變動的時候通知訂閱者更新視圖。正則表達式

數據劫持

實現流程:實現一個KVue構造器,接收options屬性和data屬性。遍歷options.data裏面的屬性,經過object.defineProperty屬性進行數據劫持。
須要注意的是,在遍歷數據屬性的過程當中咱們爲屬性值執行一個代理proxy。這樣咱們就把data上面的屬性代理到了vm實例上。編程

class KVue {
  constructor(options) {
    this.$options = options;
    this.$data = options.data;
    observe(this.$data);
  }
}
複製代碼

實現一個數據觀察器Observe()數組

observe(value) {
   if(!value || typeof value !== 'object'){
     return
   }
   // 遍歷該對象
   Object.keys(value).forEach(key => {
     this.defineReactive(value, key, value[key])
     // 代理data的中屬性到vue實例上
     this.proxyData(key)
   })
  }

  defineReactive(obj, key, val){
    this.observe(val);  // 解決數據嵌套:遞歸

    const dep = new Dep();

    Object.defineProperty(obj, key, {
     get: function(){
       return val;
     },
     set: function(newVal) {
       if(val === newVal){
         return
       }
       val = newVal;
     }
    })
  }

  proxyData(key) {       //  執行一個代理proxy。這樣咱們就把data上面的屬性代理到了vm實例上。
    Object.defineProperty(this, key, {
      get(){
        return this.$data[key];
      },
      set(newVal){
        this.$data[key] = newVal
      }
    })
  }

複製代碼

這樣就實現了對options中的data屬性的數據劫持,經過getter劫持到讀取屬性時的操做以及經過setter劫持到設置屬性值的操做。在上文中咱們在時只是簡單的讀取了值,在set是設置了新值。bash

發佈訂閱模式

在上面的步驟中咱們只是作了數據劫持,要達到數據響應式的效果,還須要在getter和setter中進行一些操做,咱們須要實現一個依賴收集器Dep()和數據偵聽器Watcher()。app

依賴收集

依賴收集也成爲發佈者。框架

上文中已經對option的date屬性進行了實現了數據劫持,在初始化讀取值時天然會觸發getter事件,因此咱們只要在最開始進行一次render,那麼全部被渲染所依賴的data中的數據就會被getter收集到Dep的subs中去。在對data中的數據進行修改的時候setter只會觸發Dep的subs的函數。

咱們先來實現一個簡單的依賴收集類,dep內部維護了一個deps數組,addDep用來添加數據的依賴, notify函數在數組內部通知依賴更新。

class Dep {
  constructor() {
   this.deps = [];
  }
   addDep(dep) {
     this.deps.push(dep)
   }
   notify() {
     this.deps.forEach(dep => {
       dep.update()
     });
   }
}
複製代碼

數據偵聽器

數據偵聽器也稱爲訂閱者。

當依賴收集的時候會addSub到sub中,在修改data中數據的時候會觸發dep對象的notify,通知全部Watcher對象去修改對應視圖。 

class Watcher {
  constructor(vm, key, cb) {
    this.vm = vm;
    this.key = key;
    this.cb = cb;

    // 在這裏將觀察者自己賦值給全局的target,只有被target標記過的纔會進行依賴收集
    Dep.target = this;
    // 觸發getter,添加依賴
    this.vm[this.key];
    Dep.target = null
  }

  update() {
    //  將回調函數代理到this.vm實例,並傳入對應屬性的value值
    this.cb.call(this.vm, this.vm[this.key]);
  }
}
複製代碼

實現一個數據可被觀察(observable)的類

開始依賴收集

實現了發佈訂閱者模式後,咱們能夠將依賴收集器和數據偵聽器應用到數據劫持中發揮做用,開始依賴收集。

defineReactive(obj, key, val){
    this.observe(val);  // 解決數據嵌套:遞歸

    const dep = new Dep();

    Object.defineProperty(obj, key, {
     get: function(){
       /*Watcher對象存在全局的Dep.target中, 只有被target標記過的纔會進行依賴收集*/
       Dep.target && dep.addDep(Dep.target)
       return val;
     },
     set: function(newVal){
       if(val === newVal){
         return
       }
       val = newVal;
       /*只有以前addSub中的函數纔會觸發*/
       dep.notify();
     }
    })
  }
複製代碼

第一部分檢測目標成果

經過以上代碼咱們就實現了mvvm框架最基本的部分,數據響應式。能夠運行一下上述代碼並模擬watcher的建立過程檢測一下目標成果。 編寫一個html文件,引入kvue腳本,定義一個KVue實例,傳入option選項,以及修改屬性值

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div id="app">
    <p id="name">
      <!-- {{test}} -->
    </p>
  </div>
  <script src="kvue.js"></script>
  <script>
    const app = new KVue({
      data: {
        test: 'I am test',
        foo: {
          bar: 'bar'
        }
      }
    })

    app.$data.test = 'hello, vue';
    app.$data.foo.bar = 'oh, my bar';
  </script>
</body>
</html>

複製代碼

在KVue構造器內模擬實現watcher的建立中依賴收集和數據偵聽的過程;

constructor(options) {
    this.$options = options;

    this.$data = options.data;
    this.observe(this.$data);

    // 模擬一下watcher的建立過程;
    new Watcher(this, 'test', (val)=>{
      console.log(val)
    });
    this.$data.test;
    new Watcher(this.foo, 'bar', (val)=>{
      console.log(val)
    });
    this.$data.foo.bar

    // new Compile(options.el, this);

    // created執行
    // if(options.created){
    //   options.created.call(this);
    // }
  }
複製代碼

此時在控制檯打開測試用的html文件,能夠看到控制檯輸出「hello, vue」,說明修改test屬性值,觸發了setter裏寫的dep.notify函數,調用了Watcher更新的函數,觸發了Watcher回調函數。因此在watcher的回調中打印了屬性值。

編譯器實現

實現目標:

  1. 插值綁定:{{name}},
  2. 指令綁定:k-text,
  3. 雙向綁定:k-model,
  4. html解析:k-html。
  5. 事件處理:@click,

Compile構造函數

新建compile.js文件,獲取實例渲染的宿主節點,遍歷宿主節點,經過document.createDocumentFragment()方法將Dom中的node節點轉成文檔片斷。由於文檔片斷存在於內存中,並不在DOM樹中,因此對文檔片斷進行插入操做不會引發頁面迴流。所以,使用文檔片斷一般會帶來更好的性能。以後對轉換後的文檔片斷執行編譯函數:遍歷根節點的全部自節點,對元素節點和插值節點分別處理。注意若是子節點仍然包含子節點的話,須要遞歸實現compile函數。

對DOM中的Node節點不熟悉的童鞋能夠參考一下MDN 上關於 Node 部分的詳解,會對編譯過程的理解有幫助。

class Compile {
    constructor(el, vm){
        this.$el = document.querySelector(el); // 拿到要遍歷的宿主節點
        this.$vm = vm;  // 存儲vm實例

        if(this.$el){
            // 轉換內部內容爲片斷fragment
            this.$fragment = this.node2Fragment(this.$el);
            // 執行編譯
            this.compile(this.$fragment);
            // 將編譯完的html結果追加至el
            this.$el.appendChild(this.$fragment);
        }
    }
    // 將宿主元素的代碼片斷拿出來遍歷,這樣作比較高效。
    node2Fragment(el) {
        const frag = document.createDocumentFragment();
        // 將el中的全部子元素搬家至frag中
        let child;
        while(child = el.firstChild){
            frag.appendChild(child)
        }
        return frag;
    }
    // 編譯過程
    compile(el){
        const childNodes = el.childNodes;  // 
        Array.from(childNodes).forEach(node => {
            if(this.isElement(node)){ // 處理元素
                console.log('編譯元素'+node.nodeName)
            } else if(this.isInterpolation(node)){ // 插值文本
                onsole.log('編譯文本'+node.textContent)
            }
            // 遞歸子節點
            if(node.childNodes && node.childNodes.length>0){
                this.compile(node)
            }
        }
    }
    isElement(node) { // 判斷node是否是元素節點
        return node.nodeType === 1;
    }
    isInterpolation(node) {  // 判斷node是否是插值節點
        return node.nodeType === 3 && /\{\{(.*)\}\}/.test(node.textContent);
    }
}
複製代碼

實現插值替換處理

先實現了一個最簡單的插值節點的插值替換處理。在compile 函數中的處理文本分支執行compileText過程。

...
      Array.from(childNodes).forEach(node => {
          if(this.isElement(node)){ // 處理元素
              console.log('編譯元素'+node.nodeName)
          } else if(this.isInterpolation(node)){ // 插值文本
              console.log('編譯文本'+node.textContent)
              this.compileText(node);
          }
      }
  ...
  compileText(node) {
      /* 捕獲正則表達式匹配中的分組1 */
      let groupName = RegExp.$1
      /* 設置節點的textContent屬性爲data中的屬性值,即完成了插值替換 */
      node.textContent = this.$vm.$data[groupName];
  }
複製代碼

針對編譯過程新建一個測試函數compile-test.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div id="app">
    <p>{{name}}</p>
    <p k-text="name"></p>
    <p>{{age}}</p>
    <p>
      {{doubleAge}}
    </p>
    <input type="text" k-model="name">
    <button @click="changeName">呵呵</button>
    <div k-html="html"></div>
  </div>
  <script src="kvue.js"></script>
  <script src="compile.js"></script>
  <script>
    const app = new KVue({
      el: "#app",
      data: {
        name: 'I am test',
        age: '10',
        doubleAge: '20',
        html: '<button>這是一個按鈕</button>'
      },
      created(){
          console.log('開始了');
          setTimeout(() => {
              this.name = 'w我是測試';
          }, 1500)
      },
      methods: {
        changeName() {
            this.name = '事件觸發';
            this.age = 1;
        }
      },
    })
  </script>
</body>
</html>
複製代碼

對編譯過程進行測試,咱們能夠在KVue的構造函數中添加created聲明週期,調整KVue以下:實現編譯器實例化,並執行created聲明周期函數。 kvue.js

constructor(options) {
    this.$options = options;

    this.$data = options.data;
    this.observe(this.$data);

    new Compile(options.el, this);

    // created執行
    if(options.created){
      options.created.call(this);
    }
  }
複製代碼

運行這個文件,咱們發現關於插值的部分被成功的解析了,可是這個值在修改的時候頁面上並不會響應式的變化。這是由於並在編譯過程當中插值屬性並無被添加到依賴中。 因此的編譯器處理節點屬性值的時候須要將compiler與數據偵聽器結合起來,添加依賴收集。

編譯器中的依賴收集

由於對於其餘類型的節點也須要進行這一步處理,因此咱們將這個功能提取成一個公共函數update,經過函數參數傳入自定義變量。

compileText(node) {
        this.update(node, this.$vm, RegExp.$1, 'text')
    }
    update(node, vm, exp, dir) {  // dir傳入節點的操做類型
        const updaterFn = this[dir+'Updater'];
        // 初始化
        updaterFn && updaterFn(node, vm[exp]);
        // 依賴收集
        new Watcher(vm, exp, function(value){
            updaterFn && updaterFn(node, value);
        })
    }
    textUpdater(node, value) {
        node.textContent = value
    }
複製代碼

在運行compile-test.html文件,咱們發現頁面上的三個插值操做符被正確解析,並created聲明週期內修改的屬性值也成功的顯示到頁面上,到這一步,咱們已經實現了能足夠響應式處理插值操做符的編譯器。

實現指令綁定

實現指令綁定的關鍵在於須要在編譯函數的處理元素的分支上正確處理指令,判斷是否以k-開頭,例如k-text,dir匹配到text,則判斷this.text函數是否存在,存在則執行text函數。text函數內調用公共更新函數update(),在依賴更新的時候觸發${dir}Updater(),即textUpdater函數,設置node的textContent屬性。

...
  if(this.isElement(node)){ // 處理元素
      // 查找 k-、@、:、
      const nodeAttrs = node.attributes;
      Array.from(nodeAttrs).forEach(attr => {
          const attrName = attr.name;
          const exp = attr.value;
          if(this.isDirective(attrName)) { // 處理指令
              // k-text
              const dir = attrName.substring(2);
              // 執行指令
              this[dir] && this[dir](node, this.$vm, exp);
          }
      })

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

  text(node, vm, exp){
    this.update(node, vm, exp, 'text')
  }
  textUpdater(node, value) {
    node.textContent = value
  }
複製代碼

雙向綁定

雙向綁定通常經過k-model實現。在上一步代碼中已經經過dir匹配到'model',因此只須要在上一步的基礎上補充model函數和modelUpdater函數。

// 雙向綁定
model(node, vm, exp){
    // 指定input value屬性
    this.update(node, vm, exp, 'model');

    // 視圖對模型響應
    node.addEventListener('input', e => {
        vm[exp] = e.target.value;
    });
}

modelUpdater(node, value){
    node.value = value;
}
複製代碼

html解析

同理,處理v-html指令,只須要添加

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

htmlUpdater(node, value){
    node.innerHTML = value;
}
複製代碼

事件綁定

在compile函數的處理的元素分子上判斷處理之間的指令,dir匹配到事件類型,如:click

...
if(this.isEvent(attrName)){  // 處理事件
  const dir = attrName.substring(1);
  this.eventHandler(node, this.$vm, exp, dir)
}
...
eventHandler(node, vm, exp, dir) {
  // 取事件名
  let fn = vm.$options.methods && vm.$options.methods[exp];
  if(dir && fn) {
      node.addEventListener(dir, fn.bind(vm)); // 綁定事件監聽
  }
}
複製代碼

至此一個基於vue的基本語法mvvn框架KVue就已經實現,可以實現相似於vue指令的語法解析,以及實時的數據視圖的雙向綁定。支持插值綁定、指令綁定、雙向綁定html解析、事件處理等語法特性。

總結

以上過程整理成腦圖以下,也許能有助於理解。

項目源碼: 

gitee地址:kvue: 瞭解vue源碼,從0到1實現本身的mvvm框架 若是你以爲有幫助能夠給個star哦!

相關文章
相關標籤/搜索