1. 程式人生 > >關於vue雙向繫結的簡單實現

關於vue雙向繫結的簡單實現

研究了一下vue雙向繫結的原理,所以簡單記錄一下,以下例子只是簡單實現,還請大家不要吐槽~

之前也瞭解過vue是通過資料劫持+訂閱釋出模式來實現MVVM的雙向繫結的,但一直沒仔細研究,這次深入學習了一下,藉此機會分享給大家。

首先先將流程圖給大家看一下
雙向繫結流程圖

我雖然參考的是這篇文章,下面的程式碼也是在閱讀幾遍後仿造的,自己只是簡單添加了個遞迴實現所有dom子節點的雙向繫結,以及添加了一些理解,但真正讓我瞭然於心,讓我可以獨立寫出2遍完整邏輯的其實是這張圖,所以個人認為這張流程圖才是最重要的,而我參考的這篇文章的作者也是參考這幅圖的原作者的。

站在閱讀和理解MVVM的完整邏輯的話,推薦大家看第一篇,但是第二篇原文章的圖文更能說明一些問題

如果大家看了我的解釋也能夠完全理解的話,那就更好啦啦啦啦啦~哈哈

好,下面我會從2個角度開始講解,先上單向繫結,再由單向繫結過渡到雙向繫結;

首先,先為大家解釋一下單向繫結model => view層的邏輯
1、劫持dom結構;
2、建立文件碎片,利用文件碎片重構dom結構;
3、在重構的過程中解析dom結構實現MVVM建構函式例項化後的資料初始化檢視資料;
4、利用判斷dom一級子元素是否依然有子元素從而進行所有子元素的單向繫結;
5、將文件碎片新增至根節點中.

這就是我總結的關於單向繫結的邏輯了,下面利用程式碼跟大家解釋

//dom結構
<div id="app">
   <input type="text" v-model="msg">
   <p>{{msg}}</p>
   <ul>
     <li>1</li>
     <li>{{msg}}</li>
     <li>{{test}}</li>
   </ul>
</div>

//one-way-binding.js
    //判斷每個dom節點是否擁有子節點,若有則返回該節點
    function isChild(node){
        //這裡使用childNodes可以讀取text文字節點,所以不用children
        if(node.childNodes.length ===0){
            return false;
        }
        else{
            return node;
        }
    }

    //利用文件碎片劫持dom結構及資料,進而進行dom的重構
    function nodeToFragment(node,vm){
        var frag = document.createDocumentFragment();
        var child;
        while(child = node.firstChild){
            //一級dom節點資料繫結
            compile(child,vm);
            //判斷每個一級dom節點是否有二級節點,若有則遞迴處理文件碎片
            if(isChild(child)){
                //遞迴實現二級dom節點的重構
                nodeToFragment(isChild(child),vm);
            }
            frag.appendChild(child);
        }
        //將文件碎片新增至對應node中,最後為id為app的元素下
        node.appendChild(frag);
    }

    //初始化繫結資料
    function compile(node,vm){
        //node節點為元素節點時
        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;
                    //將data下對應屬性名的值賦值給當前節點值
                    //這裡因為node是input標籤所以值為node.value
                    node.value = vm.data[name];
                    //最後標籤中的v-model屬性也可以功成身退了,刪除它
                    node.removeAttribute(attr[i].nodeName);
                }
            }
        }

        //node節點為text文字節點#text時
        if(node.nodeType === 3){
            var reg = /\{\{(.*)\}\}/;
            if(reg.test(node.nodeValue.trim())){
                //將正則匹配到的{{}}中的字串賦值給name
                var name = RegExp.$1;
                //利用name對應賦值相應的節點值
                node.nodeValue = vm.data[name];
            }
        }
    }

    //MVVM建構函式,這裡我就寫成Vue了
    function Vue(options){
        this.id = options.el;
        this.data = options.data;
        //將根節點與例項化後的物件作為引數傳入
        nodeToFragment(document.getElementById(this.id),this);
    }
    //例項化
    var vm = new Vue({
        el:'app',
        data:{
            msg:'hello,two-ways-binding',
            test:'test key'
        }
    })

上述就是簡單的單向綁定了,整個邏輯實際上非常簡單,我再來跟大家說明一下

