做爲前端面試官我面試必須問一下面試者:描述一下你對MVVM的理解?html
接下來,我將從零實現一套完整的基於Vue的MVVM,提供給來年「金三銀四」跳槽高峯期的小夥伴們閱讀也詳細梳理一下本身對MVVM的理解。前端
在瞭解MVVM以前,咱們來對MVC說明一下。MVC架構起初以及如今一直存在於後端。MVC分別表明後臺的三層,M表明模型層、V表明視圖層、C表明控制器層,這三層架構徹底能夠知足於絕大分部的業務需求開發。node
MVC & 三層架構
下面以Java爲例,分別闡述下MVC和三層架構中各層表明的含義以及職責:git
以登陸爲例,介紹一下三層之間的邏輯關係。當用戶點擊View視圖頁面的登陸按鈕時,系統會調取Controller控制層裏的登陸接口。通常在Controller層中不會寫不少具體的業務邏輯代碼,只會寫一個接口方法,該方法具體的邏輯在Service層進行實現,而後service層裏的具體邏輯就會調用DAO層裏的Model模型,從而達到動態化的效果。github
MVVM 的描述
MVVM 設計模式,是由 MVC(最先來源於後端)、MVP 等設計模式進化而來。面試
經過以上的MVVM模式圖,咱們能夠看出最核心的就是ViewModel,它主要的做用:對View中DOM元素的監聽和對Model中的數據進行綁定,當View變化會引發Modal中數據的改動,Model中數據的改動會觸發View視圖從新渲染,從而達到數據雙向綁定的效果,該效果也是Vue最爲核心的特性。後端
常見庫實現數據雙向綁定的作法:
面試者在回答Vue的雙向數據綁定原理時,幾乎全部人都會說:Vue是採用數據劫持結合發佈訂閱模式,經過Object.defineProperty()來劫持各個屬性的getter,setter, 在數據變更時發佈消息給訂閱者,觸發相應的回調函數,從而實現數據雙向綁定。但當繼續深刻問道:設計模式
接下來,我將一步一步的實現一套完整的MVVM,當再次問道MVVM相關問題,徹底能夠在面試過程當中脫穎而出。在開始編寫MVVM以前,咱們頗有必要對核心API和發佈訂閱模式熟悉一下:數組
介紹一下 Object.defineProperty 的使用
Object.defineProperty(obj, prop, desc) 的做用就是直接在一個對象上定義一個新屬性,或者修改一個已經存在的屬性瀏覽器
注意:通常經過爲對象的屬性賦值的狀況下,對象的屬性能夠修改也能夠刪除,可是經過Object.defineProperty()定義屬性,經過描述符的設置能夠進行更精準的控制對象屬性。
let obj = {} Object.defineProperty(obj, 'name', { configurable: true, // 默認爲false,可配置的【刪除】 writable: true, // 默認爲false, 是否可寫【修改】 enumerable: true, // 默認爲false, 是否可枚舉【for in 遍歷】 value: 'sfm', // name屬性的值 get() { // 獲取obj.name的值時會調用get函數 }, set(val) { // val就是從新賦值的值 // 從新給obj.name賦值時會調用set函數 } })
注意:當出現get,set函數時,不能同時出現writable, enumerable屬性,不然系統報錯。而且該API不支持IE8如下的版本,也就是Vue不兼容IE8如下的瀏覽器。
DocumentFragment - 文檔碎片
DocumentFragment 表示文檔片斷,它不屬於 DOM 樹,可是它能夠存儲 DOM,而且能夠將所存儲的 DOM 加入到指定的 DOM 節點中去。那麼有人要問了,那要它何用,直接把元素加入到 DOM 中不就能夠了嗎?用它的緣由在於,使用它操做 DOM 要比直接操做 DOM 性能要高不少。
介紹一下 發佈訂閱模式
發佈者-訂閱者模式定義了一種一對多的依賴關係,即當一個對象的狀態發生改變時,全部依賴於他的對象都會獲得通知並自動更新,解決了主體對象與觀察者之間功能的耦合。如下是一個發佈訂閱模式的小例子,實際上能夠理解爲靠的就是數組關係,訂閱就是放入函數,發佈就是讓數組裏的函數執行。
// 發佈訂閱模式 先有訂閱後有發佈 function Dep() { this.subs = []; } // 訂閱 Dep.prototype.addSub = function(sub) { this.subs.push(sub); } Dep.prototype.notify = function() { this.subs.forEach(sub => sub.update()); } // Watcher類,經過這個類建立的實例都有update方法 function Watcher(fn) { this.fn = fn; } Watcher.prototype.update = function() { this.fn(); } let watcher1 = new Watcher(function() { console.log(123); }) let watcher2 = new Watcher(function() { console.log(456); }) let dep = new Dep(); dep.addSub(watcher1); // 將watcher放到了數組中 dep.addSub(watcher2); dep.notify(); // 控制檯輸出: // 123 456
要實現mvvm的雙向綁定,就必需要實現如下幾點:
Observer 類主要目的就是給 data 數據內的全部層級的數據都進行數據劫持,讓其具有監聽對象屬性變化的能力
【重點】:
// 數據劫持 class Observer { constructor(data) { this.observer(data); } observer(data) { if(data && typeof data == 'object') { // 判斷data數據存在 並 data是對象 才觀察 for(let key in data) { this.defineReactive(data, key, data[key]); } } } defineReactive(obj, key, value) { let dep = new Dep(); this.observer(value); // 若是value仍是對象,還須要觀察 Object.defineProperty(obj, key, { get() { Dep.target && dep.addSub(Dep.target); return value; }, set:(newVal) => { // 設置新值 if(newVal != value) { // 新值和就值若是一致就不須要替換了 this.observer(newVal); // 若是賦值的也是對象的話 還須要觀察 value = newVal; dep.notify(); // 通知全部訂閱者更新了 } } }) } }
注意:該類只會對對象進行數據劫持,並不會對數組的監聽。
Compiler 是解析模板指令,將模板中的變量替換成數據,而後初始化渲染頁面視圖,並將每一個指令對應的節點綁定更新函數,添加監聽數據的訂閱者,一旦數據有變更,收到通知,更新視圖
Compiler 主要作了三件事:
【重點】:
// 模板編譯 class Compiler { /** * @param {*} el 元素 注意:el選項中有多是‘#app’字符串也有多是document.getElementById('#app') * @param {*} vm 實例 */ constructor(el, vm) { // 判斷el屬性 是否是一個元素 若是不是元素就獲取 this.el = this.isElementNode(el) ? el : document.querySelector(el); // console.log(this.el);拿到當前的模板 this.vm = vm; // 把當前節點中的元素獲取到 放到內存中 防止頁面重繪 let fragment = this.node2fragment(this.el); // console.log(fragment);內存中全部的節點 // 1. 編譯模板 用data中的數據編譯 this.compile(fragment); // 2. 把內存中的內容進行替換 this.el.appendChild(fragment); // 3. 再把替換後的內容回寫到頁面中 } /** * 判斷是含有指令 * @param {*} attrName 屬性名 type v-modal */ isDirective(attrName) { return attrName.startsWith('v-'); // 是否含有v- } /** * 編譯元素節點 * @param {*} node 元素節點 */ compileElement(node) { // 獲取當前元素節點的屬性;【類數組】NamedNodeMap; 也存在沒有屬性,則NamedNodeMap{length: 0} let attributes = node.attributes; // Array.from()、[...xxx]、[].slice.call 等均可以將類數組轉化爲真實數組 [...attributes].forEach(attr => { // attr格式:type="text" v-modal="obj.name" let {name, value: expr} = attr; // 判斷是否是指令 if(this.isDirective(name)) { // v-modal v-html v-bind // console.log('element', node); 元素 let [, directive] = name.split('-'); // 獲取指令名 // 須要調用不一樣的指令來處理 CompilerUtil[directive](node, expr, this.vm); } }); } /** * 編譯文本節點 判斷當前文本節點中的內容是否含有 {{}} * @param {*} node 文本節點 */ compileText(node) { let content = node.textContent; // console.log(content, ‘內容’); 元素裏的內容 if(/\{\{(.+?)\}\}/.test(content)) { // 經過正則去匹配只須要含有{{}}大括號的,空的不須要 獲取大括號中間的內容 // console.log(content, ‘內容’); 只包含{{}} 不須要空的 和其餘沒有{{}}的子元素 CompilerUtil['text'](node, content, this.vm); } } /** * 編譯內存中的DOM節點 * @param {*} fragmentNode 文檔碎片 */ compile(fragmentNode) { // 從文檔碎片中拿到子節點 注意:childNodes【之包含第一層,不包含{{}}等】 let childNodes = fragmentNode.childNodes; // 獲取的是類數組NodeLis [...childNodes].forEach(child => { // 是不是元素節點 if (this.isElementNode(child)) { this.compileElement(child); // 若是是元素的話 須要把本身傳進去 再去遍歷子節點 遞歸 this.compile(child); } else { // 文本節點 // console.log('text', child); this.compileText(child); } }); } /** * 將節點中的元素放到內存中 * @param {*} node 節點 */ node2fragment(node) { // 建立一個穩定碎片;目的是爲了將這個節點中的每一個孩子都寫到這個文檔碎片中 let fragment = document.createDocumentFragment(); let firstChild; // 這個節點中的第一個孩子 while (firstChild = node.firstChild) { // appendChild具備移動性,每移動一個節點到內存中,頁面上就會少一個節點 fragment.appendChild(firstChild); } return fragment; } /** * 判斷是否是元素 * @param {*} node 當前這個元素的節點 */ isElementNode(node) { return node.nodeType === 1; } } // 編譯功能 CompilerUtil = { /** * 根據表達式取到對應的數據 * @param {*} vm * @param {*} expr */ getVal(vm, expr) { return expr.split('.').reduce((data, current) => { return data[current]; }, vm.$data); }, setVal(vm, expr, value) { expr.split('.').reduce((data, current, index, arr) => { if (index === arr.length - 1) { return data[current] = value; } return data[current] }, vm.$data) }, /** * 處理v-modal * @param {*} node 對應的節點 * @param {*} expr 表達式 * @param {*} vm 當前實例 */ modal(node, expr, vm) { // 給輸入框賦予value屬性 node.value = xxx let fn = this.updater['modalUpdater']; new Watcher(vm, expr, (newValue) => {//給輸入框加一個觀察者 數據更新會觸發此方法 會拿新值給 輸入框賦值 fn(node, newValue) }) node.addEventListener('input', e => { let value = e.target.value; // 獲取用戶輸入的內容 this.setVal(vm, expr, value); }) let value = this.getVal(vm, expr); // 返回tmc fn(node, value); }, text(node, expr, vm) { let fn = this.updater['textUpdater']; let content = expr.replace(/\{\{(.+?)\}\}/g, (...args) => { // 給表達式 每一個{{}} 加上觀察者 new Watcher(vm, args[1], (newValue) => { fn(node, this.getContentValue(vm, expr)); // 返回了一個新的字符串 }) return this.getVal(vm, args[1].trim()); }); fn(node, content); }, updater: { // 把數據插入到節點中 modalUpdater(node, value) { node.value = value; }, // 處理文本節點 textUpdater(node, value) { node.textContent = value; } } }
Complier 具有將 HTML 模版解析成 Document Fragment 的能力,而且會建立響應的 Watcher,讓視圖中綁定的數據產生變化。
Watcher 訂閱者做爲 Observer 和 Compile 之間通訊的橋樑,主要作的事情是:
// 發佈訂閱 function Dep() { this.subs = [] } Dep.prototype.addSub = function(sub) { this.subs.push(sub) } Dep.prototype.notify = function() { this.subs.forEach(sub => sub.update()) } /** * watcher * @param {*} vm 當前實例 * @param {*} exp 表達式 * @param {*} fn 監聽函數 */ function Watcher(vm, exp, fn) { this.fn = fn; this.vm = vm; this.exp = exp; // 添加到訂約中 Dep.target = this; let val = vm; let arr = exp.split('.'); arr.forEach(function (k) { val = val[k]; }) Dep.target = null; // 保證watcher不會重複添加 } Watcher.prototype.update = function() { let val = this.vm; let arr = this.exp.split('.'); arr.forEach(function (k) { val = val[k]; }) this.fn(val) }
Dep 和 Watcher 是簡單的觀察者模式的實現,Dep 即訂閱者,它會管理全部的觀察者,而且有給觀察者發送消息的能力。Watcher 即觀察者,當接收到訂閱者的消息後,觀察者會作出本身的更新操做。
MVVM做爲數據綁定的入口,整合Observer、Compile和Watcher三者,經過Observer來監聽本身的model數據變化,經過Compile來解析編譯模板指令,最終利用Watcher搭起Observer和Compile之間的通訊橋樑,達到數據變化 -> 視圖更新;視圖交互變化(input) -> 數據model變動的雙向綁定效果。
class MVVM { constructor(options) { // 當new該類時,參數就會傳到構造函數中 options就是el data computed ... this.$el = options.el; // 建立一個當前實例$el this.$data = options.data; // 判斷根元素是否存在 <div id='app'></div> => 編譯模板 if (this.$el) { // 把data裏的數據 所有轉化成用Object.defineProperty來定義 new Observer(this.$data); new Compiler(this.$el, this); } } }
注意:這樣有個問題? 在開發中是能經過實例+屬性(vm.a)來獲取數據,而咱們實現的MVVM獲取數據要經過myMvvm.$data.xxx來獲取到數據,中間多了一個$data,這樣顯然不是咱們想要的樣子。接下來實現讓實例this來代理$data數據,便可實現myMvvm.xxx獲取數據和真實場景同樣的操做。
數據代理
在MVVM實例上添加一個屬性代理的方法,使訪問myMvvm的屬性代理爲訪問myMvvm.$data的屬性。其實仍是利用了Object.defineProperty()方法來劫持了myMvvm實例對象的屬性。添加的代理方法以下:
// this 代理 $data for (let key in data) { Object.defineProperty(this, key, { enumerable: true, get() { return this.$data[key]; // this.xxx == {} }, set(newVal) { this.$data[key] = newVal; } }) }
computed 具備緩存功能,當依賴的屬性發送變化,纔會更新視圖變化
function initComputed() { let vm = this; // 將當前this掛載到vm上 let computed = this.$options.computed; // 從options上拿到computed屬性 // 獲得的都是對象的key能夠經過Object.keys轉化爲數組 Object.keys(computed).forEach(key => { Object.defineProperty(vm, key, { // 映射到this實例上 // 判斷是computed裏的key是對象仍是函數 // 如果函數,則直接就調get方法 // 如果對象,則須要手動調一下get方法 // 由於computed只根據依賴的屬性進行觸發,當獲取依賴屬性時,系統會自動的去調用get方法,因此就不要用Watcher去監聽變化了 get: typeof computed[key] === 'function' ? computed[key] : computed[key].get, set() {} }); }); }
https://github.com/tangmengcheng/mvvm.git 歡迎小夥伴點贊、評論加關注哦🤭 star~
經過以上描述和核心代碼的演示,相信小夥伴們對MVVM有從新的認識,面試中對面面試官的提問能夠對答如流。但願同行的小夥伴手動敲一遍,實現一個本身MVVM,這樣對其原理理解更加深刻。
隨着日益月薪需求的不斷增長,jQuery操做DOM的時代已經知足不了企業項目快速迭代的進度了。 MVVM 模式對於前端領域有着重大的意義,其核心原理:實時保證View層與Model層的數據同步,實現了雙向數據綁定。減小了頻繁的DOM操做,提升了頁面渲染的性能,也讓開發者把更多的時間放到數據的處理以及業務功能的開發上。
若是本文對你有幫助得話,給本文點個贊❤️❤️❤️
歡迎你們加入,一塊兒學習前端,共同進步!