1. 程式人生 > >vue響應式原理(資料雙向繫結的原理)

vue響應式原理(資料雙向繫結的原理)

先來了解一下幾個相關概念

1、漸進式框架

下面是摘自知乎的一個解答(個人認為講述比較好的回答):

在我看來,漸進式代表的含義是:主張最少
每個框架都不可避免會有自己的一些特點,從而會對使用者有一定的要求,這些要求就是主張,主張有強有弱,它的強勢程度會影響在業務開發中的使用方式。
比如說,Angular,它兩個版本都是強主張的,如果你用它,必須接受以下東西:
- 必須使用它的模組機制- 必須使用它的依賴注入- 必須使用它的特殊形式定義元件(這一點每個檢視框架都有,難以避免)

所以Angular是帶有比較強的排它性的,如果你的應用不是從頭開始,而是要不斷考慮是否跟其他東西整合,這些主張會帶來一些困擾。

比如React,它也有一定程度的主張,它的主張主要是函數語言程式設計的理念,比如說,你需要知道什麼是副作用,什麼是純函式,如何隔離副作用。它的侵入性看似沒有Angular那麼強,主要因為它是軟性侵入。

Vue可能有些方面是不如React,不如Angular,但它是漸進的,沒有強主張,你可以在原有大系統的上面,把一兩個元件改用它實現,當jQuery用;也可以整個用它全家桶開發,當Angular用;還可以用它的檢視,搭配你自己設計的整個下層用。你可以在底層資料邏輯的地方用OO和設計模式的那套理念,也可以函式式,都可以,它只是個輕量檢視而已,只做了自己該做的事,沒有做不該做的事,僅此而已。

漸進式的含義,我的理解是:沒有多做職責之外的事

2、MVC模式

MVC的全稱是Model-View-Controller,模型-檢視-控制器,整個結構分成三層

● 最上面一層,檢視層(View):使用者介面(UI)

● 最底層,是核心的“資料層”:儲存資料

● 中間層,控制層(Controller):處理業務邏輯,負責根據使用者從“檢視層”輸入的指令,選取“資料層”的資料,然後對其進行相應的操作,產生最終的結果

各部分的通訊方式如下:

A、View傳送指令到Controller

B、Controller完成業務邏輯後,要求Model改變狀態

C、Model將新的資料傳送到View,使用者得到反饋

所有通訊都是單向的

栗子:

以計算機的計算器為例,解釋一下MVC模式(不一定使用這種模式編寫):

外部的按鈕和最上面的顯示條就是View(檢視層);需要運算的數字就是Model(資料層);執行+ - * /等內部運算步驟就是Controller(控制層)。每一層都執行不同的功能。

3、MVP

MVP模式將Controller更名為Presenter,同時改變了通訊方向

A、各部分的通訊都是雙向的

B、View與Model不發生聯絡,都通過Presenter傳遞

C、View不部署任何業務邏輯,成為“被動檢視”,而所有業務邏輯都部署在Presenter

4、MVVM模式

MVVM模式將Presenter更名為ViewModel(對應MVC中的C-controller),基本上與MVP模式一致。唯一區別MVVM採用雙向資料繫結,View的變動自動反應在ViewModel上。

● M(model):模型---javascript object,代表真實情況的內容(一個面向物件的方法)、或表示內容(以資料為中心的方法)的資料訪問層

● V(view):檢視---使用者介面(UI)

● Viewmodel:在vue中指vue例項物件,是一個公開公共屬性和命令的抽象的view;是一個轉值器,負責轉換Model中的資料物件,來讓物件變得更容易管理和使用。

View的變化會自動更新到ViewModel,ViewModel的變化也會自動同步到View上顯示。這種自動同步是因為ViewModel中的屬性實現了Observer,當屬性變更時都能觸發對應的操作。

6、資料雙向繫結

所謂的雙向繫結,就是view的變化能反映到ViewModel上,ViewModel的變化能同步到view上

vue的定義

● vue是一套用於構建使用者介面的漸進式框架

vue是一款基於MVVM方式的輕量級的框架

vue是一款基於資料驅動、元件化思想的框架

vue被設計為可以自底向上、逐層應用的框架

● vue的核心庫只關注檢視層,易於上手,還便於與第三方庫或既有專案整合

● 當與現代化的工具鏈以及各種支援類庫結合使用時,vue也完全能夠為複雜的單頁應用提供驅動

