Vue的MVVM是如何實現的?本文項目詳解原理


 

相信只要你去面試vue,都會被問到vue的雙向數據綁定,你要是就說個mvvm就是視圖模型模型視圖,只要數據改變視圖也會同時更新!那你離被pass就不遠了!

視頻已錄制,地址(www.bilibili.com/video/BV1qJ…)

幾種實現雙向綁定的做法

目前幾種主流的mvc(vm)框架都實現了單向數據綁定,而我所理解的雙向數據綁定無非就是在單向綁定的基礎上給可輸入元素(input、textare等)添加了change(input)事件,來動態修改model和 view,並沒有多高深。所以無需太過介懷是實現的單向或雙向綁定。

實現數據綁定的做法有大致如下幾種:

發布者-訂閱者模式(backbone.js)

臟值檢查(angular.js)

數據劫持(vue.js)

發布者-訂閱者模式: 一般通過sub, pub的方式實現數據和視圖的綁定監聽,更新數據方式通常做法是 vm.set('property', value),這里有篇文章講的比較詳細,有興趣可點這里

這種方式現在畢竟太low了,我們更希望通過 vm.property = value這種方式更新數據,同時自動更新視圖,於是有了下面兩種方式

臟值檢查: angular.js 是通過臟值檢測的方式比對數據是否有變更,來決定是否更新視圖,最簡單的方式就是通過 setInterval() 定時輪詢檢測數據變動,當然Google不會這么low,angular只有在指定的事件觸發時進入臟值檢測,大致如下:

  • DOM事件,譬如用戶輸入文本,點擊按鈕等。( ng-click )
  • XHR響應事件 ( $http )
  • 瀏覽器Location變更事件 ( $location )
  • Timer事件( timeout ,interval )
  • 執行 digest() 或apply()
    另外要注意除了本項目,,結合多年開發經驗整理出2020最新企業級實戰視頻教程, 包括 Vue3.0/Js/ES6/TS/React/node等,想學的進扣扣裙 519293536 免費獲取,小白勿進哦!,

數據劫持: vue.js 則是采用數據劫持結合發布者-訂閱者模式的方式,通過Object.defineProperty()來劫持各個屬性的settergetter,在數據變動時發布消息給訂閱者,觸發相應的監聽回調。

MVVM原理

Vue響應式原理最核心的方法便是通過Object.defineProperty()來實現對屬性的劫持,達到監聽數據變動的目的,無疑這個方法是本文中最重要、最基礎的內容之一

整理了一下,要實現mvvm的雙向綁定,就必須要實現以下幾點:

  • 1、實現一個數據監聽器Observer,能夠對數據對象的所有屬性進行監聽,如有變動可拿到最新值並通知訂閱者
  • 2、實現一個指令解析器Compile,對每個元素節點的指令進行掃描和解析,根據指令模板替換數據,以及綁定相應的更新函數
  • 3、實現一個Watcher,作為連接Observer和Compile的橋梁,能夠訂閱並收到每個屬性變動的通知,執行指令綁定的相應回調函數,從而更新視圖
  • 4、mvvm入口函數,整合以上三者

 

 

先看之前vue的功能

<!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>{{obj.name}}--{{obj.age}}</h2> <h2>{{obj.age}}</h2> <h3 v-text='obj.name'></h3> <h4 v-text='msg'></h4> <ul> <li>1</li> <li>2</li> <li>3</li> </ul> <h3>{{msg}}</h3> <div v-html='htmlStr'></div> <div v-html='obj.fav'></div> <input type="text" v-model='msg'> <img v-bind:src="imgSrc" v-bind:alt="altTitle"> <button v-on:click='handlerClick'>按鈕1</button> <button v-on:click='handlerClick2'>按鈕2</button> <button @click='handlerClick2'>按鈕3</button> </div> <script src="./vue.js"></script> <script> let vm = new MVue({ el: '#app', data: { obj: { name: '小馬哥', age: 19, fav:'<h4>前端Vue</h4>' }, msg: 'MVVM實現原理', htmlStr:"<h3>hello MVVM</h3>", imgSrc:'https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1568782284688&di=8635d17d550631caabfeb4306b5d76fa&imgtype=0&src=http%3A%2F%2Fh.hiphotos.baidu.com%2Fimage%2Fpic%2Fitem%2Fb3b7d0a20cf431ad7427dfad4136acaf2fdd98a9.jpg', altTitle:'眼睛', isActive:'true' }, methods: { handlerClick() { alert(1); console.log(this); }, handlerClick2(){ console.log(this); alert(2) } } }) </script> </body> </html> 復制代碼

實現指令解析器Compile

