MVVM 設計模式,是由 MVC(最先來源於後端)、MVP 等設計模式進化而來,M - 數據模型(Model),VM - 視圖模型(ViewModel),V - 視圖層(View)。javascript
在 MVC 模式中,除了 Model 和 View 層之外,其餘全部的邏輯都在 Controller 中,Controller 負責顯示頁面、響應用戶操做、網絡請求及與 Model 的交互,隨着業務的增長和產品的迭代,Controller 中的處理邏輯愈來愈多、愈來愈複雜,難以維護。爲了更好的管理代碼,爲了更方便的擴展業務,必需要爲 Controller 「瘦身」,須要更清晰的將用戶界面(UI)開發從應用程序的業務邏輯與行爲中分離,MVVM 爲此而生。html
不少 MVVM 的實現都是經過數據綁定來將 View 的邏輯從其餘層分離,能夠用下圖來簡略的表示:前端
使用 MVVM 設計模式的前端框架不少,其中漸進式框架 Vue 是典型的表明,並在開發使用中深得廣大前端開發者的青睞,咱們這篇就根據 Vue 對於 MVVM 的實現方式來簡單模擬一版 MVVM 庫。java
在 Vue 的 MVVM 設計中,咱們主要針對 Compile
(模板編譯)、Observer
(數據劫持)、Watcher
(數據監聽)和 Dep
(發佈訂閱)幾個部分來實現,核心邏輯流程可參照下圖:node
相似這種 「造輪子」 的代碼毋庸置疑必定是經過面向對象編程來實現的,並嚴格遵循開放封閉原則,因爲 ES5 的面向對象編程比較繁瑣,因此,在接下來的代碼中統一使用 ES6 的 class
來實現。正則表達式
在 Vue 中,對外只暴露了一個名爲 Vue
的構造函數,在使用的時候 new
一個 Vue
實例,而後傳入了一個 options
參數,類型爲一個對象,包括當前 Vue
實例的做用域 el
、模板綁定的數據 data
等等。編程
咱們模擬這種 MVVM 模式的時候也構建一個類,名字就叫 MVVM
,在使用時同 Vue 框架相似,須要經過 new
指令建立 MVVM
的實例並傳入 options
。後端
// MVVM.js 文件
class MVVM {
constructor(options) {
// 先把 el 和 data 掛在 MVVM 實例上
this.$el = options.el;
this.$data = options.data;
// 若是有要編譯的模板就開始編譯
if (this.$el) {
// 數據劫持,就是把對象全部的屬性添加 get 和 set
new Observer(this.$data);
// 將數據代理到實例上
this.proxyData(this.$data);
// 用數據和元素進行編譯
new Compile(this.el, this);
}
}
proxyData(data) { // 代理數據的方法
Object.keys(data).forEach(key => {
Object.defineProperty(this, key, {
get() {
return data[key];
}
set(newVal) {
data[key] = newVal;
}
});
});
}
}
複製代碼
經過上面代碼,咱們能夠看出,在咱們 new
一個 MVVM
的時候,在參數 options
中傳入了一個 Dom
的根元素節點和數據 data
並掛在了當前的 MVVM
實例上。設計模式
當存在根節點的時候,經過 Observer
類對 data
數據進行了劫持,並經過 MVVM
實例的方法 proxyData
把 data
中的數據掛在當前 MVVM
實例上,一樣對數據進行了劫持,是由於咱們在獲取和修改數據的時候能夠直接經過 this
或 this.$data
,在 Vue 中實現數據劫持的核心方法是 Object.defineProperty
,咱們也使用這個方式經過添加 getter
和 setter
來實現數據劫持。數組
最後使用 Compile
類對模板和綁定的數據進行了解析和編譯,並渲染在根節點上,之因此數據劫持和模板解析都使用類的方式實現,是由於代碼方便維護和擴展,其實不難看出,MVVM
類其實做爲了 Compile
類和 Observer
類的一個橋樑。
Compile
類在建立實例的時候須要傳入兩個參數,第一個參數是當前 MVVM
實例做用的根節點,第二個參數就是 MVVM
實例,之因此傳入 MVVM
的實例是爲了更方便的獲取 MVVM
實例上的屬性。
在 Compile
類中,咱們會盡可能的把一些公共的邏輯抽取出來進行最大限度的複用,避免冗餘代碼,提升維護性和擴展性,咱們把 Compile
類抽取出的實例方法主要分爲兩大類,輔助方法和核心方法,在代碼中用註釋標明。
// Compile.js 文件
class Compile {
constructor(el, vm) {
this.el = this.isElementNode(el) ? el : document.querySelector(el);
this.vm = vm;
// 如過傳入的根元素存在,纔開始編譯
if (this.el) {
// 一、把這些真實的 Dom 移動到內存中,即 fragment(文檔碎片)
let fragment = this.node2fragment(this.el);
}
}
/* 輔助方法 */
// 判斷是不是元素節點
isElementNode(node) {
return node.nodeType === 1;
}
/* 核心方法 */
// 將根節點轉移至文檔碎片
node2fragment(el) {
// 建立文檔碎片
let fragment = document.createDocumentFragment();
// 第一個子節點
let firstChild;
// 循環取出根節點中的節點並放入文檔碎片中
while (firstChild = el.firstChild) {
fragment.appendChild(firstChild);
}
return fragment;
}
}
複製代碼
上面編譯模板的過程當中,前提條件是必須存在根元素節點,傳入的根元素節點容許是一個真實的 Dom
元素,也能夠是一個選擇器,因此咱們建立了輔助方法 isElementNode
來幫咱們判斷傳入的元素是不是 Dom
,若是是就直接使用,是選擇器就獲取這個 Dom
,最終將這個根節點存入 this.el
屬性中。
解析模板的過程當中爲了性能,咱們應取出根節點內的子節點存放在文檔碎片中(內存),須要注意的是將一個 Dom
節點內的子節點存入文檔碎片的過程當中,會在原來的 Dom
容器中刪除這個節點,因此在遍歷根節點的子節點時,永遠是將第一個節點取出存入文檔碎片,直到節點不存在爲止。
在 Vue 中的模板編譯的主要就是兩部分,也是瀏覽器沒法解析的部分,元素節點中的指令和文本節點中的 Mustache 語法(雙大括號)。
// Compile.js 文件
class Compile {
constructor(el, vm) {
this.el = this.isElementNode(el) ? el : document.querySelector(el);
this.vm = vm;
// 如過傳入的根元素存在,纔開始編譯
if (this.el) {
// 一、把這些真實的 Dom 移動到內存中,即 fragment(文檔碎片)
let fragment = this.node2fragment(this.el);
// ********** 如下爲新增代碼 **********
// 二、將模板中的指令中的變量和 {{}} 中的變量替換成真實的數據
this.compile(fragment);
// 三、把編譯好的 fragment 再塞回頁面中
this.el.appendChild(fragment);
// ********** 以上爲新增代碼 **********
}
}
/* 輔助方法 */
// 判斷是不是元素節點
isElementNode(node) {
return node.nodeType === 1;
}
// ********** 如下爲新增代碼 **********
// 判斷屬性是否爲指令
isDirective(name) {
return name.includes("v-");
}
// ********** 以上爲新增代碼 **********
/* 核心方法 */
// 將根節點轉移至文檔碎片
node2fragment(el) {
// 建立文檔碎片
let fragment = document.createDocumentFragment();
// 第一個子節點
let firstChild;
// 循環取出根節點中的節點並放入文檔碎片中
while (firstChild = el.firstChild) {
fragment.appendChild(firstChild);
}
return fragment;
}
// ********** 如下爲新增代碼 **********
// 解析文檔碎片
compile(fragment) {
// 當前父節點節點的子節點,包含文本節點,類數組對象
let childNodes = fragment.childNodes;
// 轉換成數組並循環判斷每個節點的類型
Array.from(childNodes).forEach(node => {
if (this.isElementNode(node)) { // 是元素節點
// 遞歸編譯子節點
this.compile(node);
// 編譯元素節點的方法
this.compileElement(node);
} else { // 是文本節點
// 編譯文本節點的方法
this.compileText(node);
}
});
}
// 編譯元素
compileElement(node) {
// 取出當前節點的屬性,類數組
let attrs = node.attributes;
Array.form(attrs).forEach(attr => {
// 獲取屬性名,判斷屬性是否爲指令,即含 v-
let attrName = attr.name;
if (this.isDirective(attrName)) {
// 若是是指令,取到該屬性值得變量在 data 中對應得值,替換到節點中
let exp = attr.value;
// 取出方法名
let [, type] = attrName.split("-");
// 調用指令對應得方法
CompileUtil[type](node, this.vm, exp);
}
});
}
// 編譯文本
compileText(node) {
// 獲取文本節點的內容
let exp = node.contentText;
// 建立匹配 {{}} 的正則表達式
let reg = /\{\{([^}+])\}\}/g;
// 若是存在 {{}} 則使用 text 指令的方法
if (reg.test(exp)) {
CompileUtil["text"](node, this.vm, exp);
}
}
// ********** 以上爲新增代碼 **********
}
複製代碼
上面代碼新增內容得主要邏輯就是作了兩件事:
compile
方法對 fragment
文檔碎片進行編譯,即替換內部指令和 Mustache 語法中變量對應的值;fragment
文檔碎片塞回根節點。在第一個步驟當中邏輯是比較繁瑣的,首先在 compile
方法中獲取全部的子節點,循環進行編譯,若是是元素節點須要遞歸 compile
,傳入當前元素節點。在這個過程中抽取出了兩個方法,compileElement
和 compileText
用來對元素節點的屬性和文本節點進行處理。
compileElement
中的核心邏輯就是處理指令,取出元素節點全部的屬性判斷是不是指令,是指令則調用指令對應的方法。compileText
中的核心邏輯就是取出文本的內容經過正則表達式匹配出被 Mustache 語法的 「{{ }}」 包裹的內容,並調用處理文本的 text
方法。
文本節點的內容有可能存在 「{{ }} {{ }} {{ }}」,正則匹配默認是貪婪的,爲了防止第一個 「{」 和最後一個 「}」 進行匹配,因此在正則表達式中應使用非貪婪匹配。
在調用指令的方法時都是調用的 CompileUtil
下對應的方法,咱們之因此單獨把這些指令對應的方法抽離出來存儲在 CompileUtil
對象下的目的是爲了解耦,由於後面其餘的類還要使用。
CompileUtil
中存儲着全部的指令方法及指令對應的更新方法,因爲 Vue 的指令不少,咱們這裏只實現比較典型的 v-model
和 「{{ }}」 對應的方法,考慮到後續更新的狀況,咱們統一把設置值到 Dom
中的邏輯抽取出對應上面兩種狀況的方法,存放到 CompileUtil
的 updater
對象中。
// CompileUtil.js 文件
CompileUtil = {};
// 更新節點數據的方法
CompileUti.updater = {
// 文本更新
textUpdater(node, value) {
node.textContent = value;
},
// 輸入框更新
modelUpdater(node, value) {
node.value = value;
}
};
複製代碼
這部分的整個思路就是在 Compile
編譯模板後處理 v-model
和 「{{ }}」 時,其實都是用 data
中的數據替換掉 fragment
文檔碎片中對應的節點中的變量。所以會常常性的獲取 data
中的值,在更新節點時又會從新設置 data
中的值,因此咱們抽離出了三個方法 getVal
、getTextVal
和 setVal
掛在了 CompileUtil
對象下。
// CompileUtil.js 文件
// 獲取 data 值的方法
CompileUtil.getVal = function (vm, exp) {
// 將匹配的值用 . 分割開,如 vm.data.a.b
exp = exp.split(".");
// 歸併取值
return exp.reduce((prev, next) => {
return prev[next];
}, vm.$data);
};
// 獲取文本 {{}} 中變量在 data 對應的值
CompileUtil.getTextVal = function (vm, exp) {
// 使用正則匹配出 {{ }} 間的變量名,再調用 getVal 獲取值
return exp.replace(/\{\{([^}]+)\}\}/g, (...args) => {
return this.getVal(vm, args[1]);
});
};
// 設置 data 值的方法
CompileUtil.setVal = function (vm, exp, newVal) {
exp = exp.split(".");
return exp.reduce((prev, next, currentIndex) => {
// 若是當前歸併的爲數組的最後一項,則將新值設置到該屬性
if(currentIndex === exp.length - 1) {
return prev[next] = newVal;
}
// 繼續歸併
return prev[next];
}, vm.$data);
}
複製代碼
獲取和設置 data
的值兩個方法 getVal
和 setVal
思路類似,因爲獲取的變量層級不定,多是 data.a
,也多是 data.obj.a.b
,因此都是使用歸併的思路,借用 reduce
方法實現的,區別在於 setVal
方法在歸併過程當中須要判斷是否是歸併到最後一級,若是是則設置新值,而 getTextVal
就是在 getVal
外包了一層處理 「{{ }}」 的邏輯。
在這些準備工做就緒之後就能夠實現咱們的主邏輯,即對 Compile
類中解析的文本節點和元素節點指令中的變量用 data
值進行替換,還記得前面說針對 v-model
和 「{{ }}」 進行處理,所以設計了 model
和 text
兩個核心方法。
CompileUtil.model
方法的實現:
// CompileUtil.js 文件
// 處理 v-model 指令的方法
CompileUtil.model = function (node, vm, exp) {
// 獲取賦值的方法
let updateFn = this.updater["modelUpdater"];
// 獲取 data 中對應的變量的值
let value = this.getVal(vm, exp);
// 添加觀察者,做用與 text 方法相同
new Watcher(vm, exp, newValue => {
updateFn && updateFn(node, newValue);
});
// v-model 雙向數據綁定,對 input 添加事件監聽
node.addEventListener('input', e => {
// 獲取輸入的新值
let newValue = e.target.value;
// 更新到節點
this.setVal(vm, exp, newValue);
});
// 第一次設置值
updateFn && updateFn(vm, value);
};
複製代碼
CompileUtil.text
方法的實現:
// CompileUtil.js 文件
// 處理文本節點 {{}} 的方法
CompileUtil.text = function (node, vm, exp) {
// 獲取賦值的方法
let updateFn = this.updater["textUpdater"];
// 獲取 data 中對應的變量的值
let value = this.getTextVal(vm, exp);
// 經過正則替換,將取到數據中的值替換掉 {{ }}
exp.replace(/\{\{([^}]+)\}\}/g, (...args) => {
// 解析時遇到了模板中須要替換爲數據值的變量時,應該添加一個觀察者
// 當變量從新賦值時,調用更新值節點到 Dom 的方法
new Watcher(vm, args[1], newValue => {
// 若是數據發生變化,從新獲取新值
updateFn && updateFn(node, newValue);
});
});
// 第一次設置值
updateFn && updateFn(vm, value);
};
複製代碼
上面兩個方法邏輯類似,都獲取了各自的 updater
中的方法,對值進行設置,而且在設置的同時爲了後續 data
中的數據修改,視圖的更新,建立了 Watcher
的實例,並在內部用新值從新更新節點,不一樣的是 Vue 的 v-model
指令在表單中實現了雙向數據綁定,只要表單元素的 value
值發生變化,就須要將新值更新到 data
中,並響應到頁面上。
因此咱們的實現方式是給這個綁定了 v-model
的表單元素監聽了 input
事件,並在事件中實時的將新的 value
值更新到 data
中,至於 data
中的改變後響應到頁面中須要另外三個類 Watcher
、Observer
和 Dep
共同實現,咱們下面就來實現 Watcher
類。
在 CompileUtil
對象的方法中建立 Watcher
實例的時候傳入了三個參數,即 MVVM
的實例、模板綁定數據的變量名 exp
和一個 callback
,這個 callback
內部邏輯是爲了更新數據到 Dom
,因此咱們的 Watcher
類內部要作的事情就清晰了,獲取更改前的值存儲起來,並建立一個 update
實例方法,在值被更改時去執行實例的 callback
以達到視圖的更新。
// Watcher.js 文件
class Watcher {
constructor(vm, exp, callback) {
this.vm = vm;
this.exp = exp;
this.callback = callback;
// 更改前的值
this.value = this.get();
}
get() {
// 將當前的 watcher 添加到 Dep 類的靜態屬性上
Dep.target = this;
// 獲取值觸發數據劫持
let value = CompileUtil.getVal(this.vm, this.exp);
// 清空 Dep 上的 Watcher,防止重複添加
Dep.target = null;
return value;
}
update() {
// 獲取新值
let newValue = CompileUtil.getVal(this.vm, this.exp);
// 獲取舊值
let oldValue = this.value;
// 若是新值和舊值不相等,就執行 callback 對 dom 進行更新
if(newValue !== oldValue) {
this.callback();
}
}
}
複製代碼
看到上面代碼必定有兩個疑問:
get
方法獲取舊值得時候爲何要將當前的實例掛在 Dep
上,在獲取值後爲何又清空了;update
方法內部執行了 callback
函數,可是 update
在何時執行。這就是後面兩個類 Dep
和 observer
要作的事情,咱們首先來介紹 Dep
,再介紹 Observer
最後把他們之間的關係整個串聯起來。
其實發布訂閱說白了就是把要執行的函數統一存儲在一個數組中管理,當達到某個執行條件時,循環這個數組並執行每個成員。
// Dep.js 文件
class Dep {
constructor() {
this.subs = [];
}
// 添加訂閱
addSub(watcher) {
this.subs.push(watcher);
}
// 通知
notify() {
this.subs.forEach(watcher => watcher.update());
}
}
複製代碼
在 Dep
類中只有一個屬性,就是一個名爲 subs
的數組,用來管理每個 watcher
,即 Watcher
類的實例,而 addSub
就是用來將 watcher
添加到 subs
數組中的,咱們看到 notify
方法就解決了上面的一個疑問,Watcher
類的 update
方法是怎麼執行的,就是這樣循環執行的。
接下來咱們整合一下盲點:
Dep
實例在哪裏建立聲明,又是在哪裏將 watcher
添加進 subs
數組的;Dep
的 notify
方法應該在哪裏調用;Watcher
內容中,使用 get
方法獲取舊值得時候爲何要將當前的實例掛在 Dep
上,在獲取值後爲何又清空了。這些問題在最後一個類 Observer
實現的時候都將清晰,下面咱們重點來看最後一部分核心邏輯。
還記得實現 MVVM
類的時候就建立了這個類的實例,當時傳入的參數是 MVVM
實例的 data
屬性,在 MVVM
中把數據經過 Object.defineProperty
掛到了實例上,並添加了 getter
和 setter
,其實 Observer
類主要目的就是給 data
內的全部層級的數據都進行這樣的操做。
// Observer.js 文件
class Observer {
constructor (data) {
this.observe(data);
}
// 添加數據監聽
observe(data) {
// 驗證 data
if(!data || typeof data !== 'object') {
return;
}
// 要對這個 data 數據將原有的屬性改爲 set 和 get 的形式
// 要將數據一一劫持,先獲取到 data 的 key 和 value
Object.keys(data).forEach(key => {
// 劫持(實現數據響應式)
this.defineReactive(data, key, data[key]);
this.observe(data[key]); // 深度劫持
});
}
// 數據響應式
defineReactive (object, key, value) {
let _this = this;
// 每一個變化的數據都會對應一個數組,這個數組是存放全部更新的操做
let dep = new Dep();
// 獲取某個值被監聽到
Object.defineProperty(object, key, {
enumerable: true,
configurable: true,
get () { // 當取值時調用的方法
Dep.target && dep.addSub(Dep.target);
return value;
},
set (newValue) { // 當給 data 屬性中設置的值適合,更改獲取的屬性的值
if(newValue !== value) {
_this.observe(newValue); // 從新賦值若是是對象進行深度劫持
value = newValue;
dep.notify(); // 通知全部人數據更新了
}
}
});
}
}
複製代碼
在的代碼中 observe
的目的是遍歷對象,在內部對數據進行劫持,即添加 getter
和 setter
,咱們把劫持的邏輯單獨抽取成 defineReactive
方法,須要注意的是 observe
方法在執行最初就對當前的數據進行了數據類型驗證,而後再循環對象每個屬性進行劫持,目的是給同爲 Object
類型的子屬性遞歸調用 observe
進行深度劫持。
在 defineReactive
方法中,建立了 Dep
的實例,並對 data
的數據使用 get
和 set
進行劫持,還記得在模板編譯的過程當中,遇到模板中綁定的變量,就會解析,並建立 watcher
,會在 Watcher
類的內部獲取舊值,即當前的值,這樣就觸發了 get
,在 get
中就能夠將這個 watcher
添加到 Dep
的 subs
數組中進行統一管理,由於在代碼中獲取 data
中的值操做比較多,會常常觸發 get
,咱們又要保證 watcher
不會被重複添加,因此在 Watcher
類中,獲取舊值並保存後,當即將 Dep.target
賦值爲 null
,而且在觸發 get
時對 Dep.target
進行了短路操做,存在才調用 Dep
的 addSub
進行添加。
而 data
中的值被更改時,會觸發 set
,在 set
中作了性能優化,即判斷從新賦的值與舊值是否相等,若是相等就不從新渲染頁面,不等的狀況有兩種,若是原來這個被改變的值是基本數據類型沒什麼影響,若是是引用類型,咱們須要對這個引用類型內部的數據進行劫持,所以遞歸調用了 observe
,最後調用 Dep
的 notify
方法進行通知,執行 notify
就會執行 subs
中全部被管理的 watcher
的 update
,就會執行建立 watcher
時的傳入的 callback
,就會更新頁面。
在 MVVM
類將 data
的屬性掛在 MVVM
實例上並劫持與經過 Observer
類對 data
的劫持還有一層聯繫,由於整個發佈訂閱的邏輯都是在 data
的 get
和 set
上,只要觸發了 MVVM
中的 get
和 set
內部會自動返回或設置 data
對應的值,就會觸發 data
的 get
和 set
,就會執行發佈訂閱的邏輯。
經過上面長篇大論的敘述後,這個 MVVM 模式用到的幾個類的關係應該徹底敘述清晰了,雖然比較抽象,可是細心琢磨仍是會明白之間的關係和邏輯,下面咱們就來對咱們本身實現的這個 MVVM 進行驗證。
咱們按照 Vue 的方式根據本身的 MVVM 實現的內容簡單的寫了一個模板以下:
<!-- index.html 文件 -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>MVVM</title>
</head>
<body>
<div id="app">
<!-- 雙向數據綁定 靠的是表單 -->
<input type="text" v-model="message">
<div>{{message}}</div>
<ul>
<li>{{message}}</li>
</ul>
{{message}}
</div>
<!-- 引入依賴的 js 文件 -->
<script src="./js/Watcher.js"></script>
<script src="./js/Observer.js"></script>
<script src="./js/Compile.js"></script>
<script src="./js/CompileUtil.js"></script>
<script src="./js/Dep.js"></script>
<script src="./js/MVVM.js"></script>
<script> let vm = new MVVM({ el: '#app', data: { message: 'hello world!' } }); </script>
</body>
</html>
複製代碼
打開 Chrom 瀏覽器的控制檯,在上面經過下面操做來驗證:
vm.message = "hello"
看頁面是否更新;vm.$data.message = "hello"
看頁面是否更新;經過上面的測試,相信應該理解了 MVVM 模式對於前端開發重大的意義,實現了雙向數據綁定,實時保證 View 層與 Model 層的數據同步,並可讓咱們在開發時基於數據編程,而最少的操做 Dom
,這樣大大提升了頁面渲染的性能,也可使咱們把更多的精力用於業務邏輯的開發上。