1、為了令model層的資料可以繫結到view層的dom上,所以我們想了一個辦法來替換dom中的一些元素值,而明顯一個個替換時不可取的,因為大量的dom操作會降低程式的執行效率,你想想,每次dom操作可都是一次對dom整體的遍歷過程~,所以我們覺得采用文件碎片的形式,將dom一次全部劫持,在記憶體中執行全部資料繫結操作,最後只進行一次dom操作,即新增子節點來解決這個頻繁操作dom的問題,你也可以理解為中間的一層存在於記憶體中的虛擬dom;

2、那麼既然如此,我們就要首先劫持所有dom節點,這裡我們利用nodeToFragment函式來劫持;

3、在每次劫持對應dom節點的過程中,我們也會相對應的實現對該dom元素的資料繫結,以求在最後直接新增到為根節點的子元素即可,這個過程我們就在nodeToFragment函式中插入了compile函式來初始化繫結,並且新增遞迴函式實現所有子元素的初始繫結;

4、在compile函式中我們新增的資料又從何而來呢?對,正是因為這點,所以我們建立MVVM的建構函式Vue來實現資料支援,並實現在例項化時就執行nodeToFragment同時重構dom和實現初始化繫結compile;

5、好了,單向繫結就是這麼簡單,4個函式即可Vue => nodeToFragment => compile => isChild。

完成圖如下
單向繫結

好了,再回過來看看整體的流程圖,我們已經實現了這一塊了
單向繫結

接下來,休息下,大家準備開始流程圖後面的雙向繫結,ok,還是按照單向繫結的順序,先跟大家講明實現邏輯;

1、建立資料監聽者observer去監聽view層資料的變化;(利用Object.defineProperty劫持所有要用到的資料)

2、當view層資料變化後,通過通知者Dep通知訂閱者去實現資料的更新;(通知後,遍歷所有用到資料的訂閱者更新資料)

3、訂閱者watcher接收到view層資料變更後,重新對變化的資料進行賦值,改變model層,從而改變所有view層用到過該資料的地方。(更新資料,並改變view層所有用到該資料的節點值)

