通俗易懂講解並手寫一個vue資料雙向繫結案例

語言: CN / TW / HK

問題描述

面試中,面試官除了問基礎知識以外,還喜歡問一些框架原理。比如: 你對vue的資料雙向繫結mvvm是如何理解的? 網上的部分貼子可能寫的有點抽象,不便於快速閱讀理解。本篇文章就使用通俗易懂的簡單方式,來講解並實現一個簡單的vue資料雙向繫結原理demo,希望對大家有一定的幫助

先複習基本知識

為了便於大家更好的理解下文資料雙向繫結的程式碼,我們最好先複習一下舊知識,如果基礎知識紮實的道友,可以直接跳過這一段。

DOM.children屬性返回DOM元素有哪些元素子節點

程式碼:

<body>
    <div class="divClass">
        <span>孫悟空</span>
        <h4>豬八戒</h4>
        <input type="text" value="沙和尚">
    </div>
    <script>
        let divBox = document.querySelector('.divClass')
        console.log('元素節點', divBox);
        console.log('元素節點的子節點偽陣列', divBox.children);
    </script>
</body>

示例圖:

注意區分: DOM.childNodes得到所有的節點 ,比如元素節點、文字節點、註釋節點;而, DOM.children只得到所有的元素節點 。二者返回的都是一個偽陣列,但偽陣列有length長度,代表有多少個節點,且可以迴圈遍歷, 遍歷的每一項都是一個dom元素標籤! 不過偽陣列不能使用陣列的方法

DOM.hasAttribute(key)/getAttribute(key)判斷元素標籤是否有key屬性以及訪問對應value值

程式碼:

<body>
    <h3 class="styleCss" like="coding" v-bind="fire in the hole">穿越火線</h3>
    <script>
        let h3 = document.querySelector('h3')
        console.log(h3.hasAttribute('v-hello')); // 看看此標籤有沒有加上v-hello這個屬性,沒的,故列印:false
        console.log(h3.hasAttribute('like')); // 看看此標籤有沒有加上like這個屬性,有,故列印:true
        console.log(h3.getAttribute('like')); // 訪問此標籤上加上的這個v-bind屬性值是啥,列印:coding
        console.log(h3.hasAttribute('v-bind')); // 看看此標籤有沒有加上v-bind這個屬性,,有的,故列印:true
        console.log(h3.getAttribute('v-bind')); // 訪問此標籤上加上的這個v-bind屬性值是啥,列印:fire in the hole
        console.log(h3.attributes); // 可以看到所有的在標籤上繫結的屬性名和屬性值(key="value"),是一個偽陣列
    </script>
</body>

示例圖:

這兩個api可以用來看標籤上是否綁定了vue的指令,以及看看vue指令值是啥,以便於我們去與data中的相應資料做對應

DOM.innerHTML與DOM.innerText的區別

二者均可以修改dom的文字內容。innerHTML是符合W3C標準的屬性,所以是主流使用的dom的api。而innerText雖然相容性要好一些,不過主流還是innerHTML

程式碼:

<body>
    <h3>西遊記</h3>
    <button>更改dom內容</button>
    <script>
        let h3 = document.querySelector('h3')
        let btn = document.querySelector('button')
        btn.onclick = () => {
            h3.innerHTML = h3.innerHTML + '6'
        }
    </script>
</body>

示例圖:

DOM.innerHtml這個api可用於更改vue中的差值表示式{{key}}對應的內容值

資料雙向繫結成品效果圖

我們先看一下,我們所要實現的成品的效果圖

需求分析

  1. 輸入框輸入值內容發生變化,頁面也發生對應變化
  2. 點選按鈕,輸入框和頁面都發生對應變化
    即:
  3. 頁面變化(輸入框引起)觸發資料data變化,最終觸發頁面變化;
  4. 資料data變化(按鈕引起),觸發頁面變化

關於MVVM的理解

簡單理解

mvvm即為m v vm分別對應的是:

  • m是model資料層(就是vue中的data、computed、watch啊之類的資料配置項)
  • v是view檢視層(檢視層效果是dom堆疊出來的,所以檢視層可以理解為dom元素)
  • vm是model資料層和view檢視層的中間層view_model(vm)層,是vue中的核心,功能強大

vm可以監聽檢視層dom的變化 ,比如監聽input標籤dom的value值變化,去更改model資料層中的data對應值, vm也可以監聽model資料層中的data對應key的value的值的變化, 去更改input標籤dom的value值。

即: vm相當於一個擺渡人,可把此岸人渡到彼岸、彼岸人渡到此岸

核心理解

