5分鐘教你實現Vue雙向綁定

前言

不少人在面試過程當中都有問到Vue雙向綁定的原理和實現,這是一個老生常談的面試題了,雖然網上也有不少實現雙向綁定的文章,可是我看後以爲對於大多數前端小白來講,不是很容易理解,因此,這篇文章我就用最簡單的代碼教你們怎麼實現一個Vue的雙向綁定。javascript

雙向綁定的原理

用過Vue框架的都知道,頁面在初始化的時候,咱們能夠把data裏的屬性渲染到頁面上,改動頁面上的數據時,data裏的屬性也會相應的更新,這就是咱們所說的雙向綁定,因此,簡單來講,咱們要實現一個雙向綁定要實現如下3點操做:html

  1. 首先須要在Vue實例化的時候,解析代碼中v-modle指令和{{}}指令,而後把data裏的屬性綁定到相應的指令上,因此咱們要實現一個解析器Compile,這是第一點;
  2. 接着咱們在改變頁面的屬性的時候,要知道哪一個屬性改變了,這時候咱們須要用到Object.defineProperty中的gettersetter方法對屬性進行劫持,這裏咱們要實現一個監視器Observer,這是二點;
  3. 咱們在知道具體哪一個屬性改變後,要執行相應的函數,更新視圖,這裏咱們要實現一個消息訂閱,在頁面初始化的時候訂閱每一個屬性,而且在Object.defineProperty數據劫持的時候接收屬性改變通知,更新視圖,因此咱們要實現一個訂閱者Watcher,這是第三點。

1. 實現Compile

首先,咱們從最基本的解析指令開始,話很少說,先上代碼:前端

咱們在寫Vue的時候,用了 v-model{{}}指令,可是頁面渲染的時候,咱們在瀏覽器看到的節點是這樣的。

咱們從上面的圖片能夠看到,代碼裏寫的指令都消失了,可是data裏的屬性都正常渲染到頁面上了, 原理其實很簡單,在Vue實例化的時候,Vue便利循環,掃描和解析每一個節點的相關指令,而後再根據對應的指令賦值,最後把相應的指令替換刪除,再從新渲染頁面。 因此,接下來咱們要實現一個解析器Compile,先從解析 v-model{{}}開始。 話很少說,上代碼:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>MVVMdemo</title>
</head>

<body>
    <div id="app">
        <input type="text" v-model="text">
        <div>{{text}}</div>
    </div>
</body>
<script type="text/javascript">
    var vm = new Vue({
        el: 'app',
        data: {
            text: 'hello world',
        }
    })

    function Vue(options) {
        this.data = options.data;
        var id = options.el;
        var dom = nodeToFragment(document.getElementById(id), this) //DocumentFragment(文檔片斷)
        document.getElementById(id).appendChild(dom); //將處理好的DocumentFragment從新添加到Dom中
    }

    function nodeToFragment(node, vm) {
        var flag = document.createDocumentFragment();
        var child;
        while (child = node.firstChild) {
            compile(child, vm);
            flag.appendChild(child)
        }
        return flag
    }
    //解析節點
    function compile(node, vm) {
        var reg = /\{\{(.*)\}\}/;
        //判斷是否有子節點
        if (node.childNodes && node.childNodes.length) {
            node.childNodes.forEach(function (node) {
                compile(node, vm)
            })
        } else {
            //解析v-model
            if (node.nodeType === 1) {
                var attr = node.attributes;
                for (var i = 0; i < attr.length; i++) {
                    if (attr[i].nodeName == "v-model") {
                        var name = attr[i].nodeValue;
                        node.value = vm.data[name]; //將data裏的值賦給node
                        node.removeAttribute('v-model'); //移除v-model屬性
                    }
                };
            }
            //解析{{}}
            if (node.nodeType === 3) {
                if (reg.test(node.nodeValue)) {
                    var name = RegExp.$1; // 獲取匹配到的字符串
                    name = name.trim();
                    node.nodeValue = vm[name]
                }
            }
        }
    }
</script>

</html>
複製代碼

