Vue源碼解析(二)Vue的雙向綁定講解及實現

文章中的代碼時階段,可以下載源碼測試一下。
git項目地址:https://github.com/xubaodian/SimuVue

項目使用webpack構建,下載後先執行:

npm install

安裝依賴後使用指令:

npm run dev

可以運行項目。

上篇文章,我們講解了Vue的data屬性映射和方法的重定義,鏈接地址如下:

Vue源碼解析(一)data屬性映射和methods函數引用的重定義

這篇文章給大家帶來的是Vue的雙向綁定講解。

什麼是雙向綁定

我們看一張圖:



可以看到,輸入框上方的內同和輸入框中的值是一致的。輸入框的之變化,上方的值跟着一起變化。

這就是Vue的雙向綁定。

對象屬性監聽實現

我們先不着急瞭解Vue時如何實現這一功能的,如果我們自己要實現這樣的功能,如何實現呢?

我的思路是這樣:



可以分爲幾個步驟,如下:

1、首先給輸入框添加input事件,監視輸入值,存放在變量value中。

2、監視value變量,確保value變化時,監視器可以發現。

3、若value發生變化,則重新渲染視圖。

上面三個步驟,1(addEventListener)和3(操作dom)都很好實現,對於2的實現,可能有一下兩個方案:

1、使用Object.defineProperty()重新定義對象set和get,在值發生變化時,通知訂閱者。

2、使用定時器定時檢查value的值,發生變化就通知訂閱者。(這個方法不好,定時器不能實時反應value變化)。

Vue源碼中採用了方案1,我們首先用方案1實現對對象值的監聽,代碼如下:

function defineReactive(obj, key, val, customSetter) {
  //獲取對象給定屬性的描述符
  let property = Object.getOwnPropertyDescriptor(obj, key);
  //對象該屬性不可配置,直接返回
  if (property && property.configurable === false) {
    return;
  }

  //獲取屬性get和set屬性,若此前該屬性已經進行監聽,則確保監聽屬性不會被覆蓋
  let getter = property && property.get;
  let setter = property && property.set;
  
  if (arguments.length < 3) {
    val = obj[key];
  }

  //監聽屬性
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val;
      console.log(`讀取了${key}屬性`);
      return value;
    },
    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val;
      //如果值沒有變化,則不做改動
      if (newVal === value) {
        return;
      }
      //自定義響應函數
      if (customSetter) {
        customSetter(newVal);
      }
      if (setter) {
        setter.call(obj, newVal);
      } else {
        val = newVal;
      }
      console.log(`屬性${key}發生變化:${value} => ${newValue}`);
    }
  })
}

下面我們測試下,測試代碼如下:

let obj = {
    name: 'xxx',
    age: 20
};
defineReactive(obj, 'name');
let name = obj.name;
obj.name = '1111';

控制檯輸出爲:

讀取了name屬性
test.html:51 屬性name發生變化:xxx => 1111

可見,我們已經實現了對obj對象name屬性讀和寫的監聽。

實現了監聽,這沒問題,但是視圖怎麼知道這些屬性發生了變化呢?可以使用發佈訂閱模式實現。

發佈訂閱模式

什麼是發佈訂閱模式呢?

我畫了一個示意圖,如下:


發佈訂閱模式有幾個部分構成:

1、訂閱中心,管理訂閱者列表,發佈者發消息時,通知相應的訂閱者。

2、訂閱者,這個是訂閱消息的主體,就像關注微信公衆號一樣,有文章就會通知關注者。

3、發佈者,類似微信公衆號的文章發佈者。

訂閱中心的代碼如下:

export class Dep {
  constructor() {
    this.id = uid++;
    //訂閱列表
    this.subs = [];
  }

  //添加訂閱
  addSub(watcher) {
    this.subs.push(watcher);
  }

  //刪除訂閱者
  remove(watcher) {
    let index = this.subs.findIndex(item => item.id === watcher.id);
    if (index > -1) {
      this.subs.splice(index, 1);
    }
  }


