vue的響應式原理及從無到有用原生js實現vue的一套響應式系統

what is 響應式?

響應式作爲vue的代表特點之一, 意義非凡, 響應式的含義也就是: 我們更改了js中的數據, 頁面會同步進行更新, 同理, 頁面中的數據發生了變化, js也會得到通知從而更改數據

來看看實例

<!-- html結構相當的簡單, 一個id爲app的div, 之後我們會讓vue來接管該div -->
<div id='#app'></div>
const vm = new Vue({
    el: '#app',
    data: {
        msg: 'helloWorld'
    }
})

當我們在頁面中直接通過控制檯更改vue實例上的msg屬性的時候, 其實頁面也同步渲染了, 這就是響應式, 如下圖

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-h50TuXii-1591941872056)(./響應式.gif)]

how to do 響應式?

可能很多朋友都或多或少的瞭解過vue的響應式是通過Object.defineProperty實現的, 響應式系統的核心原理確實是如此, 但是遠不止於此, 話不多說, 我們從最基本的Object.defineProperty來看看vue優等生的完美作業之響應式系統

筆者也是自己的學習和領悟, 希望多多交流, 如有問題請及時指出

  • Object.defineProperty

    這哥們用來給一個對象進行代理, 當我們對一個對象進行代理以後, 那麼對這個對象進行任何的訪問都將得到監控

    在現實中, 我們可以理解爲明星藝人和經紀人的關係, 當有經紀人爲藝人進行代理業務以後, 以後每一個跟藝人相關的操作和業務都會被經紀人監控到, 經紀人也可以選擇是否讓藝人來承接這個業務或者參加某個活動等, 如果你還是不太理解, 那麼我們來看看實例, 我相信你會對Object.defineProperty更加的明白

    // 我們實際上是要在用戶修改某個屬性的時候, 我們要得到通知, 就這麼簡單
    let pengyuyan = {
        name: '彭于晏',
        address: '地球的某個地方'
    }
    
    pengyuyan.address =  '臺灣';
    

    像上面這樣操作我們能夠得到通知嗎? 答案是否定的, 因爲更改屬性一瞬間就發生了, 我們都妹機會來捕捉他的變化, 所以我們來看看如下這樣寫

    let pengyuyan = {
        name: '彭于晏',
        address: '地球的某個地方'
    }
    
    let pengyuyanName = pengyuyan.name;
    Object.defineProperty(pengyuyan, 'name', {
        get() {
            console.log('有人要找彭于晏啦, 快看看是不是吳彥祖');
            return pengyuyanName;
        },
        set(newVal) {
            console.log('有人要改彭于晏的名字啦, 快點來人看看在他改名字之前我們是不是要做點什麼啊');
            pengyuyanName = newVal;
        }
    })
    

    Object.defineProperty給某個對象的某個屬性進行代理以後, 會給該對象的被代理屬性提供一個getset方法, get用來監控之後任何操作對於被代理屬性的訪問, set用來監控之後任何操作對於被代理屬性的修改

    於是當我們對pengyuyan對象的name的屬性進行讀和寫的時候, 會在讀和寫的時候得到提示獲得一定的反饋, 如下圖

    [外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-oXxKbbKW-1591941872058)(./pengyuyan.gif)]

    於是乎, 我們便可以用我們的方式來模擬一下vue的響應式

    <!-- 這是一開始頁面中渲染的內容, 我們要做的就是當js的數據改變的時候頁面要相應的給予反饋 -->
    <div id='#app'>
    </div>
    
    const pengyuyan =  (function () {
        const app = document.getElementById('app');
        const pengyuyan = {
            name: 'pengyuyan',
            age: 18
        }
    
        function init() {
            render(pengyuyan.name);
            observer();
        }
    
        // 負責渲染的函數
        function render(value) {
            app.innerHTML = value;
        }
    
        function observer() {
            // 代理
            let pengyuyanName = pengyuyan.name;
            Object.defineProperty(pengyuyan, 'name', {
                get() {
                    return pengyuyanName;
                },
    
                set(newVal) {
                    pengyuyanName = newVal;
                    render(pengyuyanName);
                }
            })
        }
    
        init();
    
        return pengyuyan;
    }())
    

    上面的代碼其實寫的非常的簡單, 如果你看不懂的話筆者建議你補一補js的基礎知識, 上面的實現結果也非常的簡單, 如下

    [外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-oj1is2LB-1591941872059)(./實現響應式.gif)]

    我們會發現, 這就把vue的響應式貌似實現了? 筆者之前也說過這確實是vue響應式的核心原理, 但要就說這就是vue的響應式, 那還遠遠不夠, vue考慮到的東西更加的多更加的豐富, 但是有了這個作爲基礎, 筆者相信後面的操作不太會難得到你


    首先要知道vue的響應式系統怎麼實現, 我們必須要完整的知道vue的響應式系統到底能做哪些事兒, 不能做哪些事兒

    • vue會遍歷data對象中的所有屬性, 使用Object.defineProperty進行追蹤

    • Object.defineProperty的追蹤是遞歸進行的, 意味這如果發生引用值嵌套引用值的情況, vue也能夠很好的監測到

    • vue在某種情況下並不能監測數組和對象的改變

      • 通過數組的索引去修改數組
      • 通過數組的長度去修改數組
      • 直接對對象進行增加屬性操作
      • 直接對對象進行刪除屬性操作
      • 數組有7個數組變異方法可供開發者直接操作數組分別是

        push, pop, unshift, shift, reverse, sort, splice

    • vue實例上提供$set$delete供開發者對數組和對象進行增加和刪除操作

    • vue的響應式系統是異步的, vue只會響應最後一次數據的變更並對dom進行更新

    • 在數據變更時, vue會跟虛擬dom進行比對, 如果此次變更的值跟上一次變化的值沒有區別, 則vue不會進行dom的更新

    ok, 筆者的理解基本上就這麼多, 如有遺漏純屬忘記,還望海涵

    那麼我們可以一步一步來用我們的方式來複刻vue的這套響應式系統

    關於接下來要寫的所有的代碼, 筆者儘量會以一種小白都可以看的懂的寫法去實現這些功能, 所以並不直接是vue的源碼, 只能理解爲用比較easy的代碼來讓你大概知道vue響應式的核心工作原理, 同時因爲vue源碼中的各個功能之間的關聯性極強, 涉及到的代碼邏輯比較複雜, 比如觀察者模式, wachter, observer和異步隊列等, 筆者不會將所有的流程都會寫的很清晰, 但是會在後面你看懂了筆者這份簡易代碼以後, 當你上github查看vue真正的開源代碼中響應式這塊的處理的時候一定會更加的得心應手

    1. 首先我們如何對一個對象上所有的屬性進行追蹤: 對一個屬性追蹤使用Object.defineProperty, 對多個屬性追蹤我們使用多個Object.defineProperty不就ok了, 在這個過程中我們唯一要注意的就是對象中如果依舊嵌套對象的情況需要使用到遞歸, 這裏可能需要比較紮實的遞歸知識
    const data =  (function () {
        const data = {
            name: 'thor',
            age: 18,
            sex: 'male',
            address: {
                province: 'guangdong',
                city: 'shenzhen'
            },
            friends: [
                {
                    name: 'loki',
                    age: 19
                }
            ],
            cat: {
                name: 'lulu',
                skill: ['eat', 'sleep']
            }
        }
    
        function init() {
            observer(data, '');
        }
    
        /**
         *提供一個observer觀察方法, data爲需要被監控的對象 
            nameSpace: 命名空間, 他可以更加精準的幫助我們知道我們現在修改了哪個屬性的值
            **/
        function observer(data, nameSpace) {
            // 循環給對象上的每一個屬性進行
            for (let prop in data) {
                defineReactive(data, prop, data[prop], nameSpace);
            }
        }
    
        /**
         * defineReactive方法是真正用來監測的方法
         * data: 當前需要代理的對象, 
            prop: 需要代理對象的屬性
            value: 控制該屬性的value值
            **/
        function defineReactive(data, prop, value, nameSpace) {
            if (typeof data[prop] === 'object') {
                // 如果傳遞進來的屬性是一個對象, 那麼我們其實是需要遞歸監測的
                observer(data[prop], prop + '.');
            }
            Object.defineProperty(data, prop, {
                get() {
                    return value;
                },
                set(newVal) {
                    console.log('我在修改' + nameSpace + prop + '的值');
                    value = newVal;
                }
            })
    
        }   
    
        init();
    
        return data;
    
    }())
    

    ok, 關於多個屬性的監測和遞歸其實是很簡單的, 實現後效果如下

    [外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-7TZw8PSv-1591941872060)(./遞歸監測.gif)]

    1. 我們知道vue對於數據的更新是選擇性的, 他只會在真正需要更新的時候纔會去更新, 而如果數據經過vue的diff算法比對以後不需要更新, 則vue就會放棄本次更新

    所以我們要在代碼中增加如下代碼(新增的代碼會打上註釋)

    const data =  (function () {
        const data = {
           ...
        }
    
        function init() {
           ...
        }
    
        function observer(data, nameSpace) {
            ...
        }
    
        function defineReactive(data, prop, value, nameSpace) {
            ...
            Object.defineProperty(data, prop, {
                get() {
                    return value;
                },
                set(newVal) {
                    // 同時在這塊我們需要處理一下是否需要更新
                    const o = Object.assign({}, data); // 避免拿同樣的地址
                    console.log('我在修改' + nameSpace + prop + '的值');
                    value = newVal;
                    // 如果比較函數通過比較得出確實需要更新, 則執行render刷新頁面, 否則不刷新
                    if(compare(o, data)) render();
                }
            })
    
        }   
    
        /**
         * 我們新提供一個compare方法用來比較是否需要重新渲染頁面
        * o: 老的對象
        * n: 新的對象
        * 如果返回true則代表確實需要更改, 返回false則不需要更改
        * */
        function compare(o, n) {
            if ((o == null && n !== null) || (o !== null && n == null)) return true
            // vue內部實現這塊比較的複雜, 涉及到虛擬dom和diff算法, 而且vue的 o參數 代表的虛擬dom 是一顆樹形結構 
            // 筆者這裏就直接用使用遞歸for循環來草率對比一下
            if (o === n) return false;
            for (let prop in o) {
                if (typeof o[prop] === 'object') {
                    if (o[prop].length !== n[prop].length) return true;
                    if (Object.keys(o[prop]).length !== Object.keys(n[prop]).length) return true;
                    compare(o[prop], n[prop]);
                } else {
                    if ((o[prop] !== n[prop])) return true;
                }
    
            }
            return false;
        }
    
        /**
         * 同樣新提供一個render方法, 他代表刷新頁面的操作
         由於刷新頁面操作涉及到模板字符串的替換之類的功能
         這裏只用一個打印語句做提示
         **/
        function render() {
            console.log('compare監測到兩個對象不一致, 所以頁面是需要重新渲染的')
        }
    
    
        init();
    
        return data;
    
    }())
    

    在我們增加了compare和render方法以後, 按需渲染的功能也能夠達到了, 如下圖

    [外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-2L8OaV1M-1591941872061)(./新增render和compare方法.gif)]

    1. 我們如何緩存更改, 只響應最後一次數據的變化, 且不論中途數據產生了多少次變化, 只要最後一次數據變回了原樣也不會修改呢?

    沒錯, 答案就是異步, 如果你對事件循環(eventloop)足夠清晰的話, 我們知道異步是一定會等到當前執行棧中的全部同步代碼都走完纔會去執行異步任務的, 異步中微任務又是快於宏任務執行, 而我們既需要只響應最後一次請求又需要儘可能快的去響應操作那麼我們勢必是要將更新的render函數丟進微任務的, 來看代碼

    同樣更改的代碼會打上註釋

    
      const data =  (function () {
        const data = {
           ...
        }
    
        const renderFlag = false; // 渲染鎖, 如果鎖一旦開啓則不會提交render請求進異步隊列
    
        function init() {
           ...
        }
    
        function observer(data, nameSpace) {
            ...
        }
    
        function defineReactive(data, prop, value, nameSpace) {
            ...
            Object.defineProperty(data, prop, {
                get() {
                    return value;
                },
                set(newVal) {
                    const o = Object.assign({}, data); 
                    console.log('我在修改' + nameSpace + prop + '的值');
                    value = newVal;
                    // excutorRender會在這裏執行
                    excutorRender(o, data);
                }
            })
    
        }   
    
        /**
         * 我們提供一個執行render的方法, 同樣這樣做的初衷也是我們想在render之前做一些事情 
         **/
        function excutorRender(o, n) {
             if(renderFlag) return;
             renderFlag = true; // 開啓鎖
             Promise.resolve().then(() => {
                 renderFlag = true; // 關閉鎖
                 // 真正對比
                 if(compare(o, n)) render();
             })
        }
    
        function compare(o, n) {
           ...
        }
    
        function render() {
            ...
        }
    
    
        init();
    
        return data;
    
    }())
    
     // 我們在這進行測試
     data.name = 'thor';
     data.name = 'nina';
     data.name = 'jack';
    
     data.address.city = 'guangzhou';
     data.address.city = 'guangzhou';
     data.address.city = 'nanjing';
    
     // 上面修改了name和city值各3次, 且最後值都發生了變化, 我們來看看render方法走了幾次
    

    執行結果如下

    [外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-Xu361sq3-1591941872062)(./異步加載結果1.png)]

    那麼如果我們之前更改n次, 最後一次將數據改回原狀, 頁面會不會重新渲染呢?

    ...
    data.name = 'thor';
    data.name = 'nina';
    data.name = 'jack';
    data.name = 'thor'; // 第四次我們又將name值改回了一開始的thor
    
    data.address.city = 'guangzhou';
    data.address.city = 'guangzhou';
    data.address.city = 'nanjing';
    data.address.city = 'shenzhen'; // 城市也被我們該回了最初的shenzhen
    

    結果如下

    [外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-W6X6voTi-1591941872063)(./異步加載結果4.png)]

    很顯然, 頁面沒有進行最後的渲染, 說明我們的目標達成了

    1. 我們的數據結構中存在數組, 但是我們一直在迴避數組的更改, 這下我們來處理一下數組, 按照之前所說, 直接修改數組的長度和通過索引來修改數組是不會導致頁面重新渲染的

    如果你js基礎夠好的話, 數組也是特殊的對象, 那麼如果我們使用Object.defineProperty來監控數組的變化的話, 那麼是一定支持修改數組下表來修改數組值的, 實際上vue爲什麼不允許呢? 本質上他是可以被修改的, 但是尤雨溪我們的尤大神說了一句特別自信的話:

    通過數組下標去修改數組導致頁面重新渲染的話, 用戶體驗和性能不成正比

    於是乎vue就不支持直接通過數組的索引來修改數組了, 我們也走起

        const data =  (function () {
        const data = {
           ...
        }
    
        const renderFlag = false; // 渲染鎖, 如果鎖一旦開啓則不會提交render請求進異步隊列
    
        function init() {
           proxyArray(); // 我們執行代理數組方法
           ...
        }
    
        function observer(data, nameSpace) {
            ...
        }
    
        function defineReactive(data, prop, value, nameSpace) {
            // 所以這裏的typeof 的結果爲object就不能用在這了, 我們必須絕對判斷他爲對象, 所以數組進不去我們自然沒辦法通過索引修改
            if (data[prop] instanceof Object) {
            ...
            }
            ...
        }   
    
        function excutorRender(o, n) {
             ...
        }
    
        function compare(o, n) {
           ...
        }
    
    
        /**
         * 提供一個代理數組的方法
            * **/
        function proxyArray() {
            // 我們這裏先什麼都不做
        }
    
    
        function render() {
            ...
        }
    
    
        init();
    
        return data;
    
    }())
    

    這個時候我們通過數組的索引去更改數組的值則不會被感知到, 實際上vue也是通過這種方式來屏蔽用戶對下標的修改, 如圖

    [外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-uCl9I4eM-1591941872063)(./屏蔽數組索引.gif)]

    1. ok, 但是我們知道vue是給數組提供了7個變異方法, 當通過這種方式更改數組的時候, 數組是完全可以感知到的, 那麼這都是怎麼實現的呢? 如下
    ...
     let newArrProto; // 聲明新的變量等下用來繼承數組原型
    
     // 我們先補全proxyArray函數就好
     function proxyArray(data, prop) {
         const arrMethods = ['push', 'pop', 'unshift', 'shift', 'sort', 'reverse', 'splice'];
    
         const arrProto = Array.prototype; // 拿到數組的原型
         newArrProto = Object.create(arrProto);
    
         arrMethods.forEach(ele => {
             newArrProto[ele] = function() {
                 arrProto.call(this, ...arguments); 
                 // 我們在每次使用了數組的原型方法以後直接重新渲染頁面
                 render();
             }
         })   
    
     }
    
     // 在defineReactive中, 我們還要做一些手腳
     function defineReactive(data, prop, value, nameSpace) {
         if(data[prop] instanceof Array) {
             data[prop].__proto__ = newArrProto; // 直接更改data[prop]的原型指向
         }
         // 如果你使用的是instanceof 這裏記得要改爲elseif, 因爲數組 instanceof Object也會返回true
         // 或者你直接使用constructor來判斷則不用改成else if
         else if(data[prop] instanceof Object) {
             ...
         }
     }
    
    ...
    

    經過上述操作以後, 我們其實已經可以通過數組變異方法來監控數組的變化了, 如圖

    [外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-OodNMfDQ-1591941872064)(./數組變異方法.gif)]

    1. 最後一小點, $set$delete
    ...
    // 這兩個方法其實都是vue原型上的方法, 經過我們之前這麼長的鋪墊, 這兩個方法已經非常的好寫了
    function set(data, prop, newVal) {
        data[prop] = newVal;
        render();
    }
    
    function delete(data, prop) {
        detele(data, prop);
        render();
    }
    
    // 就不演示了, 真的不要太easy
    ...
    

    ok, vue的響應式原理, 筆者應該是一個一個都已經寫完了, 希望可以對你產生一些幫助, 這也僅僅只是原理, vue官方對於代碼的控制遠不是筆者可以比的, 未來在路上, on the road 一起加油。


    傳送門

一、該篇博客涉及所有代碼github鏈接:

代碼鏈接

二、vuejs源碼github鏈接

vue源碼鏈接

三、掘金上筆者認爲寫的比較好的真正的vuejs的響應式源碼剖析博客

輝衛無敵對於vue響應式源碼的剖析

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