上面這段代碼就是解析指令的簡單方法,我來簡單解釋一下:vue

  1. document.createDocumentFragment()
    document.createDocumentFragment() 至關於一個空的容器, 是用來建立一個虛擬的節點對象,在這裏咱們要作的就是:在遍歷節點的同時對相應指令進行解析,解析完一個指令將其添加到createDocumentFragment中,解析完後再從新渲染頁面,這樣的好處就是減小頁面渲染dom的次數,詳細內容可參考文檔 createDocumentFragment()用法總結
  2. function compile (node, vm)
    compile()方法裏面咱們對每一個節點進行判斷,首先判斷節點是否包含有子節點,有的話繼續調用compile()方法進行解析。沒有的話就判斷節點類型,咱們主要是判斷element元素類型文本text元素類型,而後分別對這兩種類型進行解析。

完成了以上步驟後,咱們的代碼就能夠正常顯示在頁面上了, 可是,有一個問題,咱們頁面上綁定了data裏的屬性,可是在改變input框裏的數據的時候,相應的data裏面的數據沒有同步更新。因此,接下來咱們要對數據的更新進行劫持,經過Object.defineProperty()劫持data裏的對應屬性變化。 java

2. 實現Observer

要實現數據的雙向綁定,咱們須要經過Object.defineProperty()來實現數據劫持,監聽屬性的變化。 因此,接下來咱們先經過一個簡單的例子來了解Object.defineProperty()的工做原理。node

var obj ={};
var name="hello";
Object.defineProperty(obj,'name',{
    
    get:function(val) {//獲取屬性
        console.log('get方法被調用了');
        
        return name 
    },
    set:function(val) { //設置屬性 
        console.log('set方法被調用了');
        name=val  
    }
})
console.log(obj.name);
obj.name='hello world'
console.log(obj.name);
複製代碼

運行代碼,咱們能夠看到控制檯輸出:面試

從控制檯的輸出咱們能夠看出,咱們經過 Object.defineProperty( )設置了對象obj的name屬性,對其get和set進行重寫操做,顧名思義,get就是在讀取name屬性這個值觸發的函數,set就是在設置name屬性這個值觸發的函數,關於 Object.defineProperty()這裏就很少說了,具體能夠參考文檔 defineProperty()使用教程
因此,接下來咱們要作的是當咱們在輸入框輸入數據的時候,首先觸發 input 事件(或者 keyup、change 事件),在相應的事件處理程序中,咱們獲取輸入框的 value 並賦值給 vm 實例的 text 屬性。話很少說,上代碼。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>MVVMdemo</title>
</head>

<body>
    <div id="app">
        <input type="text" v-model="text">
        <div>{{text}}</div>
    </div>
</body>
<script type="text/javascript">
    var vm = new Vue({
        el: 'app',
        data: {
            text: 'hello world',
        }
    })

    function Vue(options) {
        this.data = options.data;
        var id = options.el;
        observe(this.data,this); //初始化的時候對data裏的全部屬性進行監聽
        var dom = nodeToFragment(document.getElementById(id), this) //DocumentFragment(文檔片斷)
        document.getElementById(id).appendChild(dom); //將處理好的DocumentFragment從新添加到Dom中
    }

    function nodeToFragment(node, vm) {
        var flag = document.createDocumentFragment();
        var child;
        while (child = node.firstChild) {
            compile(child, vm);
            flag.appendChild(child)
        }
        return flag
    }
    //解析節點
    function compile(node, vm) {
        var reg = /\{\{(.*)\}\}/;
        //判斷是否有子節點
        if (node.childNodes && node.childNodes.length) {
            node.childNodes.forEach(function (node) {
                compile(node, vm)
            })
        } else {
            //解析v-model
            if (node.nodeType === 1) {
                var attr = node.attributes;
                for (var i = 0; i < attr.length; i++) {
                    if (attr[i].nodeName == "v-model") {
                        var name = attr[i].nodeValue;
                        node.addEventListener('input',function(e){
                            vm[name]=e.target.value;
                        })
                        node.value= vm[name];//將data裏的值賦給node
                        node.removeAttribute('v-model'); //移除v-model屬性
                    }
                };
            }
            //解析{{}}
            if (node.nodeType === 3) {
                if (reg.test(node.nodeValue)) {
                    var name = RegExp.$1; // 獲取匹配到的字符串
                    name = name.trim();
                    node.nodeValue = vm[name]
                }
            }
        }
    }
    function defineReactive(obj,key,val) {
        Object.defineProperty(obj,key,{
            get:function() {
                return val;
            },
            set:function(newval) {
                if(newval === val) return;
                val = newval;
                console.log(val);//打印(監聽數據的修改)
                
            }
        
        })
    }
    //地遞歸遍歷全部data屬性
    function observe(obj,vm) {
        Object.keys(obj).forEach(function(key){
            defineReactive(vm,key,obj[key])
        })
    }
</script>

</html>
複製代碼

咱們在頁面初始化的時候,經過遞歸遍歷data全部子屬性,給每一個屬性添加一個監視器,在監聽到數據變化時候,就會觸發defineProperty( )裏的set方法,咱們能夠在控制檯輸出看到set方法裏監聽到屬性的變化。數組

從上圖咱們能夠看到,set方法觸發了,input裏text的屬性也變化了, 可是文本節點的內容並無同步變化,如何讓一樣綁定到 text 的文本節點也同步變化呢?因此,接下來咱們要實現一個以前咱們說的訂閱者Watcher,在set方法觸發時,接受屬性改變通知,更新視圖。

3. 實現Watcher

不少人看過網上的其餘實現MVVM實現的代碼,可是都說對Watcher訂閱者不是很瞭解,其實拋開代碼,Watcher實現的功能其實很簡單,就是當Vue實例化的時候,給每一個屬性注入一個訂閱者Watcher,方便在Object.defineProperty()數據劫持中監聽屬性的獲取(get方法),在Object.defineProperty()監聽到數據改變的時候(set方法),經過Watcher通知更新,因此簡單來講,Watcher就是起到一個橋樑的做用。咱們上面已經經過Object.defineProperty()監聽到數據的改變,接下來咱們經過實現Watcher 來完成雙向綁定的最後一步。瀏覽器

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>MVVMdemo</title>
</head>

<body>
    <div id="app">
        <input type="text" v-model="text">
        <div>{{text}}</div>
    </div>
</body>
<script type="text/javascript">

    function Vue(options) {
        this.data = options.data;
        var id = options.el;
        observe(this.data, this); //初始化的時候對data裏的全部屬性進行監聽
        var dom = nodeToFragment(document.getElementById(id), this) //DocumentFragment(文檔片斷)
        document.getElementById(id).appendChild(dom); //將處理好的DocumentFragment從新添加到Dom中
    }

    function nodeToFragment(node, vm) {
        var flag = document.createDocumentFragment();
        var child;
        while (child = node.firstChild) {
            compile(child, vm);
            flag.appendChild(child)
        }
        return flag
    }
    //解析節點
    function compile(node, vm) {
        var reg = /\{\{(.*)\}\}/;
        //判斷是否有子節點
        if (node.childNodes && node.childNodes.length) {
            node.childNodes.forEach(function (node) {
                compile(node, vm)
            })
        } else {
            //解析v-model
            if (node.nodeType === 1) {
                var attr = node.attributes;
                for (var i = 0; i < attr.length; i++) {
                    if (attr[i].nodeName == "v-model") {
                        var name = attr[i].nodeValue;
                        node.addEventListener('input', function (e) {
                            vm[name] = e.target.value;
                        });
                        node.value = vm[name];//將data裏的值賦給node
                        node.removeAttribute('v-model'); //移除v-model屬性
                    }
                };
                new Watcher(vm, node, name, 'input');//生成一個新的Watcher,標記爲input
            }
            //解析{{}}
            if (node.nodeType === 3) {
                if (reg.test(node.nodeValue)) {
                    var name = RegExp.$1; // 獲取匹配到的字符串
                    name = name.trim();
                    new Watcher(vm, node, name, 'text');//生成一個新的Watcher,標記爲文本text
                }
            }
        }
    }
    //地遞歸遍歷全部data屬性
    function observe(obj, vm) {
        Object.keys(obj).forEach(function (key) {
            defineReactive(vm, key, obj[key])
        })
    }
    function defineReactive(obj, key, val) {
        var dep = new Dep();
        Object.defineProperty(obj, key, {
            get: function () {
                // 添加訂閱者 watcher 到主題對象 Dep;
                if (Dep.target) dep.addSub(Dep.target);
                return val
            },
            set: function (newVal) {
                if (newVal === val) return
                val = newVal;
                // 做爲發佈者發出通知
                dep.notify();
            }
        });
    }
    //將全部初始化的生成的訂閱者都收集到一個數組中
    function Dep() {
        this.subs = []
    }
    Dep.prototype = {
        addSub: function (sub) {
            this.subs.push(sub)
        },
        notify: function () {
            this.subs.forEach(function (sub) {
                sub.update();
            })
        }
    }
    //訂閱者Watcher
    function Watcher(vm, node, name, nodeType) {
        Dep.target = this;
        this.name = name;
        this.node = node;
        this.vm = vm;
        this.nodeType = nodeType;
        this.update();
        Dep.target = null;
    }

    Watcher.prototype = {
        //執行對應的更新函數
        update: function () {
            this.get();
            if (this.nodeType == 'text') {
                this.node.nodeValue = this.value;
            }
            if (this.nodeType == 'input') {
                this.node.value = this.value;
            }
        },
        // 獲取 data 中的屬性值
        get: function () {
            this.value = this.vm[this.name]; // 觸發相應屬性的 get
        }
    }