  depend () {
    if (Dep.target) {
      Dep.target.addDep(this);
    }
  }

//通知訂閱者
  notify() {
    this.subs.map(item => {
      item.update();
    });
  }
}

//訂閱中心  靜態變量,訂閱時使用
Dep.target = null;
const targetStack = [];

export function pushTarget (target) {
  targetStack.push(target);
  Dep.target = target;
}

export function popTarget () {
  targetStack.pop();
  Dep.target = targetStack[targetStack.length - 1];
}

訂閱中心已經實現,還有發佈者和訂閱者,先看下發布者,這裏誰是發佈者呢?

沒錯,就是defineReactive函數,這個函數實現了對data屬性的監聽,它可以檢測到data屬性的修改,發生修改時,通知訂閱中心,所以defineReactive做一些修改,如下:

//屬性監聽
export function defineReactive(obj, key, val, customSetter) {
  //獲取對象給定屬性的描述符
  let property = Object.getOwnPropertyDescriptor(obj, key);
  //對象該屬性不可配置,直接返回
  if (property && property.configurable === false) {
    return;
  }

  //訂閱中心
  const dep = new Dep();

  //獲取屬性get和set屬性,若此前該屬性已經進行監聽,則確保監聽屬性不會被覆蓋
  let getter = property && property.get;
  let setter = property && property.set;
  
  if (arguments.length < 3) {
    val = obj[key];
  }

  //如果監聽的是一個對象,繼續深入監聽
  let childOb = observe(val);
  //監聽屬性
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val;
      //這段代碼時添加訂閱時使用的
      if (Dep.target) {
        dep.depend();
        if (childOb) {
          childOb.dep.depend();
        }
      }
      return value;
    },
    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val;
      //如果值沒有變化,則不做改動
      if (newVal === value) {
        return;
      }
      //自定義響應函數
      if (customSetter) {
        customSetter(newVal);
      }
      if (setter) {
        setter.call(obj, newVal);
      } else {
        val = newVal;
      }
      //如果新的值爲對象,重新監聽
      childOb = observe(newVal);
      /**
       * 訂閱中心通知所有訂閱者
       **/
      dep.notify();
    }
  })
}

這裏設計到閉包的概念,我們在函數裏定義了:

 const dep = new Dep();

由於set和get函數一直都存在的,所有dep會一直存在,不會被回收。

當值發生變化後,利用下面的代碼通知訂閱者:

dep.notify();

訂閱中心和發佈者都有了,我們何時訂閱呢?或者什麼時間訂閱合適呢?

我們是希望實現當讀取data屬性時候,實現訂閱。所以在defineReactive函數的get監聽中添加了如下代碼:

    if (Dep.target) {
        dep.depend();
        if (childOb) {
          childOb.dep.depend();
        }
    }
    return value;

Dep.target是一個靜態變量,用來存儲訂閱者的,每次訂閱前指向訂閱者,訂閱者置爲null。

訂閱者代碼如下:

let uid = 0;
//訂閱者類
export class Watcher{
  //構造器,vm是vue實例
  constructor(vm, expOrFn, cb) {
    this.vm = vm;
    this.cb = cb;
    this.id = uid++;
    this.deps = [];
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      this.getter = parsePath(expOrFn)
    }
    this.value = this.get();
  }

  //將訂閱這添加到訂閱中心
  get() {
    //訂閱前,設置Dep.target變量,指向自身
    pushTarget(this)
    let value;
    const vm = this.vm;
    /**
     * 這個地方讀取data屬性,觸發下面的訂閱代碼,
     *  if (Dep.target) {
     *      dep.depend();
     *     if (childOb) {
     *       childOb.dep.depend();
     *     }
     *   }
     *   return value;
     **/
    value = this.getter.call(vm, vm);
    //訂閱後,置Dep.target爲null
    popTarget();
    return value
  }

  //值變化,調用回調函數
  update() {
    this.cb(this.value);
  }

  //添加依賴
  addDep(dep) {
    this.deps.push(dep);
    dep.addSub(this);
  }
}