資料驅動:Vue.js 一個核心思想是資料驅動。所謂資料驅動是指檢視是由資料驅動生成的,對檢視的修改不會直接操作 DOM,而是通過修改資料。相比傳統的前端開發,如使用 jQuery 等前端庫直接修改 DOM大大簡化了程式碼量,特別是當互動複雜的時候,只關心資料的修改會讓程式碼的邏輯變的非常清晰,因為 DOM 變成了資料的對映,我們所有的邏輯都是對資料的修改,而不用碰觸 DOM,這樣的程式碼非常利於維護

在MVVM中,M(model)---代表JavaScript  Objects,V(view)---DOM也就是UI,VM(ViewModel)----代表Vue例項物件(在該物件中有Directives和DOM Listeners)

在vue.js裡面只需要改變資料,Vue.js通過Directives指令對DOM做封裝,當資料發生變化,會通知指令修改對應的DOM,資料驅動DOM的變化,DOM是資料的一種自然的對映。vue.js還會對View操作做一些監聽(DOM Listener),當我們修改檢視的時候,vue.js監聽到這些變化,從而改變資料。這樣就形成了資料的雙向繫結。

Vue實現資料雙向繫結的原理

如右圖所示,new Vue一個例項物件a,其中有一個屬性a.b,那麼在例項化的過程中,通過Object.defineProperty()會對a.b新增getter和setter,同時Vue.js會對模板做編譯,解析生成一個指令物件(這裡是v-text指令),每個指令物件都會關聯一個Watcher,對a.b求值的時候,就會觸發它的getter,當修改a.b的值的時候,就會觸發它的setter,同時會通知被關聯的Watcher,然後Watcher就會再次對a.b求值,計算對比新舊值,當值改變了,Watcher就會通知到指令,呼叫指令的update()方法,由於指令是對DOM的封裝,所以就會呼叫DOM的原生方法去更新檢視,這樣就完成了資料改變到檢視更新的一個自動過程

大致上是使用資料劫持和訂閱釋出實現雙向繫結。通過例項化一個Vue物件的時候,對其資料屬性遍歷,通過Object.defineProperty()給資料物件新增setter  getter,並對模板做編譯生成指令物件,每個指令物件繫結一個watcher物件,然後對資料賦值的時候就會觸發setter,這時候相應的watcher對其再次求值,如果值確實發生變化了,就會通知相應的指令,呼叫指令的update方法,由於指令是對DOM的封裝,這時候會呼叫DOM的原生方法對DOM做更新,這就實現了資料驅動DOM的變化。同時vue還會對DOM做事件監聽,如果DOM發生變化,vue監聽到,就會修改相應的data

實現資料雙向繫結的方法

A、釋出者-訂閱者模式(backbone.js)

思路:使用自定義的data屬性,在HTML程式碼中指明繫結。所有繫結起來的javascript物件以及DOM元素都將訂閱一個釋出者物件。任何時候如果javascript物件或者一個HTML輸入欄位被偵測到發生變化,將代理事件變成釋出者-訂閱者模式,這會反過來變化廣播,並傳播到所有繫結的javascript物件以及DOM元素上。

B、髒值檢查(angular.js):dirty check   詳細講解連結

angular.js是通過髒值檢測的方式,對比資料是否有變更,從而決定是否更新檢視。最簡單的方式就是通過setInterval()定時輪詢檢測資料變動。angular.js只有在指定的事件觸發時,進入髒值檢測,大致如下:

● DOM事件,譬如使用者輸入文字,點選按鈕等(ng-click)

● XHR響應事件($http)

● 瀏覽器location變更事件($location)

● Timer事件($timeout,$interval)

● 執行$digest()或$apply()

C、資料劫持結合釋出者-訂閱者模式(vue.js)【vue data是如何實現的??】

vue.js採用資料劫持結合釋出者-訂閱者的方式,通過Object.defineProperty()來劫持各個屬性的setter,getter,在資料變動時,釋出訊息給訂閱者,觸發相應的監聽回撥。

具體的來講,Vue.js通過Directives指令去對DOM做封裝,當資料發生變化,會通知指令去修改對應的DOM,資料驅動DOM的變化。vue.js還會對操作做一些監聽(DOM Listener),當我們修改檢視的時候,vue.js監聽到這些變化,從而改變資料。這樣就形成了資料的雙向繫結。

具體步驟如下:

● 首先,需要對observe的資料物件進行遞迴遍歷,包括子屬性物件的屬性,都加上setter  getter。這樣的話,給這個物件的某個屬性賦值,就會觸發setter,那麼就能監聽到資料變化。(其實是通過Object.defineProperty()實現監聽資料變化的)

