VUE雙向綁定原理

前言

在之前面試中,有被問(wèn)到這個(gè)問(wèn)題,雖然了解過(guò)是劫持Object.defineProperty方法,但是其細(xì)節(jié)并不太清楚,于是遭到了面試官的鄙視??,只能回頭認(rèn)真在網(wǎng)上看一下。

剛開(kāi)始看了很多文章,還是沒(méi)看懂。

最后我是看這篇文章看懂的,其他的要么略過(guò)太多細(xì)節(jié),看著有種斷層感,根本不知道怎么突然到這一步了。有些要么跟著代碼講思路,有點(diǎn)亂。

這篇文章已經(jīng)講解得很好了,但是作為一個(gè)小白,我還是看了老半天才懂,原因就是看的源碼少,水平不夠。

所以我決定重新捋一捋里面的思想,把細(xì)節(jié)盡可能說(shuō)清楚,讓跟我一樣沒(méi)學(xué)過(guò)任何源碼的人也能搞清楚。

補(bǔ)充一下個(gè)人想法,對(duì)于這些精妙的思維接觸不多,而這些往往是決定我們的高度的,是一個(gè)使用者還是研究者?有時(shí)候眼光的高低,決定著我們未來(lái)道路的長(zhǎng)短。

大致原理

vue的響應(yīng)原理可以從下面官網(wǎng)的分析圖大致了解。

官網(wǎng)的解釋是這樣的:

每個(gè)組件實(shí)例都有相應(yīng)的 watcher 實(shí)例對(duì)象,它會(huì)在組件渲染的過(guò)程中把屬性記錄為依賴(lài),之后當(dāng)依賴(lài)項(xiàng)的 setter 被調(diào)用時(shí),會(huì)通知 watcher 重新計(jì)算,從而致使它關(guān)聯(lián)的組件得以更新。

image.png

看不懂?沒(méi)關(guān)系,有個(gè)大概印象就可以了。

defineProperty是什么鬼?

為什么要先從這里說(shuō)起?因?yàn)檫@是眾所周知vue雙向綁定的原理。

MDN解釋在這里

簡(jiǎn)單地說(shuō),就是對(duì)于我們的對(duì)象的屬性,我們可以通過(guò)defineProperty來(lái)設(shè)置它的getset方法,一旦獲取值,就會(huì)觸發(fā)get方法,一旦修改值,就會(huì)觸發(fā)set方法。

比如下面簡(jiǎn)單的例子

var obj = {name:'zeller'};

Object.defineProperty(obj,'name',{
  get:function(){
    console.log(`你正在獲取obj的name值.`)
  },
  set:function(newVal){
    console.log(`name值修改中,新的name值是${newVal}`)
  },
})

obj.name//"你正在獲取obj的name值."
obj.name = 'atoms'//"name值修改中,新的name值是atoms"
image.png

codepen在線(xiàn)預(yù)覽

用defineProperty實(shí)現(xiàn)一個(gè)極簡(jiǎn)的雙向綁定例子

既然這個(gè)方法這么有用,我們?cè)O(shè)置一個(gè)容器obj,直接在set里面渲染我們的html,然后監(jiān)聽(tīng)input的keyup事件,當(dāng)事件觸發(fā)時(shí),修改obj對(duì)應(yīng)的值,從而再觸發(fā)html的改變。

既然大概思路有了,我們可以嘗試一下.

<!--html-->
<input type="text" id="content">請(qǐng)輸入內(nèi)容
<br><br>
他輸入的內(nèi)容是:<p id="reflect" style="color:red;"></p>
var obj={};
//假設(shè)我們監(jiān)聽(tīng)'hello'這個(gè)屬性
Object.defineProperty(obj,'hello',{
  set:function(newVal){
    var p = document.getElementById('reflect');
    p.innerHTML = newVal;
  }
})

var input = document.getElementById('content');
input.addEventListener('keyup',function(e){
  obj.hello = e.target.value;
})
image.png

在線(xiàn)預(yù)覽

分解實(shí)際任務(wù)

雖然上面的簡(jiǎn)單演示我們貌似做出來(lái)了,但是與實(shí)際的樣子卻不一樣。我們看看。

image.png
image.png

實(shí)際是上面這樣子調(diào)用的,所以我們需要分析一下,該如何實(shí)現(xiàn)。

首先,我們要在初次渲染html能拿到data的數(shù)據(jù)