</script>
<script type="text/javascript">
    var vm = new Vue({
        el: 'app',
        data: {
            text: 'hello world',
        }
    })
</script>
</html>
複製代碼

咱們在第二步的代碼基礎上,加了一個訂閱者Watcher和一個消息收集器Dep,接下來我就跟你們說說他們都作了什麼。 首先:bash

function Watcher(vm, node, name, nodeType) {
        Dep.target = this;
        this.name = name;
        this.node = node;
        this.vm = vm;
        this.nodeType = nodeType;
        this.update();
        Dep.target = null;
    }

    Watcher.prototype = {
        //執行對應的更新函數
        update: function () {
            this.get();
            if (this.nodeType == 'text') {
                this.node.nodeValue = this.value;
            }
            if (this.nodeType == 'input') {
                this.node.value = this.value;
            }
        },
        // 獲取 data 中的屬性值
        get: function () {
            this.value = this.vm[this.name]; // 觸發相應屬性的 get
        }
    }
複製代碼

Watcher()方法接收的參數爲vm實例,node節點對象,name傳入的節點類型的名稱,nodeType節點類型。
首先,將本身賦給了一個全局變量 Dep.target;

其次,執行了 update 方法,進而執行了 get 方法,get 的方法讀取了 vm 的訪問器屬性,從而觸發了訪問器屬性的 get 方法,get 方法中將該 watcher 添加到了對應訪問器屬性的 dep 中;

再次,獲取屬性的值,而後更新視圖。

最後,將 Dep.target 設爲空。由於它是全局變量,也是 watcher 與 dep 關聯的惟一橋樑,任什麼時候刻都必須保證 Dep.target 只有一個值。

在實例化的時候,咱們針對每一個屬性都添加一個Watcher()訂閱者,在observe()的監聽屬性賦值的時候,將每一個屬性綁定的訂閱者存儲在Dep數組中,在set方法觸發的時候,調用dep.notify()方法通知Watcher()更新數據,最後實現了視圖的更新。

4. 結語

以上就是Vue雙向綁定的基本實現原理及代碼,固然,這只是基本的實現代碼,簡單直觀的展示給你們看,若是你們想更深刻了解的話,推薦你們去閱讀這篇文章 vue的雙向綁定原理及實現

好啦,以上就是本次的分享,但願對你們理解Vue雙向綁定的理解有所幫助,也但願你們有什麼不懂或者建議,能夠留言互動。

相關文章
相關標籤/搜索