實現一個指令解析器Compile,對每個元素節點的指令進行掃描和解析,根據指令模板替換數據,以及綁定相應的更新函數,添加監聽數據的訂閱者,一旦數據有變動,收到通知,更新視圖,如圖所示:

初始化

新建MVue.js

class MVue { constructor(options) { this.$el = options.el; this.$data = options.data; //保存 options參數,后面處理數據要用到 this.$options = options; // 如果這個根元素存在則開始編譯模板 if (this.$el) { // 1.實現一個指令解析器compile new Compile(this.$el, this) } } } class Compile{ constructor(el,vm) { // 判斷el參數是否是一個元素節點,如果是直接賦值,如果不是 則獲取賦值 this.el = this.isElementNode(el) ? el : document.querySelector(el); this.vm = vm; } isElementNode(node){ // 判斷是否是元素節點 return node.nodeType === 1 } } 復制代碼

這樣外界可以這樣操作

let vm = new Vue({ el:'#app' }) //or let vm = new Vue({ el:document.getElementById('app') }) 復制代碼

優化編譯使用文檔碎片

<h2>{{obj.name}}--{{obj.age}}</h2> <h2>{{obj.age}}</h2> <h3 v-text='obj.name'></h3> <h4 v-text='msg'></h4> <ul> <li>1</li> <li>2</li> <li>3</li> </ul> <h3>{{msg}}</h3> <div v-html='htmlStr'></div> <div v-html='obj.fav'></div> <input type="text" v-model='msg'> <img v-bind:src="imgSrc" v-bind:alt="altTitle"> <button v-on:click='handlerClick'>按鈕1</button> <button v-on:click='handlerClick2'>按鈕2</button> <button @click='handlerClick2'>按鈕3</button> 復制代碼

接下來,找到子元素的值,比如obj.name,obj.age,obj.fav 找到obj 再找到fav,獲取數據中的值替換掉

但是在這里我們不得不想到一個問題,每次找到一個數據替換,都要重新渲染一遍,可能會造成頁面的回流和重繪,那么我們最好的辦法就是把以上的元素放在內存中,在內存中操作完成之后,再替換掉.

class Compile { constructor(el, vm) { // 判斷el參數是否是一個元素節點,如果是直接賦值,如果不是 則獲取賦值 this.el = this.isElementNode(el) ? el : document.querySelector(el); this.vm = vm; // 因為每次匹配到進行替換時,會導致頁面的回流和重繪,影響頁面的性能 // 所以需要創建文檔碎片來進行緩存,減少頁面的回流和重繪 // 1.獲取文檔碎片對象 const fragment = this.node2Fragment(this.el); // console.log(fragment); // 2.編譯模板 // 3.把子元素的所有內容添加到根元素中 this.el.appendChild(fragment); } node2Fragment(el) { const fragment = document.createDocumentFragment(); let firstChild; while (firstChild = el.firstChild) { fragment.appendChild(firstChild); } return fragment } isElementNode(el) { return el.nodeType === 1; } } 復制代碼

這時候會發現頁面跟之前沒有任何變化,但是經過Fragment的處理,優化頁面渲染性能

編譯模板

// 編譯數據的類 class Compile { constructor(el, vm) { // 判斷el參數是否是一個元素節點,如果是直接賦值,如果不是 則獲取賦值 this.el = this.isElementNode(el) ? el : document.querySelector(el); this.vm = vm; // 因為每次匹配到進行替換時,會導致頁面的回流和重繪,影響頁面的性能 // 所以需要創建文檔碎片來進行緩存,減少頁面的回流和重繪 // 1.獲取文檔碎片對象 const fragment = this.node2Fragment(this.el); // console.log(fragment); // 2.編譯模板 this.compile(fragment) // 3.把子元素的所有內容添加到根元素中 this.el.appendChild(fragment); } compile(fragment) { // 1.獲取子節點 const childNodes = fragment.childNodes; // 2.遍歷子節點 [...childNodes].forEach(child => { // 3.對子節點的類型進行不同的處理 if (this.isElementNode(child)) { // 是元素節點 // 編譯元素節點 // console.log('我是元素節點',child); this.compileElement(child); } else { // console.log('我是文本節點',child); this.compileText(child); // 剩下的就是文本節點 // 編譯文本節點 } // 4.一定要記得,遞歸遍歷子元素 if (child.childNodes && child.childNodes.length) { this.compile(child); } }) } // 編譯文本的方法 compileText(node) { console.log('編譯文本') } node2Fragment(el) { const fragment = document.createDocumentFragment(); // console.log(el.firstChild); let firstChild; while (firstChild = el.firstChild) { fragment.appendChild(firstChild); } return fragment } isElementNode(el) { return el.nodeType === 1; } } 復制代碼

接下來根據不同子元素的類型進行渲染

編譯元素

compileElement(node) {
    // 獲取該節點的所有屬性 const attributes = node.attributes; // 對屬性進行遍歷 [...attributes].forEach(attr => { const { name, value } = attr; //v-text v-model v-on:click @click // 看當前name是否是一個指令 if (this.isDirective(name)) { //對v-text進行操作 const [, directive] = name.split('-'); //text model html // v-bind:src const [dirName, eventName] = directive.split(':'); //對v-on:click 進行處理 // 更新數據 compileUtil[dirName] && compileUtil[dirName](node, value, this.vm, eventName); // 移除當前元素中的屬性 node.removeAttribute('v-' + directive); }else if(this.isEventName(name)){ // 對事件進行處理 在這里處理的是@click let [,eventName] = name.split('@'); compileUtil['on'](node, value, this.vm, eventName) } }) } // 是否是@click這樣事件名字 isEventName(attrName){ return attrName.startsWith('@') } //判斷是否是一個指令 isDirective(attrName) { return attrName.startsWith('v-') } 復制代碼

編譯文本

// 編譯文本的方法 compileText(node) { const content = node.textContent; // 匹配{{xxx}}的內容 if (/\{\{(.+?)\}\}/.test(content)) { // 處理文本節點 compileUtil['text'](node, content, this.vm) } } 復制代碼

大家也會發現,compileUtil這個對象它是什么鬼?真正的編譯操作我將它放入到這個對象中,根據不同的指令來做不同的處理.比如v-text是處理文本的 v-html是處理html元素 v-model是處理表單數據的.....

這樣我們在當前對象compileUtil中通過updater函數來初始化視圖

處理元素/處理文本/處理事件....

const compileUtil = { // 獲取值的方法 getVal(expr, vm) { return expr.split('.').reduce((data, currentVal) => { return data[currentVal] }, vm.$data) }, getAttrs(expr,vm){ }, text(node, expr, vm) { //expr 可能是 {{obj.name}}--{{obj.age}} let val; if (expr.indexOf('{{') !== -1) { // val = expr.replace(/\{\{(.+?)\}\}/g, (...args) => { return this.getVal(args[1], vm); }) }else{ //也可能是v-text='obj.name' v-text='msg' val = this.getVal(expr,vm); } this.updater.textUpdater(node, val); }, html(node, expr, vm) { // html處理 非常簡單 直接取值 然后調用更新函數即可 let val = this.getVal(expr,vm); this.updater.htmlUpdater(node,val); }, model(node, expr, vm) { const val = this.getVal(expr,vm); this.updater.modelUpdater(node,val); }, // 對事件進行處理 on(node, expr, vm, eventName) { // 獲取事件函數 let fn = vm.$options.methods && vm.$options.methods[expr]; // 添加事件 因為我們使用vue時 都不需要關心this的指向問題,這是因為源碼的內部幫咱們處理了this的指向 node.addEventListener(eventName,fn.bind(vm),false); }, // 綁定屬性 簡單的屬性 已經處理 類名樣式的綁定有點復雜 因為對應的值可能是對象 也可能是數組 大家根據個人能力嘗試寫一下 bind(node,expr,vm,attrName){ let attrVal = this.getVal(expr,vm); this.updater.attrUpdater(node,attrName,attrVal); }, updater: { attrUpdater(node, attrName, attrVal){ node.setAttribute(attrName,attrVal); }, modelUpdater(node,value){ node.value = value; }, textUpdater(node, value) { node.textContent = value; }, htmlUpdater(node,value){ node.innerHTML = value; } } } 復制代碼

通過以上操作:我們實現了一個編譯器compile,用它來解析指令,通過updater初始化視圖

實現一個數據監聽器Observer

ok, 思路已經整理完畢,也已經比較明確相關邏輯和模塊功能了,let's do it 我們知道可以利用Obeject.defineProperty()來監聽屬性變動 那么將需要observe的數據對象進行遞歸遍歷,包括子屬性對象的屬性,都加上 settergetter 這樣的話,給這個對象的某個值賦值,就會觸發setter,那么就能監聽到了數據變化。。相關代碼可以是這樣:

//test.js let data = {name: 'kindeng'}; observe(data); data.name = 'dmq'; // 哈哈哈,監聽到值變化了 kindeng --> dmq function observe(data) { if (!data || typeof data !== 'object') { return; } // 取出所有屬性遍歷 Object.keys(data).forEach(function(key) { defineReactive(data, key, data[key]); }); }; function defineReactive(data, key, val) { observe(val); // 監聽子屬性 Object.defineProperty(data, key, { enumerable: true, // 可枚舉 configurable: false, // 不能再define get: function() { return val; }, set: function(newVal) { console.log('哈哈哈,監聽到值變化了 ', val, ' --> ', newVal); val = newVal; } }); } 復制代碼

 

 

再看這張圖,我們接下來實現的是一個數據監聽器Observer,能夠對數據對象的所有屬性進行監聽,如有變動可拿到最新值通知依賴收集對象(Dep)並通知訂閱者(Watcher)來更新視圖

// 創建一個數據監聽者 劫持並監聽所有數據的變化 class Observer{ constructor(data) { this.observe(data); } observe(data){ // 如果當前data是一個對象才劫持並監聽 if(data && typeof data === 'object'){ // 遍歷對象的屬性做監聽 Object.keys(data).forEach(key=>{ this.defineReactive(data,key,data[key]); }) } } defineReactive(obj,key,value){ // 循環遞歸 對所有層的數據進行觀察 this.observe(value);//這樣obj也能被觀察了 Object.defineProperty(obj,key,{ get(){ return value; }, set:(newVal)=>{ if (newVal !== value){ // 如果外界直接修改對象 則對新修改的值重新觀察 this.observe(newVal); value = newVal; // 通知變化 dep.notify(); } } }) } } 復制代碼

這樣我們已經可以監聽每個數據的變化了,那么監聽到變化之后就是怎么通知訂閱者了,所以接下來我們需要實現一個消息訂閱器,很簡單,維護一個數組,用來收集訂閱者,數據變動觸發notify,再調用訂閱者的update方法,代碼改善之后是這樣:

創建Dep

  • 添加訂閱者
  • 定義通知的方法
class Dep{ constructor() { this.subs = [] } // 添加訂閱者 addSub(watcher){ this.subs.push(watcher); } // 通知變化 notify(){ // 觀察者中有個update方法 來更新視圖 this.subs.forEach(w=>w.update()); } } 復制代碼

雖然我們已經創建了Observer,Dep(訂閱器),那么問題來了,誰是訂閱者?怎么往訂閱器添加訂閱者?

沒錯,上面的思路整理中我們已經明確訂閱者應該是Watcher, 而且const dep = new Dep();是在 defineReactive方法內部定義的,所以想通過dep添加訂閱者,就必須要在閉包內操作,所以我們可以在 getOldVal里面動手腳:

實現一個Watcher

它作為連接Observer和Compile的橋梁,能夠訂閱並收到每個屬性變動的通知,執行指令綁定的相應回調函數,從而更新視圖

只要所做事情:

1、在自身實例化時往屬性訂閱器(dep)里面添加自己

2、自身必須有一個update()方法

3、待屬性變動dep.notify()通知時,能調用自身的update()方法,並觸發Compile中綁定的回調,則功成身退。

//Watcher.js class Watcher{ constructor(vm,expr,cb) { // 觀察新值和舊值的變化,如果有變化 更新視圖 this.vm = vm; this.expr = expr; this.cb = cb; // 先把舊值存起來 this.oldVal = this.getOldVal(); } getOldVal(){ Dep.target = this; let oldVal = compileUtil.getVal(this.expr,this.vm); Dep.target = null; return oldVal; } update(){ // 更新操作 數據變化后 Dep會發生通知 告訴觀察者更新視圖 let newVal = compileUtil.getVal(this.expr, this.vm); if(newVal !== this.oldVal){ this.cb(newVal); } } } //Observer.js defineReactive(obj,key,value){ // 循環遞歸 對所有層的數據進行觀察 this.observe(value);//這樣obj也能被觀察了 const dep = new Dep(); Object.defineProperty(obj,key,{ get(){ //訂閱數據變化,往Dep中添加觀察者 Dep.target && dep.addSub(Dep.target); return value; }, //....省略 }) } 復制代碼

當我們修改某個數據時,數據已經發生了變化,但是視圖沒有更新

 

 

我們在什么時候來添加綁定watcher呢,繼續看圖

 

 

也就是說,當我們訂閱數據變化時,來綁定更新函數,從而讓watcher去更新視圖

修改

// 編譯模板工具類 const compileUtil = { // 獲取值的方法 getVal(expr, vm) { return expr.split('.').reduce((data, currentVal) => { return data[currentVal] }, vm.$data) }, //設置值 setVal(vm,expr,val){ return expr.split('.').reduce((data, currentVal, index, arr) => { return data[currentVal] = val }, vm.$data) }, //獲取新值 對{{a}}--{{b}} 這種格式進行處理 getContentVal(expr, vm) { return expr.replace(/\{\{(.+?)\}\}/g, (...args) => { return this.getVal(args[1], vm); }) }, text(node, expr, vm) { //expr 可能是 {{obj.name}}--{{obj.age}} let val; if (expr.indexOf('{{') !== -1) { // val = expr.replace(/\{\{(.+?)\}\}/g, (...args) => { //綁定watcher從而更新視圖 new Watcher(vm,args[1],()=>{ this.updater.textUpdater(node,this.getContentVal(expr, vm)); }) return this.getVal(args[1], vm); }) }else{ //也可能是v-text='obj.name' v-text='msg' val = this.getVal(expr,vm); } this.updater.textUpdater(node, val); }, html(node, expr, vm) { // html處理 非常簡單 直接取值 然后調用更新函數即可 let val = this.getVal(expr,vm); // 訂閱數據變化時 綁定watcher,從而更新函數 new Watcher(vm,expr,(newVal)=>{ this.updater.htmlUpdater(node, newVal); }) this.updater.htmlUpdater(node,val); }, model(node, expr, vm) { const val = this.getVal(expr,vm); // 訂閱數據變化時 綁定更新函數 更新視圖的變化 // 數據==>視圖 new Watcher(vm, expr, (newVal) => { this.updater.modelUpdater(node, newVal); }) // 視圖==>數據 node.addEventListener('input',(e)=>{ // 設置值 this.setVal(vm,expr,e.target.value); },false); this.updater.modelUpdater(node,val); }, // 對事件進行處理 on(node, expr, vm, eventName) { // 獲取事件函數 let fn = vm.$options.methods && vm.$options.methods[expr]; // 添加事件 因為我們使用vue時 都不需要關心this的指向問題,這是因為源碼的內部幫咱們處理了this的指向 node.addEventListener(eventName,fn.bind(vm),false); }, // 綁定屬性 簡單的屬性 已經處理 類名樣式的綁定有點復雜 因為對應的值可能是對象 也可能是數組 大家根據個人能力嘗試寫一下 bind(node,expr,vm,attrName){ let attrVal = this.getVal(expr,vm); this.updater.attrUpdater(node,attrName,attrVal); }, updater: { attrUpdater(node, attrName, attrVal){ node.setAttribute(attrName,attrVal); }, modelUpdater(node,value){ node.value = value; }, textUpdater(node, value) { node.textContent = value; }, htmlUpdater(node,value){ node.innerHTML = value; } } } 復制代碼

代理proxy

我們在使用vue的時候,通常可以直接vm.msg來獲取數據,這是因為vue源碼內部做了一層代理.也就是說把數據獲取操作vm上的取值操作 都代理到vm.$data上

class Vue { constructor(options) { this.$data = options.data; this.$el = options.el; this.$options = options // 如果這個根元素存在開始編譯模板 if (this.$el) { // 1.實現一個數據監聽器Observe // 能夠對數據對象的所有屬性進行監聽,如有變動可拿到最新值並通知訂閱者 // Object.definerProperty()來定義 new Observer(this.$data); // 把數據獲取操作 vm上的取值操作 都代理到vm.$data上 this.proxyData(this.$data); // 2.實現一個指令解析器Compile new Compiler(this.$el, this); } } // 做個代理 proxyData(data){ for (const key in data) { Object.defineProperty(this,key,{ get(){ return data[key]; }, set(newVal){ data[key] = newVal; } }) } } } 復制代碼

面試題

闡述一下你所理解vue的MVVM響應式原理

vue.js 則是采用數據劫持結合發布者-訂閱者模式的方式,通過Object.defineProperty()來劫持各個屬性的settergetter,在數據變動時發布消息給訂閱者,觸發相應的監聽回調。

MVVM作為數據綁定的入口,整合Observer、Compile和Watcher三者,通過Observer來監聽自己的model數據變化,通過Compile來解析編譯模板指令,最終利用Watcher搭起Observer和Compile之間的通信橋梁,達到數據變化 -> 視圖更新;視圖交互變化(input) -> 數據model變更的雙向綁定效果

再配合上面的那張圖,想不入職都很難

 

 

最后注意:除了本項目,,結合多年開發經驗整理出2020最新企業級實戰視頻教程, 包括 Vue3.0/Js/ES6/TS/React/node等,想學的可進扣扣裙 519293536 免費獲取,小白勿進哦!,

本文的文字及圖片來源於網絡加上自己的想法,僅供學習、交流使用,不具有任何商業用途,版權歸原作者所有,如有問題請及時聯系我們以作處理


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM