VUE雙向綁定原理

前言

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

剛開始看了很多文章绑谣,還是沒看懂党窜。

最后我是看這篇文章看懂的,其他的要么略過太多細節(jié)域仇,看著有種斷層感刑然,根本不知道怎么突然到這一步了。有些要么跟著代碼講思路暇务,有點亂泼掠。

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

所以我決定重新捋一捋里面的思想腻豌,把細節(jié)盡可能說清楚家坎,讓跟我一樣沒學(xué)過任何源碼的人也能搞清楚。

補充一下個人想法吝梅,對于這些精妙的思維接觸不多虱疏,而這些往往是決定我們的高度的,是一個使用者還是研究者苏携?有時候眼光的高低做瞪,決定著我們未來道路的長短。

大致原理

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

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

每個組件實例都有相應(yīng)的 watcher 實例對象装蓬,它會在組件渲染的過程中把屬性記錄為依賴,之后當(dāng)依賴項的 setter 被調(diào)用時纱扭,會通知 watcher 重新計算牍帚,從而致使它關(guān)聯(lián)的組件得以更新。

image.png

看不懂乳蛾?沒關(guān)系暗赶,有個大概印象就可以了。

defineProperty是什么鬼屡久?

為什么要先從這里說起忆首?因為這是眾所周知vue雙向綁定的原理。

MDN解釋在這里

簡單地說被环,就是對于我們的對象的屬性糙及,我們可以通過defineProperty來設(shè)置它的getset方法,一旦獲取值筛欢,就會觸發(fā)get方法浸锨,一旦修改值,就會觸發(fā)set方法版姑。

比如下面簡單的例子

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在線預(yù)覽

用defineProperty實現(xiàn)一個極簡的雙向綁定例子

既然這個方法這么有用柱搜,我們設(shè)置一個容器obj,直接在set里面渲染我們的html剥险,然后監(jiān)聽input的keyup事件聪蘸,當(dāng)事件觸發(fā)時,修改obj對應(yīng)的值,從而再觸發(fā)html的改變健爬。

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

<!--html-->
<input type="text" id="content">請輸入內(nèi)容
<br><br>
他輸入的內(nèi)容是:<p id="reflect" style="color:red;"></p>
var obj={};
//假設(shè)我們監(jiān)聽'hello'這個屬性
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

在線預(yù)覽

分解實際任務(wù)

雖然上面的簡單演示我們貌似做出來了,但是與實際的樣子卻不一樣娜遵。我們看看蜕衡。

image.png
image.png

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

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

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

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

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

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

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

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

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

既然要加載data里的屬性值绵脯,我們就要考慮兩種情況,app里的子節(jié)點的類型休里,

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

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

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

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

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

創(chuàng)建fragment倉庫

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

compile方法在下面解釋

替換html

這里主要用的是正則表達式的檢測方法阳距,其中對RegExp.$1的用法不了解的同學(xué)可以Google一下,這是正則一個非常巧妙而且強大的地方结借。

function compile (node, vm) {
        var reg = /\{\{(.*)\}\}/;
        // 節(jié)點類型為元素
        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é)點類型為 text
        if (node.nodeType === 3) {
            if (reg.test(node.nodeValue)) {
                var name = RegExp.$1; // 獲取匹配到的字符串
                name = name.trim();
                node.nodeValue = vm.data[name]
            }
        }
    }

我們看看上面的代碼筐摘,主要就是判斷子節(jié)點的類型,一旦是元素節(jié)點船老,我們就給它的input事件綁定方法咖熟,把input的value傳給vm.data[name],如果是文本節(jié)點柳畔,就直接替換.
這里要注意馍管,element節(jié)點我們用的是node.value,text節(jié)點我們用的是node.nodeValue薪韩,這兩個寫法的區(qū)別可以自行Google一下.

最后再創(chuàng)建一個Vue實例


image.png

下面是codepen的實例


image.png