其次,輸入框輸入內(nèi)容變化時(shí),data中的相應(yīng)屬性也能變化

最后,data中的數(shù)據(jù)變化時(shí),html能實(shí)時(shí)跟著變化

所以我們大概可以分為3個(gè)任務(wù)

  • 1、輸入框以及文本節(jié)點(diǎn)與data中的數(shù)據(jù)綁定(初始渲染)

  • 2、輸入框內(nèi)容變化時(shí),data中的數(shù)據(jù)同步變化。即view => model的變化。

  • 3、data中的數(shù)據(jù)變化時(shí),文本節(jié)點(diǎn)的內(nèi)容同步變化。即model => view的變化。

任務(wù)1:初始加載渲染data里的屬性

既然要加載data里的屬性值,我們就要考慮兩種情況,app里的子節(jié)點(diǎn)的類(lèi)型,

  • 當(dāng)childNode是文本節(jié)點(diǎn),而我們匹配到{{attr}}時(shí),我們需要去找vue里面綁定的data的attr屬性,把它的值替換給文本節(jié)點(diǎn).
  • 當(dāng)childNode是元素節(jié)點(diǎn)時(shí),比如<input v-model="attr">,我們就要去找vue.data.attr的值,并賦給childNode

因此可以看出,我們需要先把所有子節(jié)點(diǎn)遍歷出來(lái),看看有沒(méi)有符合以下兩個(gè)規(guī)則的內(nèi)容:

  • 文本節(jié)點(diǎn),含有
    {{attr}}
  • 元素節(jié)點(diǎn),含有v-model

這樣把值替換完我們就可以返回去了,但是考慮到多次操作dom的開(kāi)銷(xiāo),我們用createDocumentFragment()

它相當(dāng)與創(chuàng)建一個(gè)倉(cāng)庫(kù),每次把子節(jié)點(diǎn)修改完,我們不直接插入父節(jié)點(diǎn)(#app),而是放入倉(cāng)庫(kù),最后直接把倉(cāng)庫(kù)里的東西替換掉就可以了。

創(chuàng)建fragment倉(cāng)庫(kù)

function nodeToFragment (node, vm) {
        var flag = document.createDocumentFragment();
        var child;
        // 許多同學(xué)反應(yīng)看不懂這一段,這里有必要解釋一下
        // 首先,所有表達(dá)式必然會(huì)返回一個(gè)值,賦值表達(dá)式亦不例外
        //child = node.firstChild返回的是賦值的node.firstChild
        //即只要firstChild存在,就把firstChild賦給child
        // 理解了上面這一點(diǎn),就能理解 while (child = node.firstChild) 這種用法
        // 其次,appendChild 方法有個(gè)隱蔽的地方,就是調(diào)用以后 child 會(huì)從原來(lái) DOM 中移除
        // 所以,第二次循環(huán)時(shí),node.firstChild 已經(jīng)不再是之前的第一個(gè)子元素了
        while (child = node.firstChild) {
          compile(child,vm)//講data轉(zhuǎn)化為html
          flag.appendChild(child); // 將子節(jié)點(diǎn)劫持到文檔片段中
        }
        return flag
    }

compile方法在下面解釋

替換html

這里主要用的是正則表達(dá)式的檢測(cè)方法,其中對(duì)RegExp.$1的用法不了解的同學(xué)可以Google一下,這是正則一個(gè)非常巧妙而且強(qiáng)大的地方。

function compile (node, vm) {
        var reg = /\{\{(.*)\}\}/;
        // 節(jié)點(diǎn)類(lèi)型為元素
        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.value = vm.data[name];
                   node.removeAttribute('v-model')
                }
            };

        }
        // 節(jié)點(diǎn)類(lèi)型為 text
        if (node.nodeType === 3) {
            if (reg.test(node.nodeValue)) {
                var name = RegExp.$1; // 獲取匹配到的字符串
                name = name.trim();
                node.nodeValue = vm.data[name]
            }
        }
    }

我們看看上面的代碼,主要就是判斷子節(jié)點(diǎn)的類(lèi)型,一旦是元素節(jié)點(diǎn),我們就給它的input事件綁定方法,把input的value傳給vm.data[name],如果是文本節(jié)點(diǎn),就直接替換.
這里要注意,element節(jié)點(diǎn)我們用的是node.value,text節(jié)點(diǎn)我們用的是node.nodeValue,這兩個(gè)寫(xiě)法的區(qū)別可以自行Google一下.

最后再創(chuàng)建一個(gè)Vue實(shí)例


image.png

下面是codepen的實(shí)例


image.png

codepen

任務(wù)2:響應(yīng)式的數(shù)據(jù)綁定

再來(lái)看任務(wù)二的實(shí)現(xiàn)思路:當(dāng)我們?cè)谳斎肟蜉斎霐?shù)據(jù)的時(shí)候,首先觸發(fā)input事件(或者keyup、change事件),在相應(yīng)的事件處理程序中,我們獲取輸入框的value并賦值給vm實(shí)例的text屬性。我們會(huì)利用defineProperty將data中的text劫持為vm的訪(fǎng)問(wèn)器屬性,因此給vm.data.text賦值,就會(huì)觸發(fā)set方法。在set方法中主要做兩件事,第一是更新屬性的值,第二留到任務(wù)三再說(shuō)。

具體怎么做呢?

監(jiān)聽(tīng)input事件

input節(jié)點(diǎn)

當(dāng)我們觸發(fā)input時(shí),要在dom節(jié)點(diǎn)上綁定事件?
怎么綁定呢?記得我們前面的nodeToFragment函數(shù)嗎?就是用于遍歷所有的子節(jié)點(diǎn),進(jìn)行節(jié)點(diǎn)修改的。

而里面具體干活的是compile函數(shù),nodeToFragment只是一個(gè)包工頭。
這樣,我們就可以在v-model的標(biāo)簽里監(jiān)聽(tīng)input事件

if (attr[i].nodeName == 'v-model') {
    var name = attr[i].nodeValue; // 獲取 v-model 綁定的屬性名
node.addEventListener('input', function (e) {
    // 給相應(yīng)的 data 屬性賦值,進(jìn)而觸發(fā)該屬性的 set 方法
    vm.data[name] = e.target.value;
});

node.value = vm.data[name]; // 將 data 的值賦給該 node
node.removeAttribute('v-model');

我們看看邏輯,一開(kāi)始就是從vm.data[name]獲取value,一旦自己的內(nèi)容改變了(e.target.value),就把這個(gè)值告訴(賦值)給vm.data[name]

文本節(jié)點(diǎn)

而對(duì)于文本節(jié)點(diǎn),是不需要的,我們只需要從vm.data獲取數(shù)據(jù)就可以了。因?yàn)樗皇强梢酝ㄟ^(guò)input改變內(nèi)容的。

node.nodeValue = vm.data[name];

劫持get和set方法

想想我們的思路,我們input觸發(fā)時(shí),是這樣修改data值的

 vm.data[name] = e.target.value;

我們希望觸發(fā)點(diǎn)東西,但那是下一章的內(nèi)容,無(wú)論如何,我們先劫持這些vm.data的所有屬性的get和set方法。
以后究竟要怎么搞事我們?cè)贈(zèng)Q定。

怎么劫持呢?

我們只有在Vue中寫(xiě)入一個(gè)observe,用于遍歷所有屬性,進(jìn)行g(shù)et和set的劫持。

function Vue (options) {
        this.data = options.data;
        var data = this.data;

        observe(data, this);

        var id = options.el;
        var dom = nodeToFragment(document.getElementById(id), this);

        // 編譯完成后,將 dom 返回到 app 中
        document.getElementById(id).appendChild(dom);
    }

接下來(lái)就是怎么寫(xiě)這個(gè)observe。

首先必須遍歷所有節(jié)點(diǎn)。
然后用defineProperty設(shè)置get和set方法,這是我們暫且在set時(shí)打印新值,看看data是否真的改變了

function observe (obj, vm) {
        Object.keys(obj).forEach(function (key) {
            defineReactive(vm.data, key, obj[key]);
        })
}

function defineReactive (obj, key, val) {
        Object.defineProperty(obj, key, {
            get: function () {
                return val
            },
            set: function (newVal) {
                if (newVal === val) return
                val = newVal;
                console.log(obj[key])
            }
        });
    }

以上就是我們的第二部分,主要實(shí)現(xiàn)兩部分:

