5分鐘教你實現Vue雙向繫結

OneLine發表於2019-04-20

前言

很多人在面試過程中都有問到Vue雙向繫結的原理和實現,這是一個老生常談的面試題了,雖然網上也有很多實現雙向繫結的文章,但是我看後覺得對於大多數前端小白來說,不是很容易理解,所以,這篇文章我就用最簡單的程式碼教大家怎麼實現一個Vue的雙向繫結。

雙向繫結的原理

用過Vue框架的都知道,頁面在初始化的時候,我們可以把data裡的屬性渲染到頁面上,改動頁面上的資料時,data裡的屬性也會相應的更新,這就是我們所說的雙向繫結,所以,簡單來說,我們要實現一個雙向繫結要實現以下3點操作:

  1. 首先需要在Vue例項化的時候,解析程式碼中v-modle指令和{{}}指令,然後把data裡的屬性繫結到相應的指令上,所以我們要實現一個解析器Compile,這是第一點;
  2. 接著我們在改變頁面的屬性的時候,要知道哪個屬性改變了,這時候我們需要用到Object.defineProperty中的gettersetter方法對屬性進行劫持,這裡我們要實現一個監視器Observer,這是二點;
  3. 我們在知道具體哪個屬性改變後,要執行相應的函式,更新檢視,這裡我們要實現一個訊息訂閱,在頁面初始化的時候訂閱每個屬性,並且在Object.defineProperty資料劫持的時候接收屬性改變通知,更新檢視,所以我們要實現一個訂閱者Watcher,這是第三點。

1. 實現Compile

首先,我們從最基本的解析指令開始,話不多說,先上程式碼:

5分鐘教你實現Vue雙向繫結
我們在寫Vue的時候,用了v-model{{}}指令,但是頁面渲染的時候,我們在瀏覽器看到的節點是這樣的。

5分鐘教你實現Vue雙向繫結
我們從上面的圖片可以看到,程式碼裡寫的指令都消失了,但是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>
複製程式碼

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

  1. document.createDocumentFragment()
    document.createDocumentFragment() 相當於一個空的容器, 是用來建立一個虛擬的節點物件,在這裡我們要做的就是:在遍歷節點的同時對相應指令進行解析,解析完一個指令將其新增到createDocumentFragment中,解析完後再重新渲染頁面,這樣的好處就是減少頁面渲染dom的次數,詳細內容可參考文件 createDocumentFragment()用法總結
  2. function compile (node, vm)
    compile()方法裡面我們對每個節點進行判斷,首先判斷節點是否包含有子節點,有的話繼續呼叫compile()方法進行解析。沒有的話就判斷節點型別,我們主要是判斷element元素型別文字text元素型別,然後分別對這兩種型別進行解析。

完成了以上步驟後,我們的程式碼就可以正常顯示在頁面上了, 但是,有一個問題,我們頁面上繫結了data裡的屬性,但是在改變input框裡的資料的時候,相應的data裡面的資料沒有同步更新。所以,接下來我們要對資料的更新進行劫持,通過Object.defineProperty()劫持data裡的對應屬性變化。

5分鐘教你實現Vue雙向繫結

2. 實現Observer

要實現資料的雙向繫結,我們需要通過Object.defineProperty()來實現資料劫持,監聽屬性的變化。 所以,接下來我們先通過一個簡單的例子來了解Object.defineProperty()的工作原理。

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);
複製程式碼

執行程式碼,我們可以看到控制檯輸出:

5分鐘教你實現Vue雙向繫結
從控制檯的輸出我們可以看出,我們通過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方法裡監聽到屬性的變化。

5分鐘教你實現Vue雙向繫結
從上圖我們可以看到,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,接下來我就跟大家說說他們都做了什麼。 首先:

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雙向繫結的理解有所幫助,也希望大家有什麼不懂或者建議,可以留言互動。

相關文章