● 然後,需要compile解析模板指令,將模板中的變數替換成資料,接著初始化渲染頁面檢視,並將每個指令對應的節點繫結更新函式,新增監聽資料的訂閱者。一旦資料有變動,訂閱者收到通知,就會更新檢視

● 接著,Watcher訂閱者是Observer和Compile之間通訊的橋樑,主要負責:

         1)在自身例項化時,往屬性訂閱器(Dep)裡面新增自己

         2)自身必須有一個update()方法

         3)待屬性變動,dep.notice()通知時,就呼叫自身的update()方法,並觸發Compile中繫結的回撥

● 最後,viewmodel(vue例項物件)作為資料繫結的入口,整合Observer、Compile、Watcher三者,通過Observer來監聽自己的model資料變化,通過Compile來解析編譯模板指令,最終利用Watcher搭起Observer和Compile之間的通訊橋樑,達到資料變化 (ViewModel)-》檢視更新(view);檢視變化(view)-》資料(ViewModel)變更的雙向繫結效果。

例1:利用Object.defineProperty()實現一個小栗子(G:\有用的\練習\a.js)

var obj = {};
Object.defineProperty(obj, 'hello', { //obj---變數名,hello---變數的屬性名(任意取名)
  get: function() {
    console.log('get val:'+ value);    //value-(任意取名)--屬性“hello”的值
    return value;
   },
  set: function(newVal) {
    value = newVal;
    console.log('set val:'+ value);
  }
});

obj.hello='111';
obj.hello;

結果:

set val:111
get val:111

如果去掉下面這句話

obj.hello='111';

則控制檯報錯:

ReferenceError: value is not defined

可見Object.defineProperty()監控對資料的操作,可以自動觸發資料同步

例2、利用Object.defineProperty()實現簡單的雙向繫結(G:\有用的\練習\a.html

<!DOCTYPE html>
<head>
  <title>測試Object.defineProperty()</title>
</head>
<body>
   <div id="app">
      <input type="text" id="a">
      <span id="b"></span>
   </div>
 
   <script type="text/javascript">
        var obj = {};
        Object.defineProperty(obj, 'hello', {
            get: function() {
              console.log('get val:'+ val);
              return val;
            },
            set: function(newVal) {
              val = newVal;
              console.log('set val:'+ val);
              document.getElementById('a').value = val;
              document.getElementById('b').innerHTML = val;
            }
        });
        document.addEventListener('keyup', function(e) {//觸發事件的時機,從而執行相應的操作
           obj.hello = e.target.value;
        });
    </script>
 </body>
</html>

效果如下:

上面操作直接使用了DOM操作改變了文字節點的值,而且是在知道id的情況下,使用document.getELementById()獲取到響應的文字節點,然後修改文字節點的值。

封裝成一個框架,肯定不能使用這種做法。所以需要一個可以解析DOM並且能修改DOM中相應變數的模組。

例3、實現簡單CompileG:\有用的\練習\b.html

A、首先,獲取文字中真實的DOM節點

B、然後,分析節點的型別

C、最後,根據節點的型別做相應的處理

在例2中,多次操作DOM節點,為了提高效能和效率,將進行下面操作:

A、將所有的節點轉換成文件碎片(fragment)進行編譯操作

B、解析操作完成後,在將文件碎片(fragment)新增到原來的真實DOM節點

<!DOCTYPE html>
<head></head>
<body>
    <div id="app">
        <input type="text" id="a" v-model="text">  <!-- v-model指令實現文字輸入與應用狀態之間的雙向繫結 -->
        {{text}}     <!--data中的某個屬性名-->
    </div>


   <script type="text/javascript">


        function Compile(node, vm) { //node---DOM中的某個節點,vm---用Vue()例項化的物件
            if(node) {//判斷該節點是否存在,存在則將節點轉換成文件碎片
                this.$frag = this.nodeToFragment(node, vm);
                return this.$frag;//返回文件碎片
            }
        }


        Compile.prototype = {
            nodeToFragment: function(node, vm) {//將node節點的所有的子節點都轉換成fragment
                var self = this;
                var frag = document.createDocumentFragment();
                var child;
     
                while(child = node.firstChild) {
                     self.compileElement(child, vm);
                     frag.append(child); //  // 將所有子節點新增到fragment中,child是指向元素首個子節點的引用。將child引用指向的物件append到父物件的末尾,原來child引用的物件就跳到了frag物件的末尾,而child就指向了本來是排在第二個的元素物件。如此迴圈下去,連結就逐個往後跳了
                }
                return frag;
            },
            compileElement: function(node, vm) {//分析節點的型別,並處理節點中相應的變數的值
                var reg = /\{\{(.*)\}\}/;//.匹配除了\b之外的任何單個字元,*表示0或多個,形如{{d}}
         
                //節點型別為元素
                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; // 獲取v-model繫結的屬性名
                            node.addEventListener('input', function(e) {
                                // 給相應的data屬性賦值,進而觸發該屬性的set方法
                                vm.data[name]= e.target.value;
                            });
                            node.value = vm.data[name]; // 將data的值賦給該node
                            node.removeAttribute('v-model');
                        }
                    }
                }
                //節點型別為text
                if(node.nodeType === 3) {
                    if(reg.test(node.nodeValue)) {// 獲取v-model繫結的屬性名
                        var name = RegExp.$1; // 獲取匹配到的字串
                        name = name.trim();
                        node.nodeValue = vm.data[name]; // 將data的值賦給該node
                       // new Watcher(vm, node, name);
                    }
                }
            },
        }


        // 例項化物件的建構函式
        function Vue(options) {
            this.data = options.data;
            var data = this.data;   //vue例項物件的data物件屬性
            var id = options.el;   //掛載元素的id
            var dom =new Compile(document.getElementById(id),this);
            // 編譯完成後,將dom返回到app中
            document.getElementById(id).appendChild(dom);
        }


        var vm = new Vue({ //利用建構函式Vue(),例項化一個物件vm
            el: 'app',     //el---掛載元素,app---表示DOM中的某個節點的id
            data: {        //data---進行互動的資料
                text: 'hello world'  //屬性text名與v-model繫結的屬性名一樣
            }
        });
   </script>
 </body>
