1. 程式人生 > 實用技巧 >vue中響應式原理

vue中響應式原理

  Vue是對MVVM框架的很好體現,那什麼是MVVM呢?顧名思義它其實就是Model-View-ViewModel模式。它是一個軟體架構設計模式,Model可以看成是代表資料模型,View代表檢視,它負責將資料模型轉化成ui進行展示。ViewMode用來連線Model和View,將view需要的資料暴露,處理view層的具體業務邏輯。

  MVVM它可以分離檢視(View)和模型(Model),降低程式碼耦合,利用雙向繫結,資料更新後檢視自動更新,來自動更新DOM,可以更好的編寫測試程式碼。凡事有利有弊,它的不足之處是jbug很難被除錯,因為使用了雙向資料繫結,從而出現bug時,有可能是view的程式碼有問題,也有可能是model上的程式碼出故障,對於大型的圖形應用程式來說,檢視較多,維護成本偏高。

  Vue是MVVM很好的體現。學習vue的過程中我們知道它有以下三要素,響應式,模板引擎及渲染。最獨特的一點是響應式,即資料模型更新,檢視更新。在這期間我們要了解它是如何知道資料變化,怎樣在資料變化的同時在檢視上進行體現。

  Vue的響應式就是data中的屬性被代理到vm上。在js中,偵測資料變化有兩種方式,一種是通過Object.defineProperty進行資料劫持,另外一種是通過Proxy進行資料代理,下面我們詳細進行分析。

  Object.defineProperty與Proxy

  Object.defineProperty()方法會直接在一個物件上定義一個新屬性或者是修改一個物件的現在屬性並返回這個物件。主要是利用Object.defineProperty中的訪問器屬性get和set方法。當把一個普通物件傳入Vue例項作為data選項時,Vue將遍歷物件中所有的屬性,將其新增上訪問器屬性。當讀取data中的資料時自動呼叫get方法,當修改data中的資料自動呼叫set方法。利用的是物件屬性中的get/set方法來監聽資料的變化。

 <script>
        //這是要被劫持的物件
        let data = {
            like: ''
        };
        let newData = 'eat';
        function say(like) {
            if (like === 'swam') {
                console.log('我最喜歡的活動是游泳');
            } else {
                console.log('我最喜歡的活動是遠足');
            }
        }

        
// 只要是呼叫了data的like屬性,那就會觸發get函式,呼叫時獲取到的結果就是get函式的返回值 //只是給data的like賦值那麼就會觸發set函式,形參對應的就是設定的那個值。 Object.keys(data).forEach(key => { Object.defineProperty(data, key, { get: function () { console.log('獲取時觸發了get'); return newData; }, set: function (val) { console.log('設定時觸發了set,val是:' + val); say(val); // 不能直接進行設定不然會引起死迴圈,所以要用到第三方變數 // data.like='eat'; } }) }) console.log(data); data.like = 'gram'; console.log(data); </script>

  使用Object.defineProperty時,它是將物件的key轉換成get/set形式來跟蹤變化的,get/set它只能跟蹤一個數據是否被修改,不能跟蹤屬性的新增與刪除。這時刪除屬性我們可以用到vm.$delete實現,新增的話可以使用Vue.set(location,a,1)這樣的方法新增響應式屬性或者是給這個物件重新賦值data.location={...data.location,a:1}。

  它還無法監聽到陣列和物件的變化,對於陣列而言,有以下的八種方法push,pop,shift,unshift,splice,sort,reverse它們是經過了一些內部處理對陣列進行重寫來保證響應式的。還有不能通過索引來直接設定資料項。Object.defineProperty它只能劫持物件的屬性,我們需要對物件進行遍歷。如果屬性值也是物件時,則需要進行深度的遍歷。這樣非常麻煩,所以才有了Proxy資料代理。

  Proxy它是在目標物件之前就回調了一層“攔截”,外界對該物件的訪問,都必須先通過這層攔截,它提供了一種機制,可以對外界的訪問進行過濾和改寫,我們可以這樣認為,Proxy是Object.defineProperty的加強版。

  Proxy它可以實現直接的監聽物件而不是屬性,它還可以直接監聽陣列的變化。但它的相容性不是特別好。

<script>
    let obj = {
        like: 'swam',
        age: { age: 20 },
        arr: [1, 2, 3, 4]
    };
    function render() {
        console.log('render')
    }
    let handler = {
        get(target, key) {
            //判斷取的值是否為物件
            if (typeof target[key] == 'object' && target[key] !== null) {
                return new Proxy(target[key], handler);
            }
            return Reflect.get(target, key);
        },
        set(target, key, value) {
            if (key === 'length') return true
            render();
            return Reflect.set(target, key, value)
        }
    }
    let proxy = new Proxy(obj, handler)
    proxy.age.name = 'davina' // 支援新增屬性
    console.log(proxy.age.name) // 模擬檢視的更新 "davina"
    proxy.arr[0] = '100' //支援陣列的內容發生變化
    console.log(proxy.arr)  //Proxy {0: "100", 1: 2, 2: 3, 3: 4}
