一文徹底搞懂Vue的MVVM響應(yīng)式原理
前言
這些天都在面試,每當我被面試官們問到Vue響應(yīng)式原理時,回答得都很膚淺。如果您在回答時也只是停留在MVVM框架是model層、view層和viewmodel層這樣的雙向數(shù)據(jù)綁定,那么建議您徹底搞定Vue的MVVM響應(yīng)式原理。
(全文約13900字,閱讀時間約25分鐘。建議有一定vue基礎(chǔ)后再閱讀)
怎么來的?
要想清楚的知道某件事物的原理,就該追根溯源,刨根問底。在Vue之前,各框架都是怎么去實現(xiàn)MVVM雙向綁定的呢?
大致分為以下幾種:
- 發(fā)布者-訂閱者模式(backbone.js)臟值檢查(angular.js)數(shù)據(jù)劫持(vue.js)
- 發(fā)布者-訂閱者模式,通過sub、pub實現(xiàn)視圖的監(jiān)聽綁定,通常的做法是vm.$set(‘property’, value)。
臟值檢查,內(nèi)部其實就是setnterval
,當然,為了節(jié)約性能,不顯的那么low,一般是對特定的事件執(zhí)行臟值檢查:
DOM事件,如輸入文本、點擊按鈕(ng-click)XHR響應(yīng)事件($http)瀏覽器locaton 變更事件($location)Timer事件($timeout, $interval)執(zhí)行$digest()或 $apply()
vue則是采用發(fā)布者-訂閱者模式,通過Object.defineProperty()來劫持各個屬性的getter和setter,在數(shù)據(jù)變動時發(fā)布消息給訂閱者,觸發(fā)相應(yīng)的監(jiān)聽回調(diào)。
Vue的MVVM原理
話不多說,先上圖
首先,請盡可能記住這一張圖,并能夠自己畫出來,后面所有原理都是圍繞這張圖展開。感覺很懵逼對么?不過,相信許多人在Vue官方文檔里看過這張圖:
其實,這兩張圖要表達的是一個意思——二者都表示了雙向數(shù)據(jù)綁定的原理流程,官方文檔中展示的更為簡潔一些。看您更能接受哪種描述,后面自己實現(xiàn)響應(yīng)式原理后,這兩張圖都能記得住了。
這里就用第一張圖來介紹,在我們創(chuàng)建一個vue實例時,其實vue做了這些事情:
創(chuàng)建了入口函數(shù),分別new了一個數(shù)據(jù)觀察者Observer和一個指令解析器Compile;Compile解析所有DOM節(jié)點上的vue指令,提交到更新器Updater(實際上是一個對象);Updater把數(shù)據(jù)(如{{}},msg,@click)替換,完成頁面初始化渲染;Observer使用Object.defineProperty劫持數(shù)據(jù),其中的getter和setter通知變化給依賴器Dep;Dep中加入觀察者Watcher,當數(shù)據(jù)發(fā)生變化時,通知Watcher更新;Watcher取到舊值和新值,在回調(diào)函數(shù)中通知Updater更新視圖;Compile中每個指令都new了一個Watcher,用于觸發(fā)Watcher的回調(diào)函數(shù)進行更新。 簡單實現(xiàn)Vue的響應(yīng)式原理 完整源碼:詳見
按照前面的思路,下面我們來一步一步實現(xiàn)一個簡單的MVVM響應(yīng)式系統(tǒng),加深我們對響應(yīng)式原理的理解。
創(chuàng)建一個html示例
現(xiàn)在我們創(chuàng)建了一個簡單的Vue渲染示例,我們要做的就是使用自己的MVue去把里面的data、msg、htmlStr、methods中的數(shù)據(jù)都渲染到標簽上。完成數(shù)據(jù)驅(qū)動視圖、視圖驅(qū)動數(shù)據(jù)驅(qū)動視圖。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Document</title> </head> <body> <div id ="app"> <h2>{{person.name}} -- {{person.age}}</h2> <h3>{{person.fav}}</h3> <ul> <li>1</li> <li>2</li> <li>3</li> </ul> <h3>{{msg}}</h3> <div v-text="person.fav"></div> <div v-text="msg"></div> <div v-html="htmlStr"></div> <input type="text" v-model="msg"> <button v-on:click="handleClick">click</button> <button @click="handleClick">@click2</button> </div> <script src="./Observer.js"></script> <script src="./MVue.js"></script> <script> //創(chuàng)建Vue實例,得到 ViewModel const vm = new MVue({ el: '#app', data: { person: { name: "我的vue", age: 18, fav: "坦克世界" }, msg: "學習MVVM框架原理", htmlStr: "<h3>熱愛前端,金子總會發(fā)光</h3>" }, methods: { handleClick() { console.log(this); } } }); </script> </body> </html>
在MVue.js中創(chuàng)建MVue入口
class MVue { constructor(options) { this.$el = options.el; this.$data = options.data; this.$options = options; if (this.$el) { // 1、實現(xiàn)一個數(shù)據(jù)觀察者 // 2、實現(xiàn)一個指令觀察者 new Compile(this.$el, this); } }
思路:
首先自然是要構(gòu)建MVue這一個類,MVue類構(gòu)造函數(shù)中需要用到options參數(shù)和其中的el、data。
然后需要保證el存在條件下,先實現(xiàn)一個指令解析器Compile,后面再去實現(xiàn)Observer觀察者。
顯然,Compile應(yīng)該需要傳入MVue實例的el和整個MVue實例,用來解析標簽的指令。
創(chuàng)建Compile
思路:在解析標簽指令之前,我們首先做的是:
判斷el是不是元素節(jié)點,如果不是,就要取到el這個標簽,然后傳入vm實例;遞歸拿到所有子節(jié)點,便于下一步去解析它們。【注意:這一步會頻繁觸發(fā)頁面的回流和重繪,所以我們需要把節(jié)點先存入文檔碎片對象中,就相當于把他們放到了內(nèi)存中,減少了頁面的回流和重繪?!吭谖臋n碎片對象中編譯好模板;最后再把文檔碎片對象追加到根元素上。
class Compile { constructor(el, vm) { this.el = this.isElementNode(el) ? el : document.querySelector(el); this.vm = vm; // 獲取文檔碎片對象 放入內(nèi)存中會減少頁面的回流和重繪 const fragment = this.node2Fragment(this.el); // 編譯模板 this.compile(fragment); // 追加子元素到根元素 this.el.appendChild(fragment); }
這里我們先自己定義了幾個方法:
- 判斷是否是元素節(jié)點
isElementNode(el)
、 - 存入文檔碎片對象
node2Fragment(el)
、 - 編譯模板
compile(fragment)
分別在構(gòu)造函數(shù)之后去實現(xiàn):
node2Fragment(el) { // 創(chuàng)建文檔碎片對象 const f = document.createDocumentFragment(); // 遞歸放入 let firstChild; while ((firstChild = el.firstChild)) { f.appendChild(firstChild); } return f; } isElementNode(node) { return node.nodeType === 1; }
編譯模板compile(fragment)
實現(xiàn)思路:遞歸獲取所有子節(jié)點,判斷節(jié)點是元素節(jié)點還是文本節(jié)點,再分別定義兩個方法compileElement(child)
和compileText(child)
去處理這兩種節(jié)點。
compile(fragment) { // 獲取子節(jié)點 const childNodes = fragment.childNodes; [...childNodes].forEach((child) => { if (this.isElementNode(child)) { // 是元素節(jié)點 // 編譯元素節(jié)點 // console.log("元素節(jié)點",child); this.compileElement(child); } else { // 是文本節(jié)點 // 編譯文本節(jié)點 // console.log("文本節(jié)點", child); this.compileText(child); } // 一層一層遞歸遍歷 if (child.childNodes && child.childNodes.length) { this.compile(child); } }); }
好了,現(xiàn)在Compile的一個基本框架已經(jīng)搭好了。希望看到這里的您還沒有犯困,打起精神來!現(xiàn)在,我們繼續(xù)往下淦元素節(jié)點和文本節(jié)點的處理。
1.處理元素節(jié)點compileElement(child)
思路:
拿到標簽里的每個vue指令,如v-text v-html v-model v-on:click,顯然它們都是以v-開頭的,當然還有@開頭的指令也不要忘記把節(jié)點、節(jié)點值、vm實例、(on的事件名)傳入compileUtil
對象,后面用它處理每個指令,屬性對應(yīng)指令方法;別忘了,最后的視圖標簽上是沒有vue指令的,所以我們要把它們從節(jié)點屬性中刪去。
compileElement(node) { const attributes = node.attributes; [...attributes].forEach((attr) => { const { name, value } = attr; if (this.isDirective(name)) { // 是一個指令 v-text v-html v-model v-on:click const [, directive] = name.split("-"); // text html model on:click const [dirName, eventName] = directive.split(":"); // text html model on // 更新數(shù)據(jù) 數(shù)據(jù)驅(qū)動視圖 compileUtil[dirName](node, value, this.vm, eventName); // 刪除有指令標簽上的屬性 node.removeAttribute("v-" + directive); } else if (this.isEventName(name)) { // @click='handleClick' let [, eventName] = name.split('@'); compileUtil["on"](node, value, this.vm, eventName); } }); }
判斷是否是指令,以v-開頭
isDirective(attrName) { return attrName.startsWith("v-"); }
2.處理文本節(jié)點compileText(child)
主要使用正則匹配雙大括號即可:
compileText(node) { // {{}} v-text const content = node.textContent; if (/\{\{(.+?)\}\}/.test(content)) { compileUtil["text"](node, content, this.vm); } }
3.實現(xiàn)compileUtil指令處理
思路:
每個指令對應(yīng)各自方法,除了on需要額外傳入事件名稱,其他的指令處理函數(shù)只需要傳節(jié)點、值(或表達式expr)、vm
實例:
const compileUtil = { text(node, expr, vm) { }, html(node, expr, vm) { }, model(node, expr, vm) { }, on(node, expr, vm, eventName) { } };
沒有一下子放出代碼來的話,骨架原來這么簡單啊,繼續(xù)逐個擊破它們!
v-html指令處理,思路:拿到值,把值傳給updater更新器,更新,完事兒。
html(node, expr, vm) { const value = this.getVal(expr, vm); this.updater.htmlUpdater(node, value); },
v-model指令處理,同上。先實現(xiàn)數(shù)據(jù)=>視圖這條線,雙向綁定最后實現(xiàn)。
model(node, expr, vm) { const value = this.getVal(expr, vm); this.updater.modelUpdater(node, value); },
比較復(fù)雜的,v-on,思路:獲取事件名,從methods中取到對應(yīng)的函數(shù),添加到事件中,注意this要綁定給vm實例,false默認事件冒泡。
on(node, expr, vm, eventName) { // 獲取事件名, 從method里面取函數(shù) let fn = vm.$options.methods && vm.$options.methods[expr]; node.addEventListener(eventName, fn.bind(vm), false) },
v-text指令處理:
text(node, expr, vm) { // expr:msg: "學習MVVM框架原理" // 對傳入不同的字符串不同操作 <div v-text="person.name"></div> // {{}} let value; if (expr.indexOf('{{') !== -1) { // {{person.name}} -- {{person.age}} value = expr.replace(/\{\{(.+?)\}\}/g, (...args) => { return this.getVal(args[1], vm) }) } else { value = this.getVal(expr, vm); } this.updater.textUpdater(node, value); },
用到args這個數(shù)組,console.log一下args,發(fā)現(xiàn)args[1]就有我們要找的具體屬性:
例如,取到person.name
后,傳入到this.getVal('person.name',vm)
,最后能取到vm.$data.person.name
。
怎么拿到它們對應(yīng)的值呢?
顯然,不論是htmlStr、msg、person,它們都在實例vm的data內(nèi),在自定義方法getVal
中,可以使用split分割小圓點“.”得到數(shù)組,再用高逼格的reduce方法去遍歷找到data每個屬性(對象)下的每個屬性的值,像這樣:
getVal(expr, vm) { return expr.split(".").reduce((data, currentVal) => { return data[currentVal]; }, vm.$data); },
(不記得怎么用reduce?請在右上角新建標簽頁,去CSDN上補一補。)進階拿到雙大括號內(nèi)對應(yīng)的屬性的值:
getContentVal(expr, vm) { return expr.replace(/\{\{(.+?)\}\}/g, (...args) => { console.log(args); return this.getVal(args[1], vm); }); },
更新器Updater更新數(shù)據(jù)
在指令方法的后面接著創(chuàng)建一個updater屬性,實則是一個類,我們把它親切地稱作更新器,長得還很一目了然,您馬上就能記住它的樣子:
// 更新的函數(shù) updater: { textUpdater(node, value) { node.textContent = value; }, htmlUpdater(node, value) { node.innerHTML = value; }, modelUpdater(node, value){ node.value = value; } },
在每個指令方法取到值后,更新到node節(jié)點上。
至此,我們已經(jīng)完成了原理圖上的MVVM到Compile到Updater這一條線:
實現(xiàn)數(shù)據(jù)觀察者Observer
class MVue { constructor(options) { this.$el = options.el; this.$data = options.data; this.$options = options; if (this.$el) { // 1、實現(xiàn)一個數(shù)據(jù)觀察者 new Observer(this.$data); // 2、實現(xiàn)一個指令觀察者 new Compile(this.$el, this); } }
Observer類構(gòu)造函數(shù)應(yīng)該傳什么給它?對,Observer要監(jiān)聽所有數(shù)據(jù),所以我們將vm實例的data作為參數(shù)傳入。
- 遞歸,將data中所有的屬性、對象、子對象……都遍歷出來
- 對每個key,使用Object.defineProperty劫持數(shù)據(jù)(Object.defineProperty()的作用就是直接在一個對象上定義一個新屬性,或者修改一個已經(jīng)存在的屬性)
- Object.defineProperty下有g(shù)et方法和set方法,也就是官方原理圖上的getter和stter啦
- 在劫持數(shù)據(jù)之前,創(chuàng)建依賴器Dep實例dep
- 對于gettter,訂閱數(shù)據(jù)變化時,往dep中添加觀察者;
- 對于setter,當數(shù)據(jù)變化時,將newVal賦值為新值,并用notify通知dep變化。(此處正好對應(yīng)官方原理圖)
4、5、6這最后三點可以說是MVVM實現(xiàn)中最關(guān)鍵、最巧妙的3步,正是這畫龍點睛的三筆,把整個系統(tǒng)橋梁成功架起來,注意它們各自放置在代碼中位置。
class Observer { constructor(data) { this.observer(data); } observer(data) { /** { person:{ name:'張三', fav: { a: '愛好1', b: '愛好2' } } } */ if (data && typeof data === "object") { Object.keys(data).forEach((key) => { this.defineReactive(data, key, data[key]); }); } } defineReactive(obj, key, value) { // 遞歸遍歷 this.observer(value); const dep = new Dep(); // 劫持數(shù)據(jù) Object.defineProperty(obj, key, { // 是否可遍歷 enumerable: true, // 是否可以更改編寫 configurable: false, // 編譯之前,初始化的時候 get() { // 訂閱數(shù)據(jù)變化時,往Dep中添加觀察者 Dep.target && dep.addSub(Dep.target); return value; }, // 外界修改數(shù)據(jù)的時候 set: (newVal) => { // 新值也要劫持 this.observer(newVal); // 這里的this要指向當前的實例,所以改用箭頭函數(shù)向上查找 // 判斷新值是否有變化 if (newVal !== value) { value = newVal; } // 告訴Dep通知變化 dep.notify(); }, }); } }
數(shù)據(jù)依賴器Dep
主要作用:
- 收集要更新的觀察者
- 通知每個觀察者去更新
// 數(shù)據(jù)依賴器 class Dep { constructor() { this.subs = []; } // 收集觀察者 addSub(watcher) { this.subs.push(watcher); } // 通知觀察者去更新 notify() { console.log("通知了觀察者", this.subs); this.subs.forEach(w =>w.update()) } }
觀察者Watcher
注意Dep.target = this;
這一步,是為了把觀察者掛載到Dep實例上,關(guān)聯(lián)起來。所以當觀察者Watcher獲取舊值后,應(yīng)該解除關(guān)聯(lián),否則會重復(fù)地添加觀察者,以下是未取消關(guān)聯(lián)的錯誤示范:
最后,使用callback回調(diào)函數(shù)傳遞要處理的新值給Updater即可。
class Watcher { constructor(vm, expr, callback) { // 把新值通過cb傳出去 this.vm = vm; this.expr = expr; this.callback = callback; // 先把舊值保存起來 this.oldVal = this.getOldVal(); } getOldVal() { // 把觀察者掛載到Dep實例上,關(guān)聯(lián)起來 Dep.target = this; const oldVal = compileUtil.getVal(this.expr, this.vm); // 獲取舊值后,取消關(guān)聯(lián),就不會重復(fù)添加 Dep.target = null; return oldVal; } update() { // 更新,要取舊值和新值 const newVal = compileUtil.getVal(this.expr, this.vm); if (newVal !== this.oldVal) { this.callback(newVal); } } }
如何Updater如何接收從Watcher傳來的新值做回調(diào)處理呢?
只需要在剛剛寫好的compileUtil
對象的每個指令處理方法內(nèi)都new(添加)一個Watcher實例即可。注意text指令方法下new Watcher
實例的value參數(shù),可以用args[1]
傳入,重新處理newVal。
const compileUtil = { getVal(expr, vm) { return expr.split(".").reduce((data, currentVal) => { return data[currentVal]; }, vm.$data); }, setVal(expr, vm, inputVal) { return expr.split(".").reduce((data, currentVal) => { data[currentVal] = inputVal; // 把當前新值復(fù)制給舊值 }, vm.$data); }, getContentVal(expr, vm) { return expr.replace(/\{\{(.+?)\}\}/g, (...args) => { console.log(args); return this.getVal(args[1], vm); }); }, text(node, expr, vm) { // expr:msg: "學習MVVM框架原理" // 對傳入不同的字符串不同操作 <div v-text="person.name"></div> // {{}} let value; if (expr.indexOf('{{') !== -1) { // {{person.name}} -- {{person.age}} value = expr.replace(/\{\{(.+?)\}\}/g, (...args) => { new Watcher(vm, args[1], () => { // 額外處理expr: {{person.name}} -- {{person.age}} // 還要重新處理newVal this.updater.textUpdater(node, this.getContentVal(expr, vm)); }); return this.getVal(args[1], vm) }) } else { value = this.getVal(expr, vm); } this.updater.textUpdater(node, value); }, html(node, expr, vm) { const value = this.getVal(expr, vm); // 綁定觀察者,將來數(shù)據(jù)發(fā)生變化 出發(fā)這里的回調(diào) 進行更新 new Watcher(vm, expr, newVal => { this.updater.htmlUpdater(node, newVal); }) this.updater.htmlUpdater(node, value); }, model(node, expr, vm) { const value = this.getVal(expr, vm); // 綁定更新函數(shù) 數(shù)據(jù)=>驅(qū)動視圖 new Watcher(vm, expr, (newVal) => { this.updater.modelUpdater(node, newVal); }); // 視圖 => 數(shù)據(jù) => 視圖 node.addEventListener('input', e => { // 設(shè)置值 this.setVal(expr, vm, e.target.value); }) this.updater.modelUpdater(node, value); }, on(node, expr, vm, eventName) { // 獲取事件名, 從method里面取函數(shù) let fn = vm.$options.methods && vm.$options.methods[expr]; node.addEventListener(eventName, fn.bind(vm), false) }, bind(node, expr, vm, attrName) { // 類似on。。。 }, // 更新的函數(shù) updater: { textUpdater(node, value) { node.textContent = value; }, htmlUpdater(node, value) { node.innerHTML = value; }, modelUpdater(node, value){ node.value = value; } }, };
實現(xiàn)視圖驅(qū)動數(shù)據(jù)驅(qū)動視圖
還是借著上面這個代碼塊,我們只需要在model
指令方法下,為input
標簽綁定事件,并自定義setVal
方法為node
賦值即可。
到這里,我們已經(jīng)基本完整實現(xiàn)了Vue的MVVM雙向數(shù)據(jù)綁定
小改進:
在MVue實例中,我們一開始使用的是$data
獲取到數(shù)據(jù),這里可以做一層代理proxy
,便于我們省略$data
methods: { handleClick() { // console.log(this); this.person.name = "這是做了一層代理" // 把this.$data 代理成 this this.$data.person.name = "數(shù)據(jù)更改了" } }
還是使用Object.defineProperty數(shù)據(jù)劫持,遍歷data下的每個key,讓getter返回data[key],setter設(shè)置data[key]直接等于newVal即可。
class MVue { constructor(options) { this.$el = options.el; this.$data = options.data; this.$options = options; if (this.$el) { // 1、實現(xiàn)一個數(shù)據(jù)觀察者 new Observer(this.$data); // 2、實現(xiàn)一個指令觀察者 new Compile(this.$el, this); this.proxyData(this.$data); } } proxyData(data) { for(const key in data) { Object.defineProperty(this, key, { get() { return data[key] }, set(newVal) { data[key] = newVal; } }) } } }
總結(jié)
再次體會官方文檔對響應(yīng)式原理的描述:
當我們把一個普通的 JavaScript 對象傳入 Vue 實例作為 data 選項,Vue 將遍歷此對象所有的 property,并使用 Object.defineProperty 把這些 property 全部轉(zhuǎn)為 getter/setter。Object.defineProperty 是 ES5 中一個無法 shim 的特性,這也就是 Vue 不支持 IE8 以及更低版本瀏覽器的原因。
這些 getter/setter 對用戶來說是不可見的,但是在內(nèi)部它們讓 Vue 能夠追蹤依賴,在 property
被訪問和修改時通知變更。這里需要注意的是不同瀏覽器在控制臺打印數(shù)據(jù)對象時對 getter/setter 的格式化并不同,所以建議安裝
vue-devtools 來獲取對檢查數(shù)據(jù)更加友好的用戶界面。每個組件實例都對應(yīng)一個 watcher 實例,它會在組件渲染的過程中把“接觸”過的數(shù)據(jù) property 記錄為依賴。之后當依賴項的
setter 觸發(fā)時,會通知 watcher,從而使它關(guān)聯(lián)的組件重新渲染。
以及開頭時我自己總結(jié)的原理描述:
在我們創(chuàng)建一個vue實例時,其實vue做了這些事情:
創(chuàng)建了入口函數(shù),分別new了一個數(shù)據(jù)觀察者
- Observer和一個指令解析器Compile;
- Compile解析所有DOM節(jié)點上的vue指令,提交到更新器Updater(實際上是一個對象);
- Updater把數(shù)據(jù)(如{{}},msg,@click)替換,完成頁面初始化渲染;Observer使用Object.defineProperty劫持數(shù)據(jù),其中的getter和setter通知變化給依賴器Dep;
- Dep中加入觀察者Watcher,當數(shù)據(jù)發(fā)生變化時,通知Watcher更新;
- Watcher取到舊值和新值,在回調(diào)函數(shù)中通知Updater更新視圖;
- Compile中每個指令都new了一個Watcher,用于觸發(fā)Watcher的回調(diào)函數(shù)進行更新。
在實現(xiàn)代碼的過程中,我們能深刻地體會到Vue的數(shù)據(jù)驅(qū)動視圖,視圖驅(qū)動數(shù)據(jù)驅(qū)動視圖 這一核心的巧妙,也知道了Object.defineProperty具體應(yīng)用場景。
到此這篇關(guān)于一文徹底搞懂Vue的MVVM響應(yīng)式原理的文章就介紹到這了,更多相關(guān) Vue的MVVM響應(yīng)式 內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
詳解Vue2+Echarts實現(xiàn)多種圖表數(shù)據(jù)可視化Dashboard(附源碼)
本篇文章主要介紹了詳解Vue2+Echarts實現(xiàn)多種圖表數(shù)據(jù)可視化Dashboard(附源碼),具有一定的參考價值,感興趣的小伙伴們可以參考一下。2017-03-03vue實現(xiàn)用戶動態(tài)權(quán)限登錄的代碼示例
這篇文章主要介紹了vue如何實現(xiàn)用戶動態(tài)權(quán)限登錄,文中的代碼示例介紹的非常詳細,對大家學習vue有一定的幫助,需要的朋友可以參考閱讀2023-05-05