開(kāi)發(fā)模式是大頭乾忱。但真正掌握了讥珍,對(duì)自己的整個(gè)開(kāi)發(fā)思想是大大的提高。
曾幾何時(shí)窄瘟,為自己的面向過(guò)程而沉迷著衷佃。興嘆好多代碼呀!
但你又曾知道這樣的代碼出錯(cuò)是有多容易蹄葱,解決錯(cuò)誤是有多麻煩氏义。同事看你代碼是有多惡心。
所以图云,兄弟呀惯悠,學(xué)學(xué)開(kāi)發(fā)模式知識(shí),讓你少掉幾根頭發(fā)吧竣况。
MVVM 的前世今生
MVVM 設(shè)計(jì)模式克婶,是由 MVC(最早來(lái)源于后端)、MVP 等設(shè)計(jì)模式進(jìn)化而來(lái)丹泉,M - 數(shù)據(jù)模型(Model)情萤,VM - 視圖模型(ViewModel),V - 視圖層(View)嘀掸。
在 MVC 模式中紫岩,除了 Model 和 View 層以外,其他所有的邏輯都在 Controller 中睬塌,Controller 負(fù)責(zé)顯示頁(yè)面泉蝌、響應(yīng)用戶操作、網(wǎng)絡(luò)請(qǐng)求及與 Model 的交互揩晴,隨著業(yè)務(wù)的增加和產(chǎn)品的迭代勋陪,Controller 中的處理邏輯越來(lái)越多、越來(lái)越復(fù)雜硫兰,難以維護(hù)诅愚。為了更好的管理代碼,為了更方便的擴(kuò)展業(yè)務(wù)劫映,必須要為 Controller “瘦身”违孝,需要更清晰的將用戶界面(UI)開(kāi)發(fā)從應(yīng)用程序的業(yè)務(wù)邏輯與行為中分離,MVVM 為此而生泳赋。
使用 MVVM 設(shè)計(jì)模式的前端框架很多雌桑,其中漸進(jìn)式框架 Vue 是典型的代表,并在開(kāi)發(fā)使用中深得廣大前端開(kāi)發(fā)者的青睞祖今,我們這篇就根據(jù) Vue 對(duì)于 MVVM 的實(shí)現(xiàn)方式來(lái)簡(jiǎn)單模擬一版 MVVM 庫(kù)校坑。
MVVM 的流程分析
在 Vue 的 MVVM 設(shè)計(jì)中拣技,我們主要針對(duì) Compile
(模板編譯)、Observer
(數(shù)據(jù)劫持)耍目、Watcher
(數(shù)據(jù)監(jiān)聽(tīng))和 Dep
(發(fā)布訂閱)幾個(gè)部分來(lái)實(shí)現(xiàn)膏斤,核心邏輯流程可參照下圖:
<figcaption style="padding: 0px; margin: 0px;"></figcaption>
</figure>
類似這種 “造輪子” 的代碼毋庸置疑一定是通過(guò)面向?qū)ο缶幊虂?lái)實(shí)現(xiàn)的,并嚴(yán)格遵循開(kāi)放封閉原則邪驮,由于 ES5 的面向?qū)ο缶幊瘫容^繁瑣莫辨,所以,在接下來(lái)的代碼中統(tǒng)一使用 ES6 的 class
來(lái)實(shí)現(xiàn)耕捞。
MVVM 類的實(shí)現(xiàn)
在 Vue 中衔掸,對(duì)外只暴露了一個(gè)名為 Vue
的構(gòu)造函數(shù),在使用的時(shí)候 new
一個(gè) Vue
實(shí)例俺抽,然后傳入了一個(gè) options
參數(shù)敞映,類型為一個(gè)對(duì)象,包括當(dāng)前 Vue
實(shí)例的作用域 el
磷斧、模板綁定的數(shù)據(jù) data
等等振愿。
我們模擬這種 MVVM 模式的時(shí)候也構(gòu)建一個(gè)類,名字就叫 MVVM
弛饭,在使用時(shí)同 Vue 框架類似冕末,需要通過(guò) new
指令創(chuàng)建 MVVM
的實(shí)例并傳入 options
。
// MVVM.js 文件
class MVVM {
constructor(options) {
// 先把 el 和 data 掛在 MVVM 實(shí)例上
this.$el = options.el;
this.$data = options.data;
// 如果有要編譯的模板就開(kāi)始編譯
if (this.el) {
// 數(shù)據(jù)劫持侣颂,就是把對(duì)象所有的屬性添加 get 和 set
new Observer(this.$data);
// 將數(shù)據(jù)代理到實(shí)例上
this.proxyData(this.$data);
// 用數(shù)據(jù)和元素進(jìn)行編譯
new Compile(this.el, this);
}
}
proxyData(data) { // 代理數(shù)據(jù)的方法
Object.keys(data).forEach(key => {
Object.defineProperty(this, key, {
get() {
return data[key];
}
set(newVal) {
data[key] = newVal;
}
});
});
}
}
通過(guò)上面代碼档桃,我們可以看出,在我們 new
一個(gè) MVVM
的時(shí)候憔晒,在參數(shù) options
中傳入了一個(gè) Dom
的根元素節(jié)點(diǎn)和數(shù)據(jù) data
并掛在了當(dāng)前的 MVVM
實(shí)例上藻肄。
當(dāng)存在根節(jié)點(diǎn)的時(shí)候,通過(guò) Observer
類對(duì) data
數(shù)據(jù)進(jìn)行了劫持拒担,并通過(guò) MVVM
實(shí)例的方法 proxyData
把 data
中的數(shù)據(jù)掛在當(dāng)前 MVVM
實(shí)例上嘹屯,同樣對(duì)數(shù)據(jù)進(jìn)行了劫持,是因?yàn)槲覀冊(cè)讷@取和修改數(shù)據(jù)的時(shí)候可以直接通過(guò) this
或 this.$data
从撼,在 Vue 中實(shí)現(xiàn)數(shù)據(jù)劫持的核心方法是 Object.defineProperty
州弟,我們也使用這個(gè)方式通過(guò)添加 getter
和 setter
來(lái)實(shí)現(xiàn)數(shù)據(jù)劫持。
最后使用 Compile
類對(duì)模板和綁定的數(shù)據(jù)進(jìn)行了解析和編譯低零,并渲染在根節(jié)點(diǎn)上婆翔,之所以數(shù)據(jù)劫持和模板解析都使用類的方式實(shí)現(xiàn),是因?yàn)榇a方便維護(hù)和擴(kuò)展掏婶,其實(shí)不難看出啃奴,MVVM
類其實(shí)作為了 Compile
類和 Observer
類的一個(gè)橋梁。
模板編譯 Compile 類的實(shí)現(xiàn)
Compile
類在創(chuàng)建實(shí)例的時(shí)候需要傳入兩個(gè)參數(shù)气堕,第一個(gè)參數(shù)是當(dāng)前 MVVM
實(shí)例作用的根節(jié)點(diǎn)纺腊,第二個(gè)參數(shù)就是 MVVM
實(shí)例,之所以傳入 MVVM
的實(shí)例是為了更方便的獲取 MVVM
實(shí)例上的屬性茎芭。
在 Compile
類中揖膜,我們會(huì)盡量的把一些公共的邏輯抽取出來(lái)進(jìn)行最大限度的復(fù)用,避免冗余代碼梅桩,提高維護(hù)性和擴(kuò)展性壹粟,我們把 Compile
類抽取出的實(shí)例方法主要分為兩大類,輔助方法和核心方法宿百,在代碼中用注釋標(biāo)明趁仙。
1、解析根節(jié)點(diǎn)內(nèi)的 Dom 結(jié)構(gòu)
// Compile.js 文件
class Compile {
constructor(el, vm) {
this.el = this.isElementNode(el) ? el : document.querySelector(el);
this.vm = vm;
// 如過(guò)傳入的根元素存在垦页,才開(kāi)始編譯
if (this.el) {
// 1雀费、把這些真實(shí)的 Dom 移動(dòng)到內(nèi)存中,即 fragment(文檔碎片)
let fragment = this.node2fragment(this.el);
}
}
/* 輔助方法 */
// 判斷是否是元素節(jié)點(diǎn)
isElementNode(node) {
return node.nodeType === 1;
}
/* 核心方法 */
// 將根節(jié)點(diǎn)轉(zhuǎn)移至文檔碎片
node2fragment(el) {
// 創(chuàng)建文檔碎片
let fragment = document.createDocumentFragment();
// 第一個(gè)子節(jié)點(diǎn)
let firstChild;
// 循環(huán)取出根節(jié)點(diǎn)中的節(jié)點(diǎn)并放入文檔碎片中
while (firstChild = el.firstChild) {
fragment.appendChild(firstChild);
}
return fragment;
}
}
上面編譯模板的過(guò)程中痊焊,前提條件是必須存在根元素節(jié)點(diǎn)盏袄,傳入的根元素節(jié)點(diǎn)允許是一個(gè)真實(shí)的 Dom
元素,也可以是一個(gè)選擇器薄啥,所以我們創(chuàng)建了輔助方法 isElementNode
來(lái)幫我們判斷傳入的元素是否是 Dom
辕羽,如果是就直接使用,是選擇器就獲取這個(gè) Dom
垄惧,最終將這個(gè)根節(jié)點(diǎn)存入 this.el
屬性中刁愿。
解析模板的過(guò)程中為了性能,我們應(yīng)取出根節(jié)點(diǎn)內(nèi)的子節(jié)點(diǎn)存放在文檔碎片中(內(nèi)存)到逊,需要注意的是將一個(gè) Dom
節(jié)點(diǎn)內(nèi)的子節(jié)點(diǎn)存入文檔碎片的過(guò)程中代乃,會(huì)在原來(lái)的 Dom
容器中刪除這個(gè)節(jié)點(diǎn)谅辣,所以在遍歷根節(jié)點(diǎn)的子節(jié)點(diǎn)時(shí),永遠(yuǎn)是將第一個(gè)節(jié)點(diǎn)取出存入文檔碎片,直到節(jié)點(diǎn)不存在為止闹获。
2、編譯文檔碎片中的結(jié)構(gòu)
在 Vue 中的模板編譯的主要就是兩部分授账,也是瀏覽器無(wú)法解析的部分倘是,元素節(jié)點(diǎn)中的指令和文本節(jié)點(diǎn)中的 Mustache 語(yǔ)法(雙大括號(hào))。
// Compile.js 文件
class Compile {
constructor(el, vm) {
this.el = this.isElementNode(el) ? el : document.querySelector(el);
this.vm = vm;
// 如過(guò)傳入的根元素存在旷坦,才開(kāi)始編譯
if (this.el) {
// 1掏熬、把這些真實(shí)的 Dom 移動(dòng)到內(nèi)存中,即 fragment(文檔碎片)
let fragment = this.node2fragment(this.el);
// ********** 以下為新增代碼 **********
// 2秒梅、將模板中的指令中的變量和 {{}} 中的變量替換成真實(shí)的數(shù)據(jù)
this.compile(fragment);
// 3旗芬、把編譯好的 fragment 再塞回頁(yè)面中
this.el.appendChild(fragment);
// ********** 以上為新增代碼 **********
}
}
/* 輔助方法 */
// 判斷是否是元素節(jié)點(diǎn)
isElementNode(node) {
return node.nodeType === 1;
}
// ********** 以下為新增代碼 **********
// 判斷屬性是否為指令
isDirective(name) {
return name.includes("v-");
}
// ********** 以上為新增代碼 **********
/* 核心方法 */
// 將根節(jié)點(diǎn)轉(zhuǎn)移至文檔碎片
node2fragment(el) {
// 創(chuàng)建文檔碎片
let fragment = document.createDocumentFragment();
// 第一個(gè)子節(jié)點(diǎn)
let firstChild;
// 循環(huán)取出根節(jié)點(diǎn)中的節(jié)點(diǎn)并放入文檔碎片中
while (firstChild = el.firstChild) {
fragment.appendChild(firstChild);
}
return fragment;
}
// ********** 以下為新增代碼 **********
// 解析文檔碎片
compile(fragment) {
// 當(dāng)前父節(jié)點(diǎn)節(jié)點(diǎn)的子節(jié)點(diǎn),包含文本節(jié)點(diǎn)捆蜀,類數(shù)組對(duì)象
let childNodes = fragment.childNodes;
// 轉(zhuǎn)換成數(shù)組并循環(huán)判斷每一個(gè)節(jié)點(diǎn)的類型
Array.from(childNodes).forEach(node => {
if (this.isElementNode(node)) { // 是元素節(jié)點(diǎn)
// 遞歸編譯子節(jié)點(diǎn)
this.compile(node);
// 編譯元素節(jié)點(diǎn)的方法
this.compileElement(node);
} else { // 是文本節(jié)點(diǎn)
// 編譯文本節(jié)點(diǎn)的方法
this.compileText(node);
}
});
}
// 編譯元素
compileElement(node) {
// 取出當(dāng)前節(jié)點(diǎn)的屬性疮丛,類數(shù)組
let attrs = node.attributes;
Array.form(attrs).forEach(attr => {
// 獲取屬性名幔嫂,判斷屬性是否為指令,即含 v-
let attrName = attr.name;
if (this.isDirective(attrName)) {
// 如果是指令誊薄,取到該屬性值得變量在 data 中對(duì)應(yīng)得值履恩,替換到節(jié)點(diǎn)中
let exp = attr.value;
// 取出方法名
let [, type] = attrName.split("-");
// 調(diào)用指令對(duì)應(yīng)得方法
CompileUtil[type](node, this.vm, exp);
}
});
}
// 編譯文本
compileText(node) {
// 獲取文本節(jié)點(diǎn)的內(nèi)容
let exp = node.contentText;
// 創(chuàng)建匹配 {{}} 的正則表達(dá)式
let reg = /\{\{([^}+])\}\}/g;
// 如果存在 {{}} 則使用 text 指令的方法
if (reg.test(exp)) {
CompileUtil["text"](node, this.vm, exp);
}
}
// ********** 以上為新增代碼 **********
}
上面代碼新增內(nèi)容得主要邏輯就是做了兩件事:
- 調(diào)用
compile
方法對(duì)fragment
文檔碎片進(jìn)行編譯,即替換內(nèi)部指令和 Mustache 語(yǔ)法中變量對(duì)應(yīng)的值呢蔫; - 將編譯好的
fragment
文檔碎片塞回根節(jié)點(diǎn)切心。
在第一個(gè)步驟當(dāng)中邏輯是比較繁瑣的,首先在 compile
方法中獲取所有的子節(jié)點(diǎn)片吊,循環(huán)進(jìn)行編譯绽昏,如果是元素節(jié)點(diǎn)需要遞歸 compile
,傳入當(dāng)前元素節(jié)點(diǎn)俏脊。在這個(gè)過(guò)程當(dāng)中抽取出了兩個(gè)方法全谤,compileElement
和 compileText
用來(lái)對(duì)元素節(jié)點(diǎn)的屬性和文本節(jié)點(diǎn)進(jìn)行處理。
compileElement
中的核心邏輯就是處理指令爷贫,取出元素節(jié)點(diǎn)所有的屬性判斷是否是指令啼县,是指令則調(diào)用指令對(duì)應(yīng)的方法。compileText
中的核心邏輯就是取出文本的內(nèi)容通過(guò)正則表達(dá)式匹配出被 Mustache 語(yǔ)法的 “{{ }}” 包裹的內(nèi)容沸久,并調(diào)用處理文本的 text
方法季眷。
文本節(jié)點(diǎn)的內(nèi)容有可能存在 “{{ }} {{ }} {{ }}”,正則匹配默認(rèn)是貪婪的卷胯,為了防止第一個(gè) “{” 和最后一個(gè) “}” 進(jìn)行匹配子刮,所以在正則表達(dá)式中應(yīng)使用非貪婪匹配。
在調(diào)用指令的方法時(shí)都是調(diào)用的 CompileUtil
下對(duì)應(yīng)的方法窑睁,我們之所以單獨(dú)把這些指令對(duì)應(yīng)的方法抽離出來(lái)存儲(chǔ)在 CompileUtil
對(duì)象下的目的是為了解耦挺峡,因?yàn)楹竺嫫渌念愡€要使用。
3担钮、CompileUtil 對(duì)象中指令方法的實(shí)現(xiàn)
CompileUtil
中存儲(chǔ)著所有的指令方法及指令對(duì)應(yīng)的更新方法橱赠,由于 Vue 的指令很多,我們這里只實(shí)現(xiàn)比較典型的 v-model
和 “{{ }}” 對(duì)應(yīng)的方法箫津,考慮到后續(xù)更新的情況狭姨,我們統(tǒng)一把設(shè)置值到 Dom
中的邏輯抽取出對(duì)應(yīng)上面兩種情況的方法,存放到 CompileUtil
的 updater
對(duì)象中苏遥。
// CompileUtil.js 文件
CompileUtil = {};
// 更新節(jié)點(diǎn)數(shù)據(jù)的方法
CompileUti.updater = {
// 文本更新
textUpdater(node, value) {
node.textContent = value;
},
// 輸入框更新
modelUpdater(node, value) {
node.value = value;
}
};
復(fù)制代碼
這部分的整個(gè)思路就是在 Compile
編譯模板后處理 v-model
和 “{{ }}” 時(shí)饼拍,其實(shí)都是用 data
中的數(shù)據(jù)替換掉 fragment
文檔碎片中對(duì)應(yīng)的節(jié)點(diǎn)中的變量。因此會(huì)經(jīng)常性的獲取 data
中的值田炭,在更新節(jié)點(diǎn)時(shí)又會(huì)重新設(shè)置 data
中的值师抄,所以我們抽離出了三個(gè)方法 getVal
、getTextVal
和 setVal
掛在了 CompileUtil
對(duì)象下教硫。
// CompileUtil.js 文件
// 獲取 data 值的方法
CompileUtil.getVal = function (vm, exp) {
// 將匹配的值用 . 分割開(kāi)叨吮,如 vm.data.a.b
exp = exp.split(".");
// 歸并取值
return exp.reduce((prev, next) => {
return prev[next];
}, vm.$data);
};
// 獲取文本 {{}} 中變量在 data 對(duì)應(yīng)的值
CompileUtil.getTextVal = function (vm, exp) {
// 使用正則匹配出 {{ }} 間的變量名辆布,再調(diào)用 getVal 獲取值
return exp.replace(/\{\{([^}]+)\}\}/g, (...args) => {
return this.getVal(vm, args[1]);
});
};
// 設(shè)置 data 值的方法
CompileUtil.setVal = function (vm, exp, newVal) {
exp = exp.split(".");
return exp.reduce((prev, next, currentIndex) => {
// 如果當(dāng)前歸并的為數(shù)組的最后一項(xiàng),則將新值設(shè)置到該屬性
if(currentIndex === exp.length - 1) {
return prev[next] = newVal
}
// 繼續(xù)歸并
return prev[next];
});
}
獲取和設(shè)置 data
的值兩個(gè)方法 getVal
和 setVal
思路相似茶鉴,由于獲取的變量層級(jí)不定谚殊,可能是 data.a
,也可能是 data.obj.a.b
蛤铜,所以都是使用歸并的思路,借用 reduce
方法實(shí)現(xiàn)的丛肢,區(qū)別在于 setVal
方法在歸并過(guò)程中需要判斷是不是歸并到最后一級(jí)围肥,如果是則設(shè)置新值,而 getTextVal
就是在 getVal
外包了一層處理 “{{ }}” 的邏輯蜂怎。
在這些準(zhǔn)備工作就緒以后就可以實(shí)現(xiàn)我們的主邏輯穆刻,即對(duì) Compile
類中解析的文本節(jié)點(diǎn)和元素節(jié)點(diǎn)指令中的變量用 data
值進(jìn)行替換,還記得前面說(shuō)針對(duì) v-model
和 “{{ }}” 進(jìn)行處理杠步,因此設(shè)計(jì)了 model
和 text
兩個(gè)核心方法氢伟。
CompileUtil.model
方法的實(shí)現(xiàn):
// CompileUtil.js 文件
// 處理 v-model 指令的方法
CompileUtil.model = function (node, vm, exp) {
// 獲取賦值的方法
let updateFn = this.updater["modelUpdater"];
// 獲取 data 中對(duì)應(yīng)的變量的值
let value = this.getVal(vm, exp);
// 添加觀察者,作用與 text 方法相同
new Watcher(vm, exp, newValue => {
updateFn && updateFn(node, newValue);
});
// v-model 雙向數(shù)據(jù)綁定幽歼,對(duì) input 添加事件監(jiān)聽(tīng)
node.addEventListener('input', e => {
// 獲取輸入的新值
let newValue = e.target.value;
// 更新到節(jié)點(diǎn)
this.setVal(vm, exp, newValue);
});
// 第一次設(shè)置值
updateFn && updateFn(vm, value);
};
復(fù)制代碼
CompileUtil.text
方法的實(shí)現(xiàn):
// CompileUtil.js 文件
// 處理文本節(jié)點(diǎn) {{}} 的方法
CompileUtil.text = function (node, vm, exp) {
// 獲取賦值的方法
let updateFn = this.updater["textUpdater"];
// 獲取 data 中對(duì)應(yīng)的變量的值
let value = this.getTextVal(vm, exp);
// 通過(guò)正則替換朵锣,將取到數(shù)據(jù)中的值替換掉 {{ }}
exp.replace(/\{\{([^}]+)\}\}/g, (...args) => {
// 解析時(shí)遇到了模板中需要替換為數(shù)據(jù)值的變量時(shí),應(yīng)該添加一個(gè)觀察者
// 當(dāng)變量重新賦值時(shí)甸私,調(diào)用更新值節(jié)點(diǎn)到 Dom 的方法
new Watcher(vm, arg[1], newValue => {
// 如果數(shù)據(jù)發(fā)生變化诚些,重新獲取新值
updateFn && updateFn(node, newValue);
});
});
// 第一次設(shè)置值
updateFn && updateFn(vm, value);
};
上面兩個(gè)方法邏輯相似,都獲取了各自的 updater
中的方法皇型,對(duì)值進(jìn)行設(shè)置诬烹,并且在設(shè)置的同時(shí)為了后續(xù) data
中的數(shù)據(jù)修改,視圖的更新弃鸦,創(chuàng)建了 Watcher
的實(shí)例绞吁,并在內(nèi)部用新值重新更新節(jié)點(diǎn),不同的是 Vue 的 v-model
指令在表單中實(shí)現(xiàn)了雙向數(shù)據(jù)綁定唬格,只要表單元素的 value
值發(fā)生變化家破,就需要將新值更新到 data
中,并響應(yīng)到頁(yè)面上购岗。
所以我們的實(shí)現(xiàn)方式是給這個(gè)綁定了 v-model
的表單元素監(jiān)聽(tīng)了 input
事件员舵,并在事件中實(shí)時(shí)的將新的 value
值更新到 data
中,至于 data
中的改變后響應(yīng)到頁(yè)面中需要另外三個(gè)類 Watcher
藕畔、Observer
和 Dep
共同實(shí)現(xiàn)马僻,我們下面就來(lái)實(shí)現(xiàn) Watcher
類。
觀察者 Watcher 類的實(shí)現(xiàn)
在 CompileUtil
對(duì)象的方法中創(chuàng)建 Watcher
實(shí)例的時(shí)候傳入了三個(gè)參數(shù)注服,即 MVVM
的實(shí)例韭邓、模板綁定數(shù)據(jù)的變量名 exp
和一個(gè) callback
措近,這個(gè) callback
內(nèi)部邏輯是為了更新數(shù)據(jù)到 Dom
,所以我們的 Watcher
類內(nèi)部要做的事情就清晰了女淑,獲取更改前的值存儲(chǔ)起來(lái)瞭郑,并創(chuàng)建一個(gè) update
實(shí)例方法,在值被更改時(shí)去執(zhí)行實(shí)例的 callback
以達(dá)到視圖的更新鸭你。
// Watcher.js 文件
class Watcher {
constructor(vm, exp, callback) {
this.vm = vm;
this.exp = exp;
this.callback = callback;
// 更改前的值
this.value = this.get();
}
get() {
// 將當(dāng)前的 watcher 添加到 Dep 類的靜態(tài)屬性上
Dep.target = this;
// 獲取值觸發(fā)數(shù)據(jù)劫持
let value = CompileUtil.getVal(this.vm, this.exp);
// 清空 Dep 上的 Watcher屈张,防止重復(fù)添加
Dep.target = null;
return value;
}
update() {
// 獲取新值
let newValue = CompileUtil.getVal(this.vm, this.exp);
// 獲取舊值
let oldValue = this.value;
// 如果新值和舊值不相等,就執(zhí)行 callback 對(duì) dom 進(jìn)行更新
if(newValue !== oldValue) {
this.callback();
}
}
}
復(fù)制代碼
看到上面代碼一定有兩個(gè)疑問(wèn):
- 使用
get
方法獲取舊值得時(shí)候?yàn)槭裁匆獙?dāng)前的實(shí)例掛在Dep
上袱巨,在獲取值后為什么又清空了阁谆; -
update
方法內(nèi)部執(zhí)行了callback
函數(shù),但是update
在什么時(shí)候執(zhí)行愉老。
這就是后面兩個(gè)類 Dep
和 observer
要做的事情场绿,我們首先來(lái)介紹 Dep
,再介紹 Observer
最后把他們之間的關(guān)系整個(gè)串聯(lián)起來(lái)嫉入。
發(fā)布訂閱 Dep 類的實(shí)現(xiàn)
其實(shí)發(fā)布訂閱說(shuō)白了就是把要執(zhí)行的函數(shù)統(tǒng)一存儲(chǔ)在一個(gè)數(shù)組中管理焰盗,當(dāng)達(dá)到某個(gè)執(zhí)行條件時(shí),循環(huán)這個(gè)數(shù)組并執(zhí)行每一個(gè)成員咒林。
// Dep.js 文件
class Dep {
constructor() {
this.subs = [];
}
// 添加訂閱
addSub(watcher) {
this.subs.push(watcher);
}
// 通知
notify() {
this.subs.forEach(watcher => watcher.update());
}
}
在 Dep
類中只有一個(gè)屬性熬拒,就是一個(gè)名為 subs
的數(shù)組,用來(lái)管理每一個(gè) watcher
垫竞,即 Watcher
類的實(shí)例梦湘,而 addSub
就是用來(lái)將 watcher
添加到 subs
數(shù)組中的,我們看到 notify
方法就解決了上面的一個(gè)疑問(wèn)件甥,Watcher
類的 update
方法是怎么執(zhí)行的捌议,就是這樣循環(huán)執(zhí)行的。
接下來(lái)我們整合一下盲點(diǎn):
-
Dep
實(shí)例在哪里創(chuàng)建聲明引有,又是在哪里將watcher
添加進(jìn)subs
數(shù)組的瓣颅; -
Dep
的notify
方法應(yīng)該在哪里調(diào)用; -
Watcher
內(nèi)容中譬正,使用get
方法獲取舊值得時(shí)候?yàn)槭裁匆獙?dāng)前的實(shí)例掛在Dep
上宫补,在獲取值后為什么又清空了。
這些問(wèn)題在最后一個(gè)類 Observer
實(shí)現(xiàn)的時(shí)候都將清晰曾我,下面我們重點(diǎn)來(lái)看最后一部分核心邏輯粉怕。
數(shù)據(jù)劫持 Observer 類的實(shí)現(xiàn)
還記得實(shí)現(xiàn) MVVM
類的時(shí)候就創(chuàng)建了這個(gè)類的實(shí)例,當(dāng)時(shí)傳入的參數(shù)是 MVVM
實(shí)例的 data
屬性抒巢,在 MVVM
中把數(shù)據(jù)通過(guò) Object.defineProperty
掛到了實(shí)例上贫贝,并添加了 getter
和 setter
,其實(shí) Observer
類主要目的就是給 data
內(nèi)的所有層級(jí)的數(shù)據(jù)都進(jìn)行這樣的操作。
// Observer.js 文件
class Observer {
constructor (data) {
this.observe(data);
}
// 添加數(shù)據(jù)監(jiān)聽(tīng)
observe(data) {
// 驗(yàn)證 data
if(!data || typeof data !== 'object') {
return;
}
// 要對(duì)這個(gè) data 數(shù)據(jù)將原有的屬性改成 set 和 get 的形式
// 要將數(shù)據(jù)一一劫持稚晚,先獲取到 data 的 key 和 value
Object.keys(data).forEach(key => {
// 劫持(實(shí)現(xiàn)數(shù)據(jù)響應(yīng)式)
this.defineReactive(data, key, data[key]);
this.observe(data[key]); // 深度劫持
});
}
// 數(shù)據(jù)響應(yīng)式
defineReactive (object, key, value) {
let _this = this;
// 每個(gè)變化的數(shù)據(jù)都會(huì)對(duì)應(yīng)一個(gè)數(shù)組崇堵,這個(gè)數(shù)組是存放所有更新的操作
let dep = new Dep();
// 獲取某個(gè)值被監(jiān)聽(tīng)到
Object.defineProperty(object, key, {
enumerable: true,
configurable: true,
get () { // 當(dāng)取值時(shí)調(diào)用的方法
Dep.target && dep.addSub(Dep.target);
return value;
},
set (newValue) { // 當(dāng)給 data 屬性中設(shè)置的值適合,更改獲取的屬性的值
if(newValue !== value) {
_this.observe(newValue); // 重新賦值如果是對(duì)象進(jìn)行深度劫持
value = newValue;
dep.notify(); // 通知所有人數(shù)據(jù)更新了
}
}
});
}
}
在的代碼中 observe
的目的是遍歷對(duì)象客燕,在內(nèi)部對(duì)數(shù)據(jù)進(jìn)行劫持鸳劳,即添加 getter
和 setter
,我們把劫持的邏輯單獨(dú)抽取成 defineReactive
方法也搓,需要注意的是 observe
方法在執(zhí)行最初就對(duì)當(dāng)前的數(shù)據(jù)進(jìn)行了數(shù)據(jù)類型驗(yàn)證赏廓,然后再循環(huán)對(duì)象每一個(gè)屬性進(jìn)行劫持,目的是給同為 Object
類型的子屬性遞歸調(diào)用 observe
進(jìn)行深度劫持傍妒。
在 defineReactive
方法中幔摸,創(chuàng)建了 Dep
的實(shí)例,并對(duì) data
的數(shù)據(jù)使用 get
和 set
進(jìn)行劫持拍顷,還記得在模板編譯的過(guò)程中,遇到模板中綁定的變量塘幅,就會(huì)解析昔案,并創(chuàng)建 watcher
,會(huì)在 Watcher
類的內(nèi)部獲取舊值电媳,即當(dāng)前的值踏揣,這樣就觸發(fā)了 get
,在 get
中就可以將這個(gè) watcher
添加到 Dep
的 subs
數(shù)組中進(jìn)行統(tǒng)一管理匾乓,因?yàn)樵诖a中獲取 data
中的值操作比較多捞稿,會(huì)經(jīng)常觸發(fā) get
,我們又要保證 watcher
不會(huì)被重復(fù)添加拼缝,所以在 Watcher
類中娱局,獲取舊值并保存后,立即將 Dep.target
賦值為 null
咧七,并且在觸發(fā) get
時(shí)對(duì) Dep.target
進(jìn)行了短路操作衰齐,存在才調(diào)用 Dep
的 addSub
進(jìn)行添加。
而 data
中的值被更改時(shí)继阻,會(huì)觸發(fā) set
耻涛,在 set
中做了性能優(yōu)化,即判斷重新賦的值與舊值是否相等瘟檩,如果相等就不重新渲染頁(yè)面抹缕,不等的情況有兩種,如果原來(lái)這個(gè)被改變的值是基本數(shù)據(jù)類型沒(méi)什么影響墨辛,如果是引用類型卓研,我們需要對(duì)這個(gè)引用類型內(nèi)部的數(shù)據(jù)進(jìn)行劫持,因此遞歸調(diào)用了 observe
睹簇,最后調(diào)用 Dep
的 notify
方法進(jìn)行通知鉴分,執(zhí)行 notify
就會(huì)執(zhí)行 subs
中所有被管理的 watcher
的 update
哮幢,就會(huì)執(zhí)行創(chuàng)建 watcher
時(shí)的傳入的 callback
,就會(huì)更新頁(yè)面志珍。
在 MVVM
類將 data
的屬性掛在 MVVM
實(shí)例上并劫持與通過(guò) Observer
類對(duì) data
的劫持還有一層聯(lián)系橙垢,因?yàn)檎麄€(gè)發(fā)布訂閱的邏輯都是在 data
的 get
和 set
上,只要觸發(fā)了 MVVM
中的 get
和 set
內(nèi)部會(huì)自動(dòng)返回或設(shè)置 data
對(duì)應(yīng)的值伦糯,就會(huì)觸發(fā) data
的 get
和 set
柜某,就會(huì)執(zhí)行發(fā)布訂閱的邏輯。
通過(guò)上面長(zhǎng)篇大論的敘述后敛纲,這個(gè) MVVM 模式用到的幾個(gè)類的關(guān)系應(yīng)該完全敘述清晰了喂击,雖然比較抽象,但是細(xì)心琢磨還是會(huì)明白之間的關(guān)系和邏輯淤翔,下面我們就來(lái)對(duì)我們自己實(shí)現(xiàn)的這個(gè) MVVM 進(jìn)行驗(yàn)證翰绊。
驗(yàn)證 MVVM
我們按照 Vue 的方式根據(jù)自己的 MVVM 實(shí)現(xiàn)的內(nèi)容簡(jiǎn)單的寫了一個(gè)模板如下:
<!-- index.html 文件 -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>MVVM</title>
</head>
<body>
<div id="app">
<!-- 雙向數(shù)據(jù)綁定 靠的是表單 -->
<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>
打開(kāi) Chrom 瀏覽器的控制臺(tái),在上面通過(guò)下面操作來(lái)驗(yàn)證:
- 輸入
vm.message = "hello"
看頁(yè)面是否更新旁壮; - 輸入
vm.$data.message = "hello"
看頁(yè)面是否更新监嗜; - 改變文本輸入框內(nèi)的值,看頁(yè)面的其他元素是否更新抡谐。
總結(jié)
通過(guò)上面的測(cè)試裁奇,相信應(yīng)該理解了 MVVM 模式對(duì)于前端開(kāi)發(fā)重大的意義,實(shí)現(xiàn)了雙向數(shù)據(jù)綁定麦撵,實(shí)時(shí)保證 View 層與 Model 層的數(shù)據(jù)同步刽肠,并可以讓我們?cè)陂_(kāi)發(fā)時(shí)基于數(shù)據(jù)編程,而最少的操作 Dom
免胃,這樣大大提高了頁(yè)面渲染的性能音五,也可以使我們把更多的精力用于業(yè)務(wù)邏輯的開(kāi)發(fā)上。