Vue雙向綁定原理以及原生JS實現

前言

 

    Vue的數據雙向綁定原理是面試中必問的一題。每當被問到Vue數據雙向綁定原理的時候,大家可能都會脫口而出:Vue內部通過Object.defineProperty方法屬性攔截的方式,把data對象裏每個數據的讀寫轉化成getter/setter,當數據變化時通知視圖更新。雖然一句話把大概原理概括了,但是其內部的實現方式還是值得深究的。

 

使用原生js實現一個vue的雙向數據綁定。

 

    思路分析

數據雙向綁定,即主要是:數據變化更新視圖,視圖變化更新數據。如下圖:

         

        也就是說:

  • 輸入框內容變化時,data 中的數據同步變化。即 view => model 的變化。

  • data 中的數據變化時,文本節點的內容同步變化。即 model => view 的變化。

           要實現這兩個過程,關鍵點在於數據變化如何更新視圖,因爲視圖變化更新數據我們可以通過事件監聽的方式來實現。所以我們着重討論數據變化如何更新視圖。

數據變化更新視圖的關鍵點則在於我們如何知道數據發生了變化,只要知道數據在什麼時候變了,那麼問題就變得迎刃而解,我們只需在數據變化的時候去通知視圖更新即可。

 

   開始實現

    前言中提到的Object.defineProperty,大家可能對這個屬性的用法不是很瞭解。

    MDN上是這麼介紹的:Object.defineProperty() 方法會直接在一個對象上定義一個新屬性,或者修改一個對象的現有屬性, 並返回這個對象。

    首先,我們定義一個數據對象:

let car = {        'brand':'BMW',        'price':3000}

我們定義了這個car的品牌brand是BMW,價格price是3000。現在我們可以通過car.brand和car.price直接讀寫這個car對應的屬性值。但是,當這個car的屬性被讀取或修改時,我們並不知情。那麼應該如何做才能夠讓car主動告訴我們,它的屬性被修改了呢?

完成了數據的'可觀測'

接下來,我們使用Object.defineProperty()

let car = {        'brand':'BMW',        'price':3000}Object.defineProperty(car, 'price', {        get(){            console.log('price屬性被讀取了')            return val        },        set(newVal){            console.log('price屬性被修改了')            val = newVal        }})

通過Object.defineProperty()方法給car定義了一個price屬性,並把這個屬性的讀和寫分別使用get()set()進行攔截,每當該屬性進行讀或寫操作的時候就會出發get()set()。如下圖:

可以看到,car已經可以主動告訴我們它的屬性的讀寫情況了,這也意味着,這個car的數據對象已經是“可觀測”的了。

爲了把car的所有屬性都變得可觀測,我們可以編寫如下兩個函數:

/**     * 把一個對象的每一項都轉化成可觀測對象     * @param { Object } obj 對象     */    function observable (obj) {        if (!obj || typeof obj !== 'object') {            return;        }        let keys = Object.keys(obj);        keys.forEach((key) =>{            defineReactive(obj,key,obj[key])        })        return obj;    }/**     * 使一個對象轉化成可觀測對象     * @param { Object } obj 對象     * @param { String } key 對象的key     * @param { Any } val 對象的某個key的值     */    function defineReactive (obj,key,val) {        Object.defineProperty(obj, key, {            get(){                console.log(`${key}屬性被讀取了`);                return val;            },            set(newVal){                console.log(`${key}屬性被修改了`);                val = newVal;            }        })    }

現在,我們就可以這樣定義car:

let car = observable({        'brand':'BMW',        'price':3000    })

car的兩個屬性都變得可觀測了。

 

完成了數據的'可觀測',即我們知道了數據在什麼時候被讀或寫了,那麼,我們就可以在數據被讀或寫的時候通知那些依賴該數據的視圖更新了,爲了方便,我們需要先將所有依賴收集起來,一旦數據發生變化,就統一通知更新。其實,這就是典型的“發佈訂閱者”模式,數據變化爲“發佈者”,依賴對象爲“訂閱者”。

現在,我們需要創建一個依賴收集容器,也就是消息訂閱器Dep,用來容納所有的“訂閱者”。訂閱器Dep主要負責收集訂閱者,然後當數據變化的時候後執行對應訂閱者的更新函數。