</script>
 

  我們可以用Proxy實現一個極簡版本的雙向繫結。

<input type="text" id="input">
<div class="box"></div>
<script>
    const input = document.getElementById('input');
    const box = document.querySelector('div');
    const obj = {};
    const newObj = new Proxy(obj, {
        get: function (target, key, receiver) {

            return Reflect.get(target, key, receiver);
        },
        set: function (target, key, value, receiver) {
            if (key === 'text') {
                input.value = value;
                box.innerHTML = value;
            }
            return Reflect.set(target, key, value, receiver);
        },
    });
    input.addEventListener('keyup', function (e) {
        newObj.text = e.target.value;
    });
</script>

  當資料的屬性發生變化時,可以通知那些曾經使用過這個資料的地方資料變化了,那麼我們是怎麼知道曾經使用過這個資料的地方是哪些地方?我們要怎麼進行通知?

  這時我們就要收集相應的依賴才能知道哪此地方依賴我們的資料,以及資料更新時進行相應的更新。這時我們就要用到”事件釋出訂閱模式“。接下來先介紹兩外重要的角色-Dep訂閱者和Watcher觀察者。

  訂閱者 Dep

  Dep是儲存依賴的地方,它可以用來收集依賴,刪除依賴,向依賴傳送訊息等等。它的主要作用是用來存放watcher觀察者物件的,我們可以把觀察者看成是一箇中介的角色,資料發生變化時會通知它,然後再由它通知到其它的地方。當需求收集依賴時,我們可以呼叫addSub方法,當需求派發更新時我們呼叫notify方法。

   //訂閱者
    class Dep {
        constructor() {
            //提供一個事件池
            this.subs = [];
        }
        //增加事件的操作,向事件池裡放事件
        addSub(sub) {
            this.subs.push(sub);
        }
        //通知更新 
        notify() {
            this.subs.forEach(item => {
                // 讓對應的事件做更新操作
                item.update();
            })
        }
    }

  Observer監聽者

  我們需求知道屬性值的變化,用Observer來實現監聽

   /* 監聽者*/
  function observe(data) {
        //簡單判斷型別
        if (typeof data !== 'object') {
            return;
        }
        let keys = Object.keys(data);//key是所有屬性名組成的陣列
        keys.forEach(key => {
            defineReactive(data, key, data[key])

        })
    }

    //封裝一個defineReactive函式專門呼叫defineProperty,實現資料劫持
    function defineReactive(obj, key, value) {
        observe(value);//實現深層劫持

        let dep = new Dep;//每一個key都有一個私有變數dep

        Object.defineProperty(obj, key, {
            get() {
                if (Dep.target) {
                    dep.addSub(Dep.target);//Dep.target就是watcher例項
                }
                return value;
            },
            set(newval) {
                if (value !== newval) {
                    value = newval;
                    observe(value);
                    dep.notify();
                }
            }
        })

    }
    

  watcher觀察者

  當屬性發生變化後,我們要通知所有用到這個資料的地方,在一個專案或者是檔案中用到這個資料的地方有很多,而且型別不一定相同,這時就需求我們抽象出一個類中集中的處理這個情況。我們在收集依賴的階段只是收集這個封裝好的類的例項進來,通知也只通知它一個,再由它負責通知其它的地方,這樣速度和效率會快很多。依賴收集的目的是將watcher觀察者物件存放到當前的閉包中的Dep訂閱者的subs下。

/*watcher的簡單實現*/
class Watcher {
  constructor(obj, key, cb) {
    // 將 Dep.target 指向自己
    Dep.target = this
    this.cb = cb
    this.obj = obj
    this.key = key
    this.value = obj[key]
 // 最後將 Dep.target 置空
    Dep.target = null
  }
  update() {
    // 獲得新值
    this.value = this.obj[this.key]
   // 我們定義一個 cb 函式,這個函式用來模擬檢視更新,呼叫它即代表更新檢視
    this.cb(this.value)
  }
}

  以上就是watcher的簡單實現 ,在執行建構函式的時候將Dep.target指向自己,從而使收集到了對應的watcher,在派發更新的時候取出相應的watcher,然後然後執行update。

  總結一下就是:所謂的依賴其實就是watcher,收集依賴時,我們要做到在get中收集依賴,在set中觸發依賴。先收集依賴,就把用到這個資料的地方先收集起來,放到一個地方,然後屬性發生變化時,把之前收集好的依賴迴圈觸發就可以了。當外界通過watcher讀取資料時,這就發觸發get,從而將watcher新增到依賴中,哪個watcher觸發了get,就把哪個watcher放到到Dep中,當資料發生變化時,會迴圈依賴列表,把所有的watcher都執行一次。

 一個完整的雙向繫結有以下幾點:

  1、在new Vue()後利用Proxy或Object.defineProperty方法對物件/物件屬性進行"劫持",Vue中的data會通過observe新增上get/set屬性,來對資料進行追蹤變化,當物件被讀取時會執行get函式,而當被賦值時執行set函式。在屬性發生變化後通知訂閱者

  2、解析器Compile解析模板中的指令,收集方法和資料,等待資料變化然後渲染。

  3、Watcher接收到的Observe產生的資料變化,並根據Compile提供的指令進行檢視渲染,使得資料變化促使檢視變化

   vue是通過虛擬DOM追蹤自己要改變的真實DOM,這裡用真實的dom來簡單的進行模擬。