上面是實現邏輯,下面將通過具體程式碼告訴大家每一步的做法,由於雙向繫結中訂閱者會涉及初始化繫結的過程,所以程式碼量較多,我會在大更改處用——為大家框出來

    //判斷每個dom節點是否擁有子節點,若有則返回該節點
    function isChild(node){
        if(node.childNodes.length ===0){
            return false;
        }
        else{
            return node;
        }
    }

    //利用文件碎片劫持dom結構及資料,進而進行dom的重構
    function nodeToFragment(node,vm){
        var frag = document.createDocumentFragment();
        var child;
        while(child = node.firstChild){
            //一級dom節點資料繫結
            compile(child,vm);
            //判斷每個一級dom節點是否有二級節點,若有則遞迴處理文件碎片
            if(isChild(child)){
                nodeToFragment(isChild(child),vm);
            }
            frag.appendChild(child);
        }
        node.appendChild(frag);
    }

    //初始化繫結資料
    function compile(node,vm){
        //node節點為元素節點時
        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;
                    //特殊處理input標籤
                    //------------------------
                    if(node.nodeName === 'INPUT'){
                        node.addEventListener('keyup',function(e){
                            vm[name] = e.target.value;
                        })
                    }
                    //由於資料已經由data劫持至vm下,所以直接賦值vm[name]即可觸發getter訪問器
                    node.value = vm[name];
                    //-------------------------
                    node.removeAttribute(attr[i].nodeName);
                }
            }
        }

        //node節點為text文字節點時
        if(node.nodeType === 3){
            var reg = /\{\{(.*)\}\}/;
            if(reg.test(node.nodeValue.trim())){
                var name = RegExp.$1;
                    //node.nodeValue = vm[name];
                    //----------------------
                    //為每個節點建立訂閱者,通過訂閱者watcher初始化及更新檢視資料
                    new watcher(vm,node,name);
                    //-----------------------
                }
            }
        }
    //----------------------------------------------------------------
    //訂閱者(為每個節點的資料建立watcher佇列,每次接受更改資料需求後,利用劫持資料執行對應節點的資料更新)
    function watcher(vm,node,name){
        //將每個掛載了資料的dom節點新增到通知者列表,要保證每次建立watcher時只有一個新增目標,否則後續會因為watcher是全域性而被覆蓋,所以每次要清空目標

        Dep.target = this;
        this.vm = vm;
        this.node = node;
        this.name = name;
        //執行update的時候會呼叫監聽者劫持的getter事件,從而新增到watcher佇列,因為update中有訪問this.vm[this.name]
        this.update();
        //為保證只有一個全域性watcher,新增到佇列後,清空全域性watcher
        Dep.target = null;
    }

    watcher.prototype = {
        update(){
            this.get();
            //input標籤特殊處理化
            if(this.node.nodeName === 'INPUT'){
                this.node.value = this.value;
            }
            else{
                this.node.nodeValue = this.value;
            }
        },
        get(){
            //這裡呼叫了資料劫持的getter
            this.value = this.vm[this.name];
        }
    };

    //通知者(將監聽者的更改資訊需求傳送給訂閱者,告訴訂閱者哪些資料需要更改)
    function Dep(){
        this.subs = [];
    }

    Dep.prototype = {
        addSub(watcher){
            //新增用到資料的節點進入watcher佇列
            this.subs.push(watcher);
        },
        notify(){
            //遍歷watcher佇列,令相應資料節點重新更新view層資料,model => view
            this.subs.forEach(function(watcher){
                watcher.update();
            })
        }
    };

    //監聽者(利用setter監聽view => model的資料變化,發出通知更改model資料後再從model => view更新檢視所有用到該資料的地方)
    function observer(data,vm){
        //遍歷劫持data下所有屬性
        Object.keys(data).forEach(function(key){
            defineReactive(vm,key,data[key]);
        })
    }

    function defineReactive(vm,key,val){
        //新建通知者
        var dep = new Dep();
        //靈活利用setter與getter訪問器
        Object.defineProperty(vm,key,{
            get(){
                //初始化資料更新時將每個資料的watcher新增至佇列棧中
                if(Dep.target) dep.addSub(Dep.target);
                return val;
            },
            set(newVal){
                if(val === newVal) return ;
                //初始化後,文件碎片中的虛擬dom已與model層資料繫結起來了
                val = newVal;
                //同步更新model中data屬性下的資料
                vm.data[key] = val;
                //資料有改動時向通知者傳送通知
                dep.notify();
            }
        })
    }
    //---------------------------------------------------------------
    function Vue(options){
        this.id = options.el;
        this.data = options.data;
        observer(this.data,this);
        nodeToFragment(document.getElementById(this.id),this);
    }

    var vm = new Vue({
        el:'app',
        data:{
            msg:'hello,two-ways-binding',
            test:'test key'
        }
    })

好的,到這裡雙向繫結的講解也就結束了,程式碼量確實有點多,但是我們看到其實邏輯在你熟悉後並不複雜,特別是參照了上文的流程圖後,其實就是:

1、通過observer劫持所有model層資料到vue下,並在劫持時靈活運用getter與setter訪問器屬性來在虛擬dom初始化資料繫結時,利用此時的get方法繫結初始化資料進入通知者佇列,後續初始化完成後,在view層資料發生變化時,利用set方法及時利用通知者發出通知;

2、在dep通知者接收到有一處dom節點資料更改的通知時,遍歷watcher佇列及告訴watcher訂閱者,view層資料有所變動model層已經相應改變,你要重新執行update將model層的資料更新到view層所有用到該資料的地方(比如我們利用input實現的雙向繫結就不止一個dom節點內使用了,而是多個,所以必須整體遍歷修改)。

3、這是一個model => view => model =>view的過程,真正的邏輯順序為model => view,view => model,model => view,反覆的過程~
貼上結果圖
初始未改動view層資料圖
未修改view層資料

修改view層資料後圖
修改view層資料後

最後大家再看一次流程圖,看看整個邏輯是不是跟流程圖一模一樣,通過流程圖就可以回憶起這個邏輯過程,寫多2遍就可以記住!

雙向繫結流程圖

以上只是通過簡單實現來告訴大家vue的資料劫持+訂閱釋出模式這個雙向繫結的原理,其中有很多細節上的不足可能未作處理,還請見諒~

說出來你可能不信,利用部落格講解一遍雙向繫結原理比我自己重寫一遍累太多了吧~~~~