創建消息訂閱器Dep:

 class Dep {        constructor(){            this.subs = []        },        //增加訂閱者        addSub(sub){            this.subs.push(sub);        },        //判斷是否增加訂閱者        depend () {            if (Dep.target) {                this.addSub(Dep.target)            }        },
        //通知訂閱者更新        notify(){            this.subs.forEach((sub) =>{sub.update()            })        }    }Dep.target = null;

有了訂閱器,再將defineReactive函數進行改造一下,向其植入訂閱器:

function defineReactive (obj,key,val) {        let dep = new Dep();        Object.defineProperty(obj, key, {            get(){                dep.depend();                console.log(`${key}屬性被讀取了`);                return val;            },            set(newVal){                val = newVal;                console.log(`${key}屬性被修改了`);                dep.notify()                    //數據變化通知所有訂閱者            }        })    }

從代碼上看,我們設計了一個訂閱器Dep類,該類裏面定義了一些屬性和方法,這裏需要特別注意的是它有一個靜態屬性 target,這是一個全局唯一 的Watcher,這是一個非常巧妙的設計,因爲在同一時間只能有一個全局的 Watcher 被計算,另外它的自身屬性 subs 也是 Watcher 的數組。

我們將訂閱器Dep添加訂閱者的操作設計在getter裏面,這是爲了讓Watcher初始化時進行觸發,因此需要判斷是否要添加訂閱者。在setter函數裏面,如果數據變化,就會去通知所有訂閱者,訂閱者們就會去執行對應的更新的函數。

到此,訂閱器Dep設計完畢,接下來,我們設計訂閱者Watcher.

 

訂閱者Watcher在初始化的時候需要將自己添加進訂閱器Dep中,那該如何添加呢?我們已經知道監聽器Observer是在get函數執行了添加訂閱者Wather的操作的,所以我們只要在訂閱者Watcher初始化的時候出發對應的get函數去執行添加訂閱者操作即可,那要如何觸發get的函數,再簡單不過了,只要獲取對應的屬性值就可以觸發了,核心原因就是因爲我們使用了Object.defineProperty( )進行數據監聽。這裏還有一個細節點需要處理,我們只要在訂閱者Watcher初始化的時候才需要添加訂閱者,所以需要做一個判斷操作,因此可以在訂閱器上做一下手腳:在Dep.target上緩存下訂閱者,添加成功後再將其去掉就可以了。訂閱者Watcher的實現如下:

  class Watcher {        constructor(vm,exp,cb){            this.vm = vm;            this.exp = exp;            this.cb = cb;            this.value = this.get();  // 將自己添加到訂閱器的操作        },
        update(){            let value = this.vm.data[this.exp];            let oldVal = this.value;            if (value !== oldVal) {                this.value = value;                this.cb.call(this.vm, value, oldVal);            },        get(){            Dep.target = this;  // 緩存自己            let value = this.vm.data[this.exp]  // 強制執行監聽器裏的get函數            Dep.target = null;  // 釋放自己            return value;        }    }

過程分析:

訂閱者Watcher 是一個 類,在它的構造函數中,定義了一些屬性:

  • vm:一個Vue的實例對象;

  • exp:node節點的v-modelv-on:click等指令的屬性值。如v-model="name"exp就是name;

  • cb:Watcher綁定的更新函數;

當我們去實例化一個渲染 watcher 的時候,首先進入 watcher 的構造函數邏輯,就會執行它的 this.get() 方法,進入 get 函數,首先會執行:

Dep.target = this;  // 緩存自己

實際上就是把 Dep.target 賦值爲當前的渲染 watcher ,接着又執行了:

let value = this.vm.data[this.exp]  // 強制執行監聽器裏的get函數

在這個過程中會對 vm 上的數據訪問,其實就是爲了觸發數據對象的getter

每個對象值的 getter都持有一個 dep,在觸發 getter 的時候會調用 dep.depend() 方法,也就會執行this.addSub(Dep.target),即把當前的 watcher 訂閱到這個數據持有的 dep 的 subs 中,這個目的是爲後續數據變化時候能通知到哪些 subs 做準備。

這樣實際上已經完成了一個依賴收集的過程。那麼到這裏就結束了嗎?其實並沒有,完成依賴收集後,還需要把 Dep.target 恢復成上一個狀態,即:

Dep.target = null;  // 釋放自己

因爲當前vm的數據依賴收集已經完成,那麼對應的渲染Dep.target 也需要改變。

update()函數是用來當數據發生變化時調用Watcher自身的更新函數進行更新的操作。先通過let value = this.vm.data[this.exp];獲取到最新的數據,然後將其與之前get()獲得的舊數據進行比較,如果不一樣,則調用更新函數cb進行更新。

至此,簡單的訂閱者Watcher設計完畢。

完成以上工作後,我們就可以來真正的測試了。

index.html

<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">    <title>Document</title></head><body>    <h1 id="name"></h1><input type="text"><input type="button" value="改變data內容" onclick="changeInput()">    <script src="observer.js"></script><script src="watcher.js"></script><script>    function myVue (data, el, exp) {        this.data = data;        observable(data);                      //將數據變的可觀測        el.innerHTML = this.data[exp];           // 初始化模板數據的值        new Watcher(this, exp, function (value) {            el.innerHTML = value;        });        return this;    }
    var ele = document.querySelector('#name');    var input = document.querySelector('input');        var myVue = new myVue({        name: 'hello world'    }, ele, 'name');    //改變輸入框內容    input.oninput = function (e) {        myVue.data.name = e.target.value    }//改變data內容    function changeInput(){        myVue.data.name = "極致簡文"        }</script></body></html>

效果:

 

獲取完整代碼,請關注公衆號’極致簡文‘,後臺回覆 '雙向綁定源碼’;

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章