所以,MVVM的核心是,所以,MVVM的核心是,所以,MVVM的核心是(重要的事情說三遍:)

  1. 監聽頁面的DOM的內容值變化,從而通知到data中做對應資料變化(主要是監聽表單標籤)

    監聽表單標籤的變化,是使用dom.addEventListener()這個方法

  2. 當data中資料變化以後,再去及時更新頁面DOM的內容變化

    監聽data中資料的變化,是使用Object.defineProperty()的set方法,自動幫我們監聽變化,至於更新dom,就是首先找到要更新哪個dom,如果是普通標籤就更新其innerHTML值、如果是表單標籤,就更改其value即可

關於Object.defineProperty的理解

關於Object.defineProperty這個方法,一言以蔽之,給物件定義響應式。論壇有很多資料帖子,在此不贅述。推薦看官方文件: https://developer.mozilla.org...

關於這個方法,我們先理解下面案例就差不多了:

案例需求

有一個物件obj,裡面有name和age屬性,要讓這個obj的每一個屬性,都是響應式的,訪問和修改的時候,都要對應列印資訊。

案例程式碼

複製貼上跑一下,大致就明白了

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <button id="nameId">修改名字</button>
    <button id="ageId">修改年齡</button>
    <script>
        let obj = {
            name: '孫悟空',
            age: 500,
        }
        for (const key in obj) { // 因為是給物件中每一個屬性都新增響應式,所以要遍歷物件
            let value = obj[key] // 存一份對應value值,用於訪問返回,以及新值修改賦值
            Object.defineProperty(obj, key, { // 給這個obj物件的每一個屬性名key都定義響應式
                get() {
                    console.log('訪問之(自動觸發),訪問值為:', value);
                    return value
                },
                set(newVal) {
                    console.log('修改之(自動觸發),修改的屬性名為:', key, '屬性值為:', newVal);
                    value = newVal
                }
            })
        }
        let nameBtn = document.querySelector('#nameId')
        let ageBtn = document.querySelector('#ageId')
        nameBtn.onclick = () => {
            obj.name = obj.name + '^_^ | '
        }
        ageBtn.onclick = () => {
            obj.age = obj.age + 1
        }
        // 這樣的話,訪問和修改的時候都會觸發啦(修改的時候是要先訪問找到,再去修改,故列印兩次)
    </script>
</body>

</html>

案例效果圖

完整程式碼

程式碼中寫了不少註釋,大家跟著註釋步驟閱讀應該就可以了。演示的話直接複製貼上即可。注意程式碼中的subArr,蒐集依賴,目的是看看有哪些dom元素需要做後續的響應式更新內容

列印new出來的Vue例項

如果下方的完整程式碼,有助於各位道友更好的理解mvvm的話,那就給咱點個贊鼓勵一下創作唄 ^_^

完整MVVM程式碼

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        #app {
            width: 600px;
            height: 216px;
            background-color: #ccc;
            padding: 24px;
        }

        button {
            cursor: pointer;
        }
    </style>
</head>