codepen

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

再來看任務(wù)二的實現(xiàn)思路:當(dāng)我們在輸入框輸入數(shù)據(jù)的時候确沸,首先觸發(fā)input事件(或者keyup、change事件)俘陷,在相應(yīng)的事件處理程序中罗捎,我們獲取輸入框的value并賦值給vm實例的text屬性。我們會利用defineProperty將data中的text劫持為vm的訪問器屬性拉盾,因此給vm.data.text賦值桨菜,就會觸發(fā)set方法。在set方法中主要做兩件事盾剩,第一是更新屬性的值雷激,第二留到任務(wù)三再說。

具體怎么做呢?

監(jiān)聽input事件

input節(jié)點

當(dāng)我們觸發(fā)input時告私,要在dom節(jié)點上綁定事件屎暇?
怎么綁定呢?記得我們前面的nodeToFragment函數(shù)嗎驻粟?就是用于遍歷所有的子節(jié)點根悼,進行節(jié)點修改的凶异。

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

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

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

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

文本節(jié)點

而對于文本節(jié)點母廷,是不需要的轻黑,我們只需要從vm.data獲取數(shù)據(jù)就可以了。因為它不是可以通過input改變內(nèi)容的琴昆。

node.nodeValue = vm.data[name];

劫持get和set方法

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

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

我們希望觸發(fā)點東西业舍,但那是下一章的內(nèi)容抖拦,無論如何,我們先劫持這些vm.data的所有屬性的get和set方法舷暮。
以后究竟要怎么搞事我們再決定态罪。

怎么劫持呢?

我們只有在Vue中寫入一個observe脚牍,用于遍歷所有屬性向臀,進行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);
    }

接下來就是怎么寫這個observe。

首先必須遍歷所有節(jié)點君纫。
然后用defineProperty設(shè)置get和set方法驯遇,這是我們暫且在set時打印新值,看看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])
            }
        });
    }

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

1叉庐、設(shè)置觀察函數(shù)observe,改寫get和set
2会喝、監(jiān)聽元素節(jié)點的input陡叠,當(dāng)符合條件(匹配正則)時,首先從vm.data.key獲取相應(yīng)屬性的值肢执,觸發(fā)get枉阵。
當(dāng)input的內(nèi)容發(fā)生改變時,把該值賦給vm.data.key预茄,觸發(fā)set兴溜。

codepen完整代碼在這里

image.png

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

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

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

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

接下來想想我們該如何把data渲染進dom。

記得我們剛開始的極簡版demo嗎岛心?

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

我們是通過找到p元素来破,當(dāng)data改變時,直接把新值傳給p元素鹉梨。

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

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

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

這是vue最核心的部分之一

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

vue管這個容器叫"依賴"(dep),或許表示所有dep里的節(jié)點都會依賴這個容器dep商乎。

這么說有點繞口,比如這樣,我們在每個屬性上綁定一個容器dep祭阀,容器上有個數(shù)組subs鹉戚,當(dāng)有節(jié)點get這個屬性的值時,我們就記錄下這個節(jié)點专控,push進subs抹凳。

而當(dāng)我們的data改變時,就可以遍歷所有的節(jié)點伦腐,讓他們更新dom了赢底。

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

首先我們每個屬性各自都需要一個依賴dep,我們可以寫一個構(gòu)造函數(shù)Dep,實例對象維護一個數(shù)組幸冻,用于存放節(jié)點。

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

這個依賴還必須有兩個功能咳焚,添加和更新洽损。
有節(jié)點綁定了,就把它添加到數(shù)組黔攒。
有內(nèi)容(data)更新了趁啸,就”告訴“所有節(jié)點去更新dom

所以原型還需要添加這兩個方法:

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

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

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

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

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

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

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

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