1、設(shè)置觀(guān)察函數(shù)observe,改寫(xiě)get和set
2、監(jiān)聽(tīng)元素節(jié)點(diǎn)的input,當(dāng)符合條件(匹配正則)時(shí),首先從vm.data.key獲取相應(yīng)屬性的值,觸發(fā)get。
當(dāng)input的內(nèi)容發(fā)生改變時(shí),把該值賦給vm.data.key,觸發(fā)set。

codepen完整代碼在這里

image.png

可以看到當(dāng)input的值發(fā)生改變時(shí),vm.data.key也發(fā)生改變,這里我們先用console來(lái)判斷這個(gè)值是否改變了。

至此,第二部分已經(jīng)完成。

任務(wù)3:把data的值渲染到dom里面

上面已經(jīng)實(shí)現(xiàn)了值的雙向傳遞,我們主要用了屬性劫持和方法監(jiān)聽(tīng)(input)。

接下來(lái)想想我們?cè)撊绾伟裠ata渲染進(jìn)dom。

記得我們剛開(kāi)始的極簡(jiǎn)版demo嗎?

Object.defineProperty(obj,'hello',{
 set:function(newVal){
   var p = document.getElementById('reflect');
   p.innerHTML = newVal;
 }
})

我們是通過(guò)找到p元素,當(dāng)data改變時(shí),直接把新值傳給p元素。

但是有一個(gè)問(wèn)題,我們這里假設(shè)已經(jīng)知道p元素與data雙向綁定了。

如果我們不知道呢?
仔細(xì)看看這句代碼p.innerHTML = newVal;
到底哪一個(gè)元素的innerHTML才是newVal?

所以我們的關(guān)鍵是找到哪一個(gè)節(jié)點(diǎn)的對(duì)應(yīng)哪一個(gè)屬性(vm.data)

這是vue最核心的部分之一

假設(shè)我們有一個(gè)容器,當(dāng)我們get內(nèi)容時(shí),那這個(gè)節(jié)點(diǎn)肯定與data綁定了,此時(shí)我們把這個(gè)節(jié)點(diǎn)push進(jìn)這個(gè)容器,這樣只要每次data改變,我們遍歷所有的節(jié)點(diǎn)不就可以了嗎?

vue管這個(gè)容器叫"依賴(lài)"(dep),或許表示所有dep里的節(jié)點(diǎn)都會(huì)依賴(lài)這個(gè)容器dep。

這么說(shuō)有點(diǎn)繞口,比如這樣,我們?cè)诿總€(gè)屬性上綁定一個(gè)容器dep,容器上有個(gè)數(shù)組subs,當(dāng)有節(jié)點(diǎn)get這個(gè)屬性的值時(shí),我們就記錄下這個(gè)節(jié)點(diǎn),push進(jìn)subs。

而當(dāng)我們的data改變時(shí),就可以遍歷所有的節(jié)點(diǎn),讓他們更新dom了。

意思就是連接節(jié)點(diǎn)和data的基本思路。具體怎么實(shí)現(xiàn)呢?

首先我們每個(gè)屬性各自都需要一個(gè)依賴(lài)dep,我們可以寫(xiě)一個(gè)構(gòu)造函數(shù)Dep,實(shí)例對(duì)象維護(hù)一個(gè)數(shù)組,用于存放節(jié)點(diǎn)。

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

這個(gè)依賴(lài)還必須有兩個(gè)功能,添加和更新。
有節(jié)點(diǎn)綁定了,就把它添加到數(shù)組。
有內(nèi)容(data)更新了,就”告訴“所有節(jié)點(diǎn)去更新dom

所以原型還需要添加這兩個(gè)方法:

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

   notify: function() {
       this.subs.forEach(function(sub) {
           sub.update();
       });
   }
}

這個(gè)dep是跟著屬性走的,所以我們需要在遍歷屬性時(shí)創(chuàng)建。