</html>

到這我們獲取了文字中真實的DOM節點,然後分析節點的型別,並能處理節點中相應的變數,如上面的{{text}},最後渲染到頁面中。最後,需要和雙向繫結聯絡起來,實現{{text}}響應式的資料繫結

例4、實現簡單observeG:\有用的\練習\c.html

簡單的observe定義如下

需要監控data的屬性值,物件的某個屬性被賦值了,就會觸發setter,這樣就能監聽到資料變化。

需要將vm.data[name]屬性改為vm[name]

完整程式碼如下:

<!DOCTYPE html>
<head></head>
<body>
    <div id="app">
        <input type="text" id="a" v-model="text">
        {{text}}
    </div>
    <script type="text/javascript">
      function Compile(node, vm) {
            if(node) {
                this.$frag = this.nodeToFragment(node, vm);
                return this.$frag;
            }
        }

        Compile.prototype = {
            nodeToFragment: function(node, vm) {
                var self = this;
                var frag = document.createDocumentFragment();
                var child;
     
                while(child = node.firstChild) {
                     self.compileElement(child, vm);
                     frag.append(child); // 將所有子節點新增到fragment中
                }
                return frag;
            },
            compileElement: function(node, vm) {
                var reg = /\{\{(.*)\}\}/;
         
                //節點型別為元素
                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; // 獲取v-model繫結的屬性名
                            node.addEventListener('input', function(e) {
                                // 給相應的data屬性賦值,進而觸發該屬性的set方法
                                vm[name]= e.target.value;
                            });
                            node.value = vm[name]; // 將data的值賦給該node
                            node.removeAttribute('v-model');
                        }
                    }
                }
                //節點型別為text
                if(node.nodeType === 3) {
                    if(reg.test(node.nodeValue)) {
                        var name = RegExp.$1; // 獲取匹配到的字串
                        name = name.trim();
                        node.nodeValue = vm[name]; // 將data的值賦給該node
                       // new Watcher(vm, node, 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);
                }
            });
        }

        function observe(obj, vm) {
            Object.keys(obj).forEach(function(key) {  //---監聽data的每一個屬性
                defineReactive(vm, key, obj[key]);   
            });
        }

       function Vue(options) {
            this.data = options.data;
            var data = this.data;
            observe(data, this);
            var id = options.el;
            var dom =new Compile(document.getElementById(id),this);
            // 編譯完成後,將dom返回到app中
            document.getElementById(id).appendChild(dom);
        }

        var vm = new Vue({
            el: 'app',
            data: {
                text: 'hello world'
            }
        });
     </script>
 </body>
</html>

顯示效果如下