此時是不是通過判斷節(jié)點是否有”v-model“,但有時,從data里獲取v-model綁定的屬性值崖疤?

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

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

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

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

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

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é)點與data綁定的標志孤里,一旦這個存在了,說明我們要去get方法那里push Watcher了橘洞。
之后我們要清除這個Dep.target扭粱,有其他Watcher實例對象創(chuàng)建時再賦值,傳給dep.
因此相當(dāng)于一個臨時的標志容器震檩,且是全局的。

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

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

至此,我們的程序就完成了套才。
測試是沒有問題的迂猴。

image.png

下面是我畫的流程圖,可以幫助理解背伴。

image.png

完整示例在這里

回顧

我們創(chuàng)建了一個類似vue的雙向綁定機制沸毁,怎么實現(xiàn)的呢峰髓?

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

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

此時新建一個watcher,用于標識dom和存放更新dom的方法炭懊。

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

初次寫這么長的文章父阻,剛開始理解這個機制對我來說也有點吃力愈涩,但總算搞懂了。

以上至非,我的解釋還有許多不足钠署,歡迎指教,感謝閱讀荒椭。

如果有可取之處谐鼎,一個贊便感激不已。??

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末趣惠,一起剝皮案震驚了整個濱河市狸棍,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌味悄,老刑警劉巖草戈,帶你破解...
    沈念sama閱讀 206,839評論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異侍瑟,居然都是意外死亡唐片,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,543評論 2 382
  • 文/潘曉璐 我一進店門涨颜,熙熙樓的掌柜王于貴愁眉苦臉地迎上來费韭,“玉大人,你說我怎么就攤上這事庭瑰⌒浅郑” “怎么了?”我有些...
    開封第一講書人閱讀 153,116評論 0 344
  • 文/不壞的土叔 我叫張陵弹灭,是天一觀的道長督暂。 經(jīng)常有香客問我揪垄,道長,這世上最難降的妖魔是什么逻翁? 我笑而不...
    開封第一講書人閱讀 55,371評論 1 279
  • 正文 為了忘掉前任饥努,我火速辦了婚禮,結(jié)果婚禮上卢未,老公的妹妹穿的比我還像新娘肪凛。我一直安慰自己,他們只是感情好辽社,可當(dāng)我...
    茶點故事閱讀 64,384評論 5 374
  • 文/花漫 我一把揭開白布伟墙。 她就那樣靜靜地躺著,像睡著了一般滴铅。 火紅的嫁衣襯著肌膚如雪戳葵。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,111評論 1 285
  • 那天汉匙,我揣著相機與錄音拱烁,去河邊找鬼。 笑死噩翠,一個胖子當(dāng)著我的面吹牛戏自,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播伤锚,決...
    沈念sama閱讀 38,416評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼擅笔,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了屯援?” 一聲冷哼從身側(cè)響起猛们,我...
    開封第一講書人閱讀 37,053評論 0 259
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎狞洋,沒想到半個月后弯淘,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,558評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡吉懊,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,007評論 2 325
  • 正文 我和宋清朗相戀三年庐橙,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片借嗽。...
    茶點故事閱讀 38,117評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡怕午,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出淹魄,到底是詐尸還是另有隱情,我是刑警寧澤堡距,帶...
    沈念sama閱讀 33,756評論 4 324
  • 正文 年R本政府宣布甲锡,位于F島的核電站兆蕉,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏缤沦。R本人自食惡果不足惜虎韵,卻給世界環(huán)境...
    茶點故事閱讀 39,324評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望缸废。 院中可真熱鬧包蓝,春花似錦、人聲如沸企量。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,315評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽届巩。三九已至硅瞧,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間恕汇,已是汗流浹背腕唧。 一陣腳步聲響...
    開封第一講書人閱讀 31,539評論 1 262
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留瘾英,地道東北人枣接。 一個月前我還...
    沈念sama閱讀 45,578評論 2 355
  • 正文 我出身青樓,卻偏偏與公主長得像缺谴,于是被迫代替她去往敵國和親但惶。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 42,877評論 2 345