<body>
    <div id="app">
        <h1>我的名字是:{{name}}</h1>
        <h2>今年是:{{age}}</h2>
        <input type="text" v-model='name'></br>
        <input type="text" v-model='age'>
    </div>
</body>
<script>
    /* 用來資料劫持 */
    function observe(data) {
        if (typeof data !== 'object') {
            return;
        }
        let keys = Object.keys(data);
        keys.forEach(key => {
            defineReactive(data, key, data[key])

        })
    }
    //封裝一個defineReactive函式專門呼叫defineProperty,實現資料劫持
    function defineReactive(obj, key, value) {
        observe(value);
        let dep = new Dep;
        Object.defineProperty(obj, key, {
            get() {
                if (Dep.target) {
                    dep.addSub(Dep.target);
                }
                return value;
            },
            set(newval) {
                if (value !== newval) {
                    value = newval;
                    observe(value);
                    dep.notify();
                }
            }
        })

    }
    /* 模板編譯 */
    //把元素節點轉移到文件碎片上,將節點進行編譯後再還給節點
    function nodeToFragment(node, vm) {
        let child;
        let fragment = document.createDocumentFragment();
        //把node中的每一個子節點,轉移到了fragment
        while (child = node.firstChild) {
            //在移到fragment上先進行編譯
            compile(child, vm);
            fragment.appendChild(child);
        }
        //又把fragment上所有的節點放到node上
        node.appendChild(fragment);
    }
    function compile(node, vm) {
        //判斷node的節點型別
        if (node.nodeType == 1) {
            let attrs = node.attributes;
            [...attrs].forEach(item => {
                if (/^v\-/.test(item.nodeName)) {
                    //證明它是v-開頭的
                    let valName = item.nodeValue;//獲取"name"這個單詞

                    new Watcher(node, vm, valName);

                    let val = vm.$data[valName];
                    node.value = val;
                    node.addEventListener('input', (e) => {
                        vm.$data[valName] = e.target.value;
                    })
                }
            });
            //針對有子節點的元素接著進行編譯
            [...node.childNodes].forEach(item => {
                compile(item, vm);
            })
        } else {
            let str = node.textContent;
            node.str = str; 
            if (/\{\{(.+?)\}\}/.test(str)) {
                str = str.replace(/\{\{(.+?)\}\}/g, (a, b) => {
                    b = b.replace(/^ +| +$/g, '');
                    new Watcher(node, vm, b);
                    return vm.$data[b]
                })
                node.textContent = str
            }

        }
    }
    //訂閱者
    class Dep {
        constructor() {
            this.subs = [];
        }
        addSub(sub) {
            this.subs.push(sub);
        }
        notify() {
            this.subs.forEach(item => {
                item.update();
            })
        }
    }
       //觀察者
    class Watcher {
        constructor(node, vm, key) {
            Dep.target = this; 
            this.node = node;
            this.vm = vm;
            this.key = key;
            this.get();
            Dep.target = null;
        }
        //把對應節點裡的內容進行更新
        update() {
            //如果是input更新value值,如果是文字更新textContent
            this.get();
            if (this.node.nodeType == 1) { 
                this.node.value = this.value;
            } else {
                let str = this.node.str;//node.str = str; 
                str = str.replace(/\{\{(.+?)\}\}/g, (a, b) => {
                    b = b.trim();
                    //考慮到在一個文本里有多個小鬍子
                    if (b == this.key) {
                        return this.value
                    } else {
                        return this.vm.$data[b];
                    }
                })
                this.node.textContent = str;
            }
        }
        get() {
            this.value = this.vm.$data[this.key]
        }
    }
       function Vue(options) {
        //$el 儲存的是當前元素 
        this.$el = document.querySelector(options.el);
        // $data儲存的是data中的屬性
        this.$data = options.data
        observe(this.$data)
        nodeToFragment(this.$el, this)
    }
    let vm = new Vue({
        el: '#app',
        data: {
            name: 'davina',
            age: 10,
        }
    })
</script>