Vue的雙向綁定原理及實現

前言


學習vue也有一段時間了,對雙向綁定原理的實現一直有所瞭解,但是並沒有深入瞭解其實現原理。所以花了時間和查閱了一些資料,自己動手嘗試實現了簡單的vue的雙向綁定。

本文主要分爲兩部分:

  • vue的數據雙向綁定的實現原理
  • 實現簡單版的vue的雙向綁定,主要實現{{}}、v-model和事件指令的功能。

Vue的數據雙向綁定原理

vue的數據綁定是通過數據劫持結合發佈者和訂閱者模式來實現的。那麼vue是如何進行數據劫持的。可以先看一下vue初始化數據上的對象在控制檯上輸出的是什麼?

var vm = new Vue({
    data:{
        obj:{
            a:1
        }
    },
    created(){
        console.log(obj);
    }
})

結果:
在這裏插入圖片描述

上圖中可以看到屬性a有兩個對應的get和set方法,爲什麼會多出這兩個方法呢?那是因爲Vue是通過Object.defineProperty()方法進行數據劫持的。

Object.defineProperty( )是用來做什麼的?它可以來控制一個對象屬性的一些特有操作,比如讀寫權、是否可以枚舉,這裏我們主要先來研究下它對應的兩個描述屬性 get 和 set。更多內容可以點擊這裏

在平常,我們可以很容易打印出一個對象的屬性

var Book = {
    name: 'node權威指南',
}

console.log(Book.name);

當我想要在執行console.log(Book.name)的時候,我想要輸出的內容加上書名號,或者說我們該通過什麼方法來實現監聽數據的變化,這時候Object.defineProperty()方法就派上了用場。

Object.defineProperty(Book, 'name', {
    set:function(value) {
        name = value;
        console.log(`you have got a book named:${name}`);
    },
    get:function() {
        return '《'+name+'》';
    }
})

Book.name = '深入淺出webpack'; //you have got a book named:深入淺出webpack
console.log(Book.name);//《深入淺出webpack》

我們通過 Object.defineProperty( )設置了對象Book的name 屬性,對其get和set進行重寫操作,顧名思義,get就是在讀取name屬性這個值觸發的函數,set就是在設置name屬性這個值觸發的函數,所以當執行Book.name = '深入淺出webpack'這個語句時,控制檯會打印出 “you have got a book named:深入淺出webpack”,緊接着,當讀取這個屬性時,就會輸出 “《深入淺出webpack》”,因爲我們在 get 函數裏面對該值做了加工了。如果這個時候我們執行console.log(Book)的語句,控制檯會輸出什麼?

結果:
在這裏插入圖片描述

乍一看,是不是跟我們在上面打印 vue 數據長得有點類似,說明 vue 確實是通過這種方法來進行數據劫持的。接下來我們通過其原理來實現一個簡單版的 mvvm 雙向綁定代碼。

思路分析

實現mvvm主要包含兩個方面,數據變化更新視圖,視圖變化更新數據。
mvvm3
關鍵點在於data如果更新view,因爲view更新data。其實可以通過事件監聽即可,比如監聽’input’事件就可以實現了。所以着重分析一下,當數據改變如何更新視圖的。
mvvm4
數據更新視圖的重點是如何知道數據變了,只要知道數據變了,那麼接下去的事情都好處理。如何知道數據變了,其實上文我們已經給出答案了,就是通過Object.defineProperty()對屬性設置了一個set函數,當數據改變了就會觸發這個函數,所以只要我們將一些需要更新的方法放在這裏面就可以實現data更新view了。

實現過程

我們已經知道實現數據的雙向綁定,首先要對數據進行劫持監聽,我們需要設置一個監聽器Observer,用來監聽所有屬性。如果屬性上發生了變化,就需要告訴訂閱者Watcher是否需要更新。因爲訂閱者是有很多個,所以我們需要有一個消息訂閱器Dep來專門收集這些訂閱者,然後在監聽器Observer和訂閱者Watcher之間進行統一管理的,接着,我們還需要一個指令解析器compile,對每一個節點元素進行掃描和解析。將相關指令對應初始化成一個訂閱者Watcher,並替換模板數據或者綁定相應的函數,此時訂閱者Watcher接收到相應屬性的變化,就會執行相應的更新函數,從而更新視圖,因此接下去我們執行以下3個步驟,並實現數據的雙向綁定。

  1. 實現一個監聽器 Observer,用來劫持並監聽所有屬性,如果有變動的,就通知訂閱者。

  2. 實現一個訂閱者 Watcher,可以收到屬性的變化通知並執行相應的函數,從而更新視圖。

  3. 實現一個解析器 Compile,可以掃描和解析每個節點的相關指令,並根據指令 初始化模板數據以及初始化相應的訂閱器。