<body>

    <!-- view檢視層dom,為了便於理解,這裡以#app的根元素內部只有一層dom為例(多層需要遞迴) -->
    <div id="app">
        <input v-model="name" placeholder="請填寫名字">
        <span>名字是:</span><span v-bind="name"></span>
        <br>
        <br>
        <input v-model="age" placeholder="請填寫年齡">
        <span>年齡是:</span><span v-bind="age"></span>
        <br>
        <br>
        <h3>{{name}}</h3>
        <h3>{{age}}</h3>
        <button id="nameId">更改名字</button>
        <button id="ageId">更改年齡</button>
        <button id="resetId">恢復預設</button>
        <button id="removeId">全部清空</button>
    </div>

    <script>
        // 簡單函式封裝 之 判斷標籤內是否包含雙差值表示式
        function isIncludesFourKuoHao(str) {
            // 不過這裡不是特別嚴謹。嚴謹需要使用正則限制,大家明白思路即可
            if (str.length <= 4) { // 得大於4個字元
                return false
            }
            if ( // 且要有雙差值表示式,
                str[0] == '{' &
                str[1] == '{' &
                str[str.length - 1] == '}' &
                str[str.length - 2] == '}'
            ) {
                return true
            } else {
                return false
            }
        }

        // 簡單函式封裝 之 獲取雙差值表示式之間的變數名
        function getKuoHaoBetweenValue(params) {
            // 這裡也不是特別嚴謹,嚴謹也需要使用正則,大家明白思路即可
            return params.slice(2, params.length - 2) // {{name}} --> name
        }

        // 這裡使用建構函式,使之擁有new的功能。當然也可以使用class類程式設計
        function Vue(options) {
            /**
             * 第一步,獲取根節點dom元素,這一步的作用是有了根節點dom以後,可以通過dom.children獲取其所有子節點的dom元素,
             *         便於我們對子節點的dom進行操作,比如給子節點的input標籤繫結input事件監聽,這樣就可以通過dom.value
             *         實時拿到使用者在輸入框輸入的值了
             * */
            this.$el = document.querySelector(options.el);

            /**
             * 第二步,把data中的資料{name:'jack',age:'500'}存一份,因為我們除了修改this.name要是響應式的,同樣:
             *        this.$data.name也要是響應式的
            */
            this.$data = options.data;

            /**
             * 第三步,定義一個數組蒐集要變化的dom元素,當我們修改data中資料的時候,觸發Object.defineProperty()的set方法執行
             *         然後去subArr陣列中去尋找,看看是要修改那個dom元素的資料值即可,大家列印一下,就會發現subArr存放的是一個又
             *         一個物件,物件中記錄的是 哪一個dom,什麼屬性名key,以及對應更改innerHTML或value
             * */
            this.subArr = []

            /**
             * 第四步,執行模板編譯操作,把data中的資料做頁面呈現。這裡又分為兩部分
             *         4.1 給相應的互動輸入類標籤繫結事件監聽,比如input標籤繫結input事件,select標籤繫結change事件等。為便於理解
             *             本案例中只以input標籤為例說明(當然前提是:加了v-model指令做資料雙向繫結才會去操作這一步)
             *         4.2 把v-bind和插值表示式{{}}做內容呈現,即:把model中的對應資料值,並找到對應dom,更改其innerHTML的值為對應資料值
             * */
            this.useDataToCompileRenderPage(); // 使用data中的資料做模板編譯並渲染到頁面上

            /**
             * 第五步,給m中的資料使用Object.defineProperty做資料劫持,這樣的話,訪問或者修改物件的屬性值時,都可以得知。即:
             *         訪問時,不用額外操作。不過修改時,model中的data的值變化了,於此同時,還需同時更新dom,因為m變v也要跟著變
             *         即:dataChangeUpdatePage方法的執行,只要一set更新,我就讓dataChangeUpdatePage方法去更新對應的dom值
             *         (因為第四步以後,data中資料是渲染到頁面上了,但還需讓data中的資料變化,頁面也跟著變化,故要做資料劫持)
             * */
            this.definePropertyAllDataKey(); // 資料劫持data中的所有key使之成為響應式的

        }

        // 先把data中的資料,去編譯渲染到頁面上
        Vue.prototype.useDataToCompileRenderPage = function () {
            let _this = this; // 存一份this例項物件
            let nodes = this.$el.children; // 獲取根元素下的所有的子節點dom;值為偽陣列,列印結果:[input, span, span, br, br, input, span, span, br, br, button]
            for (let i = 0; i < nodes.length; i++) { // 迴圈這個子節點dom偽陣列,
                let node = nodes[i]; // 所有的標籤,一個一個去判斷,判斷這個標籤有沒有加上v-model,有沒有加上v-bind,有沒有差值表示式{{}} ,以這三種情況為例

                // 若dom標籤節點上加上了v-model指令
                if (node.hasAttribute('v-model')) {
                    let dataKey = node.getAttribute('v-model');// 去獲取v-model繫結的那個屬性值,本例中為dataKey的值分別為:name、age
                    node.addEventListener('input', function () { // 以input輸入框為例:給標籤繫結input輸入事件監聽,即:<input/>.addEventListener('input',function(){})
                        /** 注意,這裡是頁面到資料的處理,即v --> vm --> m的流程 */
                        _this.$data[dataKey] = node.value; // 如果是input標籤,可以直接通過inputDom.value獲取到input標籤中使用者輸入的值
                        _this[dataKey] = node.value; // 上一行是$data更改,即:this.$data.name或age獲取dom最新的值、這一行是this.name或age獲取最新的值
                    });
                    
                    /** 把model中的資料更新賦值(編譯)到頁面上 */
                    node['value'] = _this.$data[dataKey]; // inputDom.value = this.$data.name或age 賦值
                    /** 所以,經過這一波操作,成功的把輸入框(變化)的值,更改到資料層中了 即:v --> vm --> m */

                    /** 注意這裡,就是蒐集依賴,可以提取一個方法的,為了便於理解,就不提取了 */
                    _this.subArr.push({
                        nodeLabelDom: node, // 哪個dom標籤元素
                        whichAttribute: dataKey, // 哪一個屬性name或age
                        valueOrInnerHtml: 'value', // 更改value還是innerHTML
                    })
                }

                // 若dom標籤節點上加上了v-bind指令
                if (node.hasAttribute('v-bind')) {
                    /** 如果是v-bind指令,只需要新增watcher即可 * */
                    let dataKey = node.getAttribute('v-bind'); // 去獲取v-bind繫結的那個屬性值,本例中為dataKey的值分別為:name、age
                    node['innerHTML'] = _this.$data[dataKey]; // normalDom.innerHtml = this.$data.name或age 普通dom顯示賦值操作

                    /** 注意這裡,就是蒐集依賴,可以提取一個方法的,為了便於理解,就不提取了 */
                    _this.subArr.push({
                        nodeLabelDom: node, // 哪個dom標籤元素
                        whichAttribute: dataKey, // 哪一個屬性name或age
                        valueOrInnerHtml: 'innerHTML', // 更改value還是innerHTML
                    })
                }

                // 如果包含雙差值表示式{{}}
                if (isIncludesFourKuoHao(node.textContent)) {
                    let dataKey = getKuoHaoBetweenValue(node.textContent) // 就拿到雙差值表示式中間的key,屬性名,這裡的dataKey分別為:name、age
                    node['innerHTML'] = _this.$data[dataKey]; // 把雙差值表示式中的key做一個替換對應值

                    /** 注意這裡,就是蒐集依賴,可以提取一個方法的,為了便於理解,就不提取了 */
                    _this.subArr.push({
                        nodeLabelDom: node, // 哪個dom標籤元素
                        whichAttribute: dataKey, // 哪一個屬性name或age
                        valueOrInnerHtml: 'innerHTML', // 更改value還是innerHTML
                    })
                }
            }
        }

        // 再做資料劫持,遍歷給data中的每一個數據都劫持,使之,都用於set和get方法
        Vue.prototype.definePropertyAllDataKey = function () {
            let _this = this; // 存一份this以便使用
            for (let key in _this.$data) { // 遍歷物件{name:'孫悟空',age: 500}
                let value = _this.$data[key]; // value值為孫悟空、500  key的值自然是name和age
                Object.defineProperty(_this.$data, key, { // 使用defineProperty去新增攔截、劫持(劫持到$data身上)
                    get: function () { // 
                        return value; // 訪問key,訪問name或者age,就返回對應的值
                    },
                    set: function (newVal) {
                        value = newVal; // 修改key的屬性值,修改name或者age的屬性值,在做正常操作value = newVal賦值的同時
                        // 每當更新this.$data資料時,如:this.$data.name = 'newVal'就去做對應dom的更新即可
                        _this.dataChangeUpdatePage(key, newVal)
                    }
                })
                Object.defineProperty(_this, key, { // 劫持到自己身上
                    get: function () {
                        return value;
                    },
                    set: function (newVal) {
                        value = newVal;
                        // 每當更新this資料時,如:this.name = 'newVal'就去做對應dom的更新即可
                        _this.dataChangeUpdatePage(key, newVal)
                    }
                })
            }
        }

        // 公共方法,當更新觸發的時候,去根據資料做頁面渲染
        Vue.prototype.dataChangeUpdatePage = function (key, newVal) {
            let _this = this; // 存一份this例項物件
            // 也要去更新對應dom的內容
            _this.subArr.forEach((item) => {
                if (key == item.whichAttribute) {
                    // 哪個dom的     // innerText或者value      // 賦新值
                    item.nodeLabelDom[item.valueOrInnerHtml] = newVal;
                }
            })
        }

        let vm = new Vue({
            el: '#app', // 指定vue的根元素
            /**
             * model資料層,為了便於理解,這裡也是舉例data中資料只有一層,多層需要遞迴
             * */
            data: {
                name: '孫悟空',
                age: 500,
            }
        });
        console.log('vmvm', vm);

        // 更改名字
        let nameBtn = document.querySelector('#nameId')
        nameBtn.onclick = () => {
            vm.name = vm.name + '^' // 直接訪問
        }
        // 更改年齡
        let ageBtn = document.querySelector('#ageId')
        ageBtn.onclick = () => {
            vm.$data.age = vm.$data.age * 1 + 1 // 通過$data間接訪問
        }
        // 恢復預設的名字和年齡
        let resetBtn = document.querySelector('#resetId')
        resetBtn.onclick = () => {
            vm.$data.name = '孫悟空'
            vm.age = 500
        }
        // 清空名字和年齡
        let removeBtn = document.querySelector('#removeId')
        removeBtn.onclick = () => {
            vm.name = ''
            vm.$data.age = null
        }
    </script>
</body>
</html>

好記性不如爛筆頭,記錄一下唄