function defineReactive (obj, key, val) {
   var dep = new Dep();

   Object.defineProperty(obj, key, {
       get: function () {
           // 添加訂閱者 watcher 到主題對(duì)象 Dep
           if (添加一個(gè)條件) dep.addSub();
           return val
       },
       set: function (newVal) {
           if (newVal === val) return
           val = newVal;
           // 作為發(fā)布者發(fā)出通知
           dep.notify();
       }
   });

這里的get我們應(yīng)該把節(jié)點(diǎn)push進(jìn)容器數(shù)組,但是想一想,是不是連接建立后我們才要把這個(gè)節(jié)點(diǎn)push進(jìn)去呢?怎么判斷是不是建立連接了呢?

記得我們的compile函數(shù)嗎?

if (attr[i].nodeName == 'v-model') {
    var name = attr[i].nodeValue; // 獲取 v-model 綁定的屬性名
    node.addEventListener('input', function (e) {
       // 給相應(yīng)的 data 屬性賦值,進(jìn)而觸發(fā)該屬性的 set 方法
       vm.data[name] = e.target.value;
    });
    node.value = vm.data[name]; // 將 data 的值賦給該 node
    node.removeAttribute('v-model');
}

此時(shí)是不是通過(guò)判斷節(jié)點(diǎn)是否有”v-model“,但有時(shí),從data里獲取v-model綁定的屬性值?

這是連接建立的關(guān)鍵,所以再這之后,我們可以判斷可以把節(jié)點(diǎn)push進(jìn)去了。

但是想想,光是節(jié)點(diǎn)夠嗎?我們是否還需要更新的函數(shù)?能否寫(xiě)在一起?
所以我們可以建立一個(gè)Watcher函數(shù),用于更新dom,這樣當(dāng)有data改變時(shí),只要dep告訴我們?nèi)ジ滤械腤atcher就可以了。

這個(gè)Watcher就相當(dāng)于一個(gè)容易,包裹了dom元素的內(nèi)容還有更新方法。

所以我們push進(jìn)dep的是一個(gè)個(gè)的Watcher,有更新就調(diào)用Watcher的update方法就可以了。

Watcher應(yīng)該像下面這么寫(xiě)

function Watcher (vm, node, name, nodeType) {
        Dep.target = this;
        this.name = name;
        this.node = node;
        this.vm = vm;
        this.nodeType = nodeType;
        this.update();
        Dep.target = null;
    }

    Watcher.prototype = {
        update: function () {
            this.get();
            if (this.nodeType == 'text') {
                this.node.nodeValue = this.value;
            }
            if (this.nodeType == 'input') {
                this.node.value = this.value;
            }
        },
        // 獲取 data 中的屬性值
        get: function () {
            this.value = this.vm.data[this.name]; // 觸發(fā)相應(yīng)屬性的 get
        }
    }

這里的Dep.target是作為節(jié)點(diǎn)與data綁定的標(biāo)志,一旦這個(gè)存在了,說(shuō)明我們要去get方法那里push Watcher了。
之后我們要清除這個(gè)Dep.target,有其他Watcher實(shí)例對(duì)象創(chuàng)建時(shí)再賦值,傳給dep.
因此相當(dāng)于一個(gè)臨時(shí)的標(biāo)志容器,且是全局的。

現(xiàn)在看看上面劫持get時(shí)的if條件,應(yīng)該知道怎么寫(xiě)了吧。
就是Dep.target存在的時(shí)候

get: function () {
 // 添加訂閱者 watcher 到主題對(duì)象 Dep
 if (Dep.target) dep.addSub();
 return val
}

至此,我們的程序就完成了。
測(cè)試是沒(méi)有問(wèn)題的。

image.png

下面是我畫(huà)的流程圖,可以幫助理解。

image.png

完整示例在這里

回顧

我們創(chuàng)建了一個(gè)類(lèi)似vue的雙向綁定機(jī)制,怎么實(shí)現(xiàn)的呢?

我想從data獲取數(shù)值,于是我們改變dom,通過(guò)匹配正則,符合條件的把data的值賦給dom的value或nodeValue

我們想把內(nèi)容變更傳遞給data,于是我們改造所有的data.
各自給它們一個(gè)容器dep的數(shù)組subs,當(dāng)連接建立(標(biāo)志是)同樣是正則匹配上了。

此時(shí)新建一個(gè)watcher,用于標(biāo)識(shí)dom和存放更新dom的方法。

當(dāng)input的內(nèi)容改變時(shí),觸發(fā)obj的set方法,set方法命令subs更新dom,subs遍歷所有watcher,讓所有watcher中的方法去更新自己的dom。

初次寫(xiě)這么長(zhǎng)的文章,剛開(kāi)始理解這個(gè)機(jī)制對(duì)我來(lái)說(shuō)也有點(diǎn)吃力,但總算搞懂了。

以上,我的解釋還有許多不足,歡迎指教,感謝閱讀。

如果有可取之處,一個(gè)贊便感激不已。??

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀(guān)點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容