流程圖如下:
mvvm4

  1. 實現一個Observe

Observer 是一個數據監聽器,其實現核心方法就是前文所說的 Object.defineProperty( )。如果要對所有屬性都進行監聽的話,那麼可以通過遞歸方法遍歷所有屬性值,並對其進行 Object.defineProperty( )處理。如下代碼,實現了一個 Observer

    defineReactive(object, key, value) {
        observe(value);
        Object.defineProperty(object, key, {
            enumerable:true,
            configurable:true,
            get:function() {
                return value;
            },
            set:function(newVal) {
                value = newVal;
                console.log(`property ${key} has been listened, now value is ${newVal}`);
            }
        });
    }

    function observe(val) {
        if(!val || typeof val !== 'object') {
            return;
        }

        Obejct.keys(val).forEach(e => {
            defineReactive(val, e, val[e])
        });
    }


    var book = {
        name:'node',
        author:{
            firstName:'lee',
            lastName: 'wang'
        }
    }

    observe(book);
    book.name = 'webpack';
    book.author.firstName = 'Tian';

思路分析中,需要創建一個可以容納訂閱者的消息訂閱器 Dep,訂閱器 Dep 主要負責收集訂閱者,然後再屬性變化的時候執行對應訂閱者的更新函數。所以顯然訂閱器需要有一個容器,這個容器就是 list,將上面的 Observer 稍微改造下,植入消息訂閱器:

function defineReactive(obj, key, value) {
    observe(value);
    var dep = new Dep();
    Object.defineProperty(obj, key, {
        enumerable:true,
        configurable:true,
        get: function() {
            if(是否需要添加訂閱) {
                dep.addSub(watcher); //在這裏需要添加一個訂閱者
            }
            return val;
        },
        set: function(newVal) {
            if(val === newVal) {
                return;
            }
            console.log(`property ${key} has been listened, now value is ${newVal}`);
            dep.notify();
        }
    })
}

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

Dep.prototype = {
    addSub:function(key) {
        this.subs.push(key);
    },
    notify: function() {
        this.subs.forEach(e => {
            e.update();
        }) 
    }
}

從代碼上看,我們將訂閱器 Dep 添加一個訂閱者設計在 getter 裏面,這是爲了讓 Watcher 初始化進行觸發,因此需要判斷是否要添加訂閱者,至於具體設計方案,下文會詳細說明的。在 setter 函數裏面,如果數據變化,就會去通知所有訂閱者,訂閱者們就會去執行對應的更新的函數。到此爲止,一個比較完整 Observer 已經實現了,接下來我們開始設計 Watcher。

2.實現Watcher

訂閱者 Watcher 在初始化的時候需要將自己添加進訂閱器 Dep 中,那該如何添加呢?我們已經知道監聽器 Observer 是在 get 函數執行了添加訂閱者 Wather 的操作的,所以我們只要在訂閱者 Watcher 初始化的時候出發對應的 get 函數去執行添加訂閱者操作即可,那要如何觸發 get 的函數,再簡單不過了,只要獲取對應的屬性值就可以觸發了,核心原因就是因爲我們使用了 Object.defineProperty( )進行數據監聽。這裏還有一個細節點需要處理,我們只要在訂閱者 Watcher 初始化的時候才需要添加訂閱者,所以需要做一個判斷操作,因此可以在訂閱器上做一下手腳:在 Dep.target 上緩存下訂閱者,添加成功後再將其去掉就可以了。訂閱者 Watcher 的實現如下:

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

Watcher.prototype = {
    update:function() {
        this.run();
    },
    run:function() {
        var value = this.vm.data[this.exp];
        var oldVal = this.value;
        if(value !== oldVal) {
            this.value = value;
            this.cb.call(this.value, value, oldVal);
        }
    },
    get:function(){
        Dep.target = this; //自己綁定自己
        var value = this.vm.data[this.exp]; //強制執行監聽器裏的get函數
        Dep.target = null; //釋放自己
        return value;
    }
}

這時候,我們需要對監聽器 Observer 也做個稍微調整,主要是對應 Watcher 類原型上的 get 函數。需要調整地方在於 defineReactive 函數:

    function defineReactive(object, key, value) {
        observe(value);
        var dep = new Dep();
        Object.defineProperty(object, key, {
            enumerable:true,
            configurable:true,
            set:function(newVal){
                if (val === newVal) {
                return;
                }
                val = newVal;
                console.log('屬性' + key + '已經被監聽了,現在值爲:“' + newVal.toString() + '”');
                dep.notify(); // 如果數據變化,通知所有訂閱者
            },
            get:function() {
                if (Dep.target) {.  // 判斷是否需要添加訂閱者
                    dep.addSub(Dep.target); // 在這裏添加一個訂閱者
                }
                return val;
            }
        });
    }