//解析類屬性的路徑,例如obj.sub.name,返回實際的值
export function parsePath (path){
  const segments = path.split('.');
  return function (obj) {
    for (let i = 0; i < segments.length; i++) {
      if (!obj) return;
      obj = obj[segments[i]];
    }
    return obj;
  }
}

除了發佈訂閱以外,雙向綁定還需要編譯dom。

dom編譯和input綁定

主要實現兩個功能:

1、將dom中的{{key}}元素替換爲Vue中的屬性。

2、檢測帶有v-model屬性的input元素,添加input事件,有修改時,修改Vue實例的屬性。

檢測v-model,綁定事件的代碼如下:

export function initModelMixin(Vue) {
    Vue.prototype._initModel = function () {
        if (this._dom == undefined) {
            if (this.$options.el) {
                let el = this.$options.el;
                let dom = document.querySelector(el);
                if (dom) {
                    this._dom = dom;
                } else {
                    console.error(`未發現dom: ${el}`);
                }
           } else {
               console.error('vue實例未綁定dom');
           }
        } 
        bindModel(this._dom, this);
    } 
}

//input輸入框有V-model屬性,則綁定input事件
function bindModel(dom, vm) {
    if (dom) {
        if (dom.tagName === 'INPUT') {
            let attrs = Array.from(dom.attributes);
            attrs.map(item => {
                if (item.name === 'v-model') {
                    let value = item.value;
                    dom.value = getValue(vm, value);
                    //綁定事件,暫不考慮清除綁定,因此刪除dom造成的內存泄露我們暫不考慮,這些問題後續解決
                    dom.addEventListener('input', (event) => {
                        setValue(vm, value, event.target.value);
                    });
                }
            })
        }
        let children = Array.from(dom.children);
        if (children) {
            children.map(item => {
                bindModel(item, vm);
            });
        }
    }
}

替換dom中{{key}}類似的屬性代碼:

export function renderMixin(Vue) {
    Vue.prototype._render = function () {
        if (this._dom == undefined) {
            if (this.$options.el) {
                let el = this.$options.el;
                let dom = document.querySelector(el);
                if (dom) {
                    this._dom = dom;
                } else {
                    console.error(`未發現dom: ${el}`);
                }
           } else {
               console.error('vue實例未綁定dom');
           }
        } 
        replaceText(this._dom, this);
    } 
}

//替換dom的innerText
function replaceText(dom, vm) {
    if (dom) {
        let children = Array.from(dom.childNodes);
        children.map(item => {
            if (item.nodeType === 3) {
                if (item.originStr === undefined) {
                    item.originStr = item.nodeValue;
                }
                let str = replaceValue(item.originStr, function(key){
                    return getValue(vm, key);
                });
                item.nodeValue = str;
            } else if (item.nodeType === 1) {
                replaceText(item, vm);
            }
        });
    }
}

到此位置,就實現了雙向綁定。

測試代碼如下,因爲我用webpack構建的前端項目,html模板如下:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <title>test
  </title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
  <div id="app">
    <div class="test">{{name}}</div>
    <input type="text" v-model="name">
  </div>
</body>
</html>

main.js代碼:

import { Vue } from '../src/index';

let options = {
    el: '#app',
    data: {
        name: 'xxx',
        age: 18
    },
    methods: {
        sayName() {
            console.log(this.name);
        }
    }
}


let vm = new Vue(options);

效果如下:



可以下載源碼嘗試,git項目地址:https://github.com/xubaodian/SimuVue

項目使用webpack構建,下載後先執行:

npm install

安裝依賴後使用指令:

npm run dev

可以運行項目。

如有疑問,歡迎留言或發送郵件至[email protected]

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章