雖然set方法觸發了,但是文字節點{{text}}的內容沒有變化,要讓繫結的文字節點同步變化,就需要引入訂閱者-釋出者模式。

例5、訂閱釋出模式

訂閱釋出模式(又稱觀察者模式),定義了一種一對多的關係,讓多個觀察者同時監聽某一個主題物件,這個主題物件的狀態發生改變時就會通知所有觀察者物件。

釋出者發出通知=》主題物件收到通知,並推送給訂閱者=》訂閱者執行相應操作。

A、首先,要有一個收集訂閱者的容器,定義一個Dep作為主題物件。

B、然後,定義訂閱者Watcher

C、新增訂閱者Watcher到主題物件Dep,釋出者發出通知到屬性監聽裡面

D、最後,需要訂閱的地方

至此,用DOM操作,實現雙向繫結的操作的完整程式碼如下

<!DOCTYPE html>
<head></head>
<body>
    <div id="app">
        <input type="text" id="a" v-model="text">
        {{text}}
    </div>
    <script type="text/javascript">
      function Compile(node, vm) {
            if(node) {
                this.$frag = this.nodeToFragment(node, vm);
                return this.$frag;
            }
        }

        Compile.prototype = {
            nodeToFragment: function(node, vm) {
                var self = this;
                var frag = document.createDocumentFragment();
                var child;
       
                while(child = node.firstChild) {
                    self.compileElement(child, vm);
                    frag.append(child); // 將所有子節點新增到fragment中
                }
                return frag;
            },
            compileElement: function(node, vm) {
                var reg = /\{\{(.*)\}\}/;
     
                //節點型別為元素
                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; // 獲取v-model繫結的屬性名
                            node.addEventListener('input', function(e) {
                                // 給相應的data屬性賦值,進而觸發該屬性的set方法
                                vm[name]= e.target.value;
                            });
                            // node.value = vm[name]; // 將data的值賦給該node
                            new Watcher(vm, node, name, 'value');
                        }
                    }
                }
                //節點型別為text
                if(node.nodeType === 3) {
                    if(reg.test(node.nodeValue)) {
                        var name = RegExp.$1; // 獲取匹配到的字串
                        name = name.trim();
                        // node.nodeValue = vm[name]; // 將data的值賦給該node
                        new Watcher(vm, node, name, 'nodeValue');
                    }
                }
               },
        }

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

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

        function Watcher(vm, node, name, type) {
            Dep.target = this;
            this.name = name;
            this.node = node;
            this.vm = vm;
            this.type = type;
            this.update();
            Dep.target = null;
        }
     
        Watcher.prototype = {
            update: function() {
                this.get();
                this.node[this.type] = this.value; // 訂閱者執行相應操作
            },
            // 獲取data的屬性值
            get: function() {
                this.value = this.vm[this.name]; //觸發相應屬性的get
            }
        }

        function defineReactive (obj, key, val) {
            var dep = new Dep();
            Object.defineProperty(obj, key, {
                get: function() {
                    //新增訂閱者watcher到主題物件Dep
                    if(Dep.target) {
                        // JS的瀏覽器單執行緒特性,保證這個全域性變數在同一時間內,只會有同一個監聽器使用
                        dep.addSub(Dep.target);
                    }
                   return val;
                },
                set: function (newVal) {
                    if(newVal === val) return;
                        val = newVal;
                        console.log(val);
                        // 作為釋出者發出通知
                        dep.notify();
                    }
            });
        }

        function observe(obj, vm) {
            Object.keys(obj).forEach(function(key) {
                defineReactive(vm, key, obj[key]);
            });
        }
     
       function Vue(options) {
            this.data = options.data;
            var data = this.data;
            observe(data, this);
            var id = options.el;
            var dom =new Compile(document.getElementById(id),this);
            // 編譯完成後,將dom返回到app中
            document.getElementById(id).appendChild(dom);
        }

        var vm = new Vue({
            el: 'app',
            data: {
                text: 'hello world'
            }
        });
     </script>
</body>
</html>

效果如下:可以雙向繫結資料了

總結:Vue.js實現資料雙向通訊的原理,用幾句話簡單的概括:

A、通過observe()函式裡面Object.defineProperty()監聽UI的變化,從而得到資料的變化;再通過compile()函式監聽繫結該資料的DOM節點,當資料變化的時候就會通知這些節點更新資料,從而實現UI的變化。

B、observe()函式實現監聽UI變化,從而獲得新的資料;compile()函式實現監聽資料變化,然後將資料傳送到繫結該資料的DOM節點上顯示。