到此爲止,簡單版的 Watcher 設計完畢,這時候我們只要將 Observer 和 Watcher 關聯起來,就可以實現一個簡單的雙向綁定數據了。因爲這裏沒有還沒有設計解析器 Compile,所以對於模板數據我們都進行寫死處理,假設模板上又一個節點,且 id 號爲’name’,並且雙向綁定的綁定的變量也爲’name’,且是通過兩個大雙括號包起來(這裏只是爲了掩飾,暫時沒什麼用處),模板如下:

<body>
    <h1 id="name">{{name}}</h1>
</body>

這時候我們需要將Observer和Watcher關聯起來:

function selfVue(data, el, exp) {
    this.data = data;
    observe(data);
    el.innerHTML = this.data[exp]; // 初始化模板數據的值
    new Watcher(this, exp, function(value) {
        el.innerHTML = value;
    });
    return this;
}

然後在頁面上 new 以下 SelfVue 類,就可以實現數據的雙向綁定了:

<body>
  <h1 id="name">{{name}}</h1>
</body>
<script src="js/observer.js"></script>
<script src="js/watcher.js"></script>
<script src="js/index.js"></script>
<script type="text/javascript">
  var ele = document.querySelector("#name");
  var selfVue = new SelfVue(
    {
      name: "hello world"
    },
    ele,
    "name"
  );

  window.setTimeout(function() {
    console.log("name值改變了");
    selfVue.data.name = "canfoo";
  }, 2000);
</script>

這時候打開頁面,可以看到頁面剛開始顯示了是’hello world’,過了 2s 後就變成’canfoo’了。到這裏,總算大功告成一半了,但是還有一個細節問題,我們在賦值的時候是這樣的形式 selfVue.data.name = ‘canfoo’ 而我們理想的形式是selfVue.name = 'canfoo’爲了實現這樣的形式,我們需要在 new SelfVue 的時候做一個代理處理,讓訪問 selfVue 的屬性代理爲訪問 selfVue.data 的屬性,實現原理還是使用 Object.defineProperty( )對屬性值再包一層:

function SelfVue(data, el, exp) {
  var self = this;
  this.data = data;

  Object.keys(data).forEach(function(key) {
    self.proxyKeys(key); // 綁定代理屬性
  });

  observe(data);
  el.innerHTML = this.data[exp]; // 初始化模板數據的值
  new Watcher(this, exp, function(value) {
    el.innerHTML = value;
  });
  return this;
}

SelfVue.prototype = {
  proxyKeys: function(key) {
    var self = this;
    Object.defineProperty(this, key, {
      enumerable: false,
      configurable: true,
      get: function proxyGetter() {
        return self.data[key];
      },
      set: function proxySetter(newVal) {
        self.data[key] = newVal;
      }
    });
  }
};

這下我們就可以直接通過selfVue.name = 'canfoo’的形式來進行改變模板數據了。如果想要迫切看到現象的童鞋趕快來獲取代碼!

3.實現compile

雖然上面已經實現了一個雙向數據綁定的例子,但是整個過程都沒有去解析 dom 節點,而是直接固定某個節點進行替換數據的,所以接下來需要實現一個解析器 Compile 來做解析和綁定工作。解析器 Compile 實現步驟:

  1. 解析模板指令,並替換模板數據,初始化視圖

  2. 將模板指令對應的節點綁定對應的更新函數,初始化相應的訂閱器

爲了解析模板,首先需要獲取到 dom 元素,然後對含有 dom 元素上含有指令的節點進行處理,因此這個環節需要對 dom 操作比較頻繁,所有可以先建一個 fragment 片段,將需要解析的 dom 節點存入 fragment 片段裏再進行處理:

function nodeToFragment(el) {
  var fragment = document.createDocumentFragment();
  var child = el.firstChild;
  while (child) {
    // 將 Dom 元素移入 fragment 中
    fragment.appendChild(child);
    child = el.firstChild;
  }
  return fragment;
}

接下來需要遍歷各個節點,對含有相關指定的節點進行特殊處理,這裏咱們先處理最簡單的情況,只對帶有 ‘{{變量}}’ 這種形式的指令進行處理,先簡到難嘛,後面再考慮更多指令情況:

function compileElement (el) {
    var childNodes = el.childNodes;
    var self = this;
    [].slice.call(childNodes).forEach(function(node) {
        var reg = /\{\{(.*)\}\}/;
        var text = node.textContent;

        if (self.isTextNode(node) && reg.test(text)) {  // 判斷是否是符合這種形式{{}}的指令
            self.compileText(node, reg.exec(text)[1]);
        }

        if (node.childNodes && node.childNodes.length) {
            self.compileElement(node);  // 繼續遞歸遍歷子節點
        }
    });
},
function compileText (node, exp) {
    var self = this;
    var initText = this.vm[exp];
    this.updateText(node, initText);  // 將初始化的數據初始化到視圖中
    new Watcher(this.vm, exp, function (value) {  // 生成訂閱器並綁定更新函數
        self.updateText(node, value);
    });
},
function (node, value) {
    node.textContent = typeof value == 'undefined' ? '' : value;
}

獲取到最外層節點後,調用 compileElement 函數,對所有子節點進行判斷,如果節點是文本節點且匹配{{}}這種形式指令的節點就開始進行編譯處理,編譯處理首先需要初始化視圖數據,對應上面所說的步驟 1. 接下去需要生成一個並綁定更新函數的訂閱器,對應上面所說的步驟 2. 這樣就完成指令的解析、初始化、編譯三個過程,一個解析器 Compile 也就可以正常的工作了。爲了將解析器 Compile 與監聽器 Observer 和訂閱者 Watcher 關聯起來,我們需要再修改一下類 SelfVue 函數:

function SelfVue(options) {
  var self = this;
  this.vm = this;
  this.data = options;

  Object.keys(this.data).forEach(function(key) {
    self.proxyKeys(key);
  });

  observe(this.data);
  new Compile(options, this.vm);
  return this;
}

更改後,我們就不要像之前通過傳入固定的元素值進行雙向綁定了,可以隨便命名各種變量進行雙向綁定了:

<body>
  <div id="app">
    <h2>{{title}}</h2>
    <h1>{{name}}</h1>
  </div>
</body>
<script src="js/observer.js"></script>
<script src="js/watcher.js"></script>
<script src="js/compile.js"></script>
<script src="js/index.js"></script>
<script type="text/javascript">
  var selfVue = new SelfVue({
    el: "#app",
    data: {
      title: "hello world",
      name: ""
    }
  });

  window.setTimeout(function() {
    selfVue.title = "你好";
  }, 2000);

  window.setTimeout(function() {
    selfVue.name = "canfoo";
  }, 2500);
</script>

如上代碼,在頁面上可觀察到,剛開始 title 和 name 分別被初始化爲 ‘hello world’ 和空,2s 後 title 被替換成 ‘你好’ 3s 後 name 被替換成 ‘canfoo’ 了。

到這裏,一個數據雙向綁定功能已經基本完成了,接下去就是需要完善更多指令的解析編譯,在哪裏進行更多指令的處理呢?答案很明顯,只要在上文說的 compileElement 函數加上對其他指令節點進行判斷,然後遍歷其所有屬性,看是否有匹配的指令的屬性,如果有的話,就對其進行解析編譯。這裏我們再添加一個v-model 指令和事件指令的解析編譯,對於這些節點我們使用函數 compile 進行解析處理:

function compile(node) {
  var nodeAttrs = node.attributes;
  var self = this;
  Array.prototype.forEach.call(nodeAttrs, function(attr) {
    var attrName = attr.name;
    if (self.isDirective(attrName)) {
      var exp = attr.value;
      var dir = attrName.substring(2);
      if (self.isEventDirective(dir)) {
        // 事件指令
        self.compileEvent(node, self.vm, exp, dir);
      } else {
        // v-model 指令
        self.compileModel(node, self.vm, exp, dir);
      }
      node.removeAttribute(attrName);
    }
  });
}

上面的 compile 函數是掛載 Compile 原型上的,它首先遍歷所有節點屬性,然後再判斷屬性是否是指令屬性,如果是的話再區分是哪種指令,再進行相應的處理,處理方法相對來說比較簡單,這裏就不再列出來.

最後我們在稍微改造下類 SelfVue,使它更像 vue 的用法:

function SelfVue(options) {
  var self = this;
  this.data = options.data;
  this.methods = options.methods;

  Object.keys(this.data).forEach(function(key) {
    self.proxyKeys(key);
  });

  observe(this.data);
  new Compile(options.el, this);
  options.mounted.call(this); // 所有事情處理好後執行mounted函數
}

這時候我們可以來真正測試了,在頁面上設置如下東西:

<body>
  <div id="app">
    <h2>{{title}}</h2>
    <input v-model="name" />
    <h1>{{name}}</h1>
    <button v-on:click="clickMe">click me!</button>
  </div>
</body>
<script src="js/observer.js"></script>
<script src="js/watcher.js"></script>
<script src="js/compile.js"></script>
<script src="js/index.js"></script>
<script type="text/javascript">
  new SelfVue({
    el: "#app",
    data: {
      title: "hello world",
      name: "canfoo"
    },
    methods: {
      clickMe: function() {
        this.title = "hello world";
      }
    },
    mounted: function() {
      window.setTimeout(() => {
        this.title = "你好";
      }, 1000);
    }
  });
</script>

這時候看起來就更像Vue了。

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