1 什么是MVVM
? MVVM 是 Model-View-ViewModel (模型-視圖-視圖模型)的縮寫,其本質(zhì) MVC (Model-View-Controller)的改進(jìn)版,將其中 View 前端視圖層的狀態(tài)和行為抽象化,以便將視圖 UI 和業(yè)務(wù)邏輯分離。[1]
? MVVM 中 M(Model,模型)指的是前端靜態(tài)數(shù)據(jù)及后端傳遞數(shù)據(jù),V(View,視圖)指的是前端顯示頁面,VM(ViewModel,視圖模型)是 MVVM 模式的核心,它是連接 View 與 Model 的橋梁。
? 在 MVVM 模式下,View 和 Model 是不能直接通信的,它們通過 ViewModel 來通信。ViewModel 有兩個方向:
? 一是通過數(shù)據(jù)綁定,當(dāng) Model 數(shù)據(jù)發(fā)生變化時,ViewModel 的 observer 觀察者監(jiān)聽到數(shù)據(jù)變化,然后通知對應(yīng) View 視圖自動更新;
? 二是通過 DOM 事件監(jiān)聽,當(dāng)用戶操作視圖時,ViewModel 的 observer 觀察者監(jiān)聽到視圖變化,然后通知對應(yīng)的Model 數(shù)據(jù)改動。[2]

? 通過 數(shù)據(jù)綁定和 DOM 事件監(jiān)聽,MVVM 模式實(shí)現(xiàn)了 View 和 View 的互相通信,即數(shù)據(jù)的雙向綁定。
2 為什么會產(chǎn)生MVVM
? 1989 件,歐洲核子研究中心的物理學(xué)家 Tim Berners-Lee 發(fā)明了超文本標(biāo)記語言(HyperText Markup Language),簡稱HTML,并在 1993 年成為互聯(lián)網(wǎng)草案。
? 最早的 HTML 頁面是完全靜態(tài)的網(wǎng)頁,它們是預(yù)先編寫好的存放在 Web 服務(wù)器上的 html 文件。瀏覽器請求某個 url 時,Web 服務(wù)器把對應(yīng)的 html 文件 傳遞給瀏覽器,顯示 html 文件內(nèi)容。
? 如果需要針對不同的用戶顯示不同的頁面,不可能給成千上萬的用戶準(zhǔn)備成千上萬的 html 文件。所以,服務(wù)器需要針對不同的用戶,動態(tài)生成不同的 html 文件。而在 html 文件中,大多數(shù)字符串都是不變的 HTML 片段,變化的只有少數(shù)和用戶相關(guān)的數(shù)據(jù),所以出現(xiàn)了創(chuàng)建動態(tài) HTML 的方式:ASP、JSP 和 PHP。
? 在 PHP 中,一個 PHP 文件就是一個 HTML 頁面,需要替換的變量用特殊的 <?php ?> 標(biāo)記出來,再配合循環(huán)、條件判斷等,動態(tài)創(chuàng)建出HTML。
? 但是,瀏覽器顯示了一個 HTML 頁面,一旦需要更新內(nèi)容,唯一的方法就是重新向服務(wù)器獲取一份新的 HTML 內(nèi)容。直到 1995 年底,JavaScript 被引入到瀏覽器后,瀏覽器可以通過 JavaScript 對頁面進(jìn)行一些修改。JavaScript 還可以通過修改 HTML 的 DOM 結(jié)構(gòu)和 CSS 來實(shí)現(xiàn)一些動畫效果,這些功能無法通過服務(wù)器完成,必須在瀏覽器實(shí)現(xiàn)。
? JavaScript 可以使用瀏覽器提供的原生 API,直接操作 DOM 節(jié)點(diǎn)。但是原生 API 并不好用,且有瀏覽器兼容性問題。JavaScript 庫 JQuery 出現(xiàn)后,已其簡潔的 API 迅速推廣。
? 現(xiàn)在,由于前端開發(fā)混合了 HTML、CSS 和 JavaScript,且前端頁面越來越復(fù)雜,用戶對于交互性的要求也原來越高,導(dǎo)致代碼的組織和維護(hù)難度更加復(fù)雜,MVVM 應(yīng)運(yùn)而生。
? MVVM 借鑒了 MVC 分層開發(fā)的思想,在前端頁面中,把 Model 用 純 JavaScript 對象表示,View 負(fù)責(zé)顯示,做到最大限度的分離。兩者通過 ViewModel 相關(guān)聯(lián),ViewModel 負(fù)責(zé)把 Model 的數(shù)據(jù)同步到 View 顯示,還負(fù)責(zé)把 View 的修改同步回 Model。
? 使用 JQuery 和 MVVM 操作 DOM 節(jié)點(diǎn)的對比:
<!-- HTML -->
<p>Hello, <span id="name">Zhangsan</span></p>
<p>You are <span id="age">12</span></p>
? 使用 JQuery 修改 name 和 age 節(jié)點(diǎn)的內(nèi)容:
// JQuery
let name = 'Lisi'
let age = 13
$('#name').text(name)
$('#age').text(age)
? 使用 MVVM 修改 name 和 age 節(jié)點(diǎn)的內(nèi)容:
// Model 中的 person 與 View 中的 DOM 節(jié)點(diǎn)相關(guān)聯(lián)
let person = {
name: 'zhangsan',
age: 12
}
// MVVM
person.name = 'lisi'
person.age = 13
? 由此可見,MVVM 并不關(guān)心頁面的 DOM 結(jié)構(gòu),而是關(guān)心數(shù)據(jù)如何存儲。修改頁面內(nèi)容是并不操作 DOM,而是直接修改數(shù)據(jù)內(nèi)容。這讓我們的關(guān)注點(diǎn)從如何操作 DOM 變?yōu)榱?如何更新數(shù)據(jù)的狀態(tài),而操作數(shù)據(jù)狀態(tài)比操作 DOM 簡單的多。MVVM 模式的使用將開發(fā)者從繁瑣的 DOM 操作中解脫出來。[3]
3 MVVM優(yōu)缺點(diǎn)
3.1 MVVM的優(yōu)點(diǎn)
-
自動更新 DOM
利用雙向綁定,數(shù)據(jù)更新后視圖自動更新,將開發(fā)者從繁瑣的手動
DOM中解放。 -
降低代碼耦合
分離
View和Model,降低代碼耦合。當(dāng)View變化的時候,Model可以不變;當(dāng)Model變化的時候,View也可以不變。 -
提高可重用性
一個
ViewModel可以綁定到不同的View上,讓很多View重用這段ViewModel。 -
提高可測試姓
ViewModel的存在可以幫助開發(fā)者更好的編寫測試代碼。
3.2 MVVM的缺點(diǎn)
-
Bug難被調(diào)試
由于采用雙向綁定模式,當(dāng)界面異常時,有可能是 View 的代碼有問題,也有可能是
Model代碼有問題。數(shù)據(jù)綁定使得一個位置的Bug快速被傳遞到了另一個位置,定位原位置變得困難。另外,由于數(shù)據(jù)綁定的聲明是指令式的寫在View模板中,這些內(nèi)容無法采用debug斷點(diǎn)調(diào)試。 -
占用內(nèi)存多
一個大的模塊中的
model也會很大,雖然使用方便也很容易保證了數(shù)據(jù)的一致性,但是長期持有,不釋放內(nèi)存造成耗費(fèi)很多內(nèi)存。 -
維護(hù)成本提高
對于大型的圖形應(yīng)用程序,視圖狀態(tài)較多,
ViewModel的構(gòu)建和維護(hù)成本提高。[4]
4 MVVM簡單實(shí)現(xiàn)
? 本部分MVVM框架主要分為三部分,Compile 模板編譯、 Observer 數(shù)據(jù)劫持與發(fā)布訂閱連接視圖與數(shù)據(jù)。

? 本部分為代碼按步實(shí)現(xiàn)過程,完整代碼見 5 完整代碼。
4.1 創(chuàng)建并使用 MVVM 對象
? 首先,創(chuàng)建一個 MVVM 對象,并在模板中引入。MVVM 是連接 Compiler 模板編譯與 Observer 數(shù)據(jù)劫持的橋梁。
4.1.1 創(chuàng)建 MVVM 對象
? 創(chuàng)建 MVVM 對象并將屬性綁定在實(shí)例上。
// MVVM.js
class MVVM {
constructor (options) {
// 一般情況下,在寫庫或者框架時,都需要將屬性掛載到實(shí)例上,保證其原型或方法能夠取到該屬性
this.$el = options.el
this.$data = options.data
}
}
4.1.2 在模板中使用 MVVM 對象
? 在模板中引入 MVVM 對象并實(shí)例化
<!-- index.html -->
<!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">
<input type="text" v-model="person.name">
<div>{{person.name}}</div>
{{person.name}}{{person.age}}
</div>
<script src="./MVVM.JS"></script>
<script>
// 實(shí)例化 MVVM 對象
let vm = new MVVM({
el: '#app',
data: {
person: {
name: 'zhangsan',
age: 12
}
}
})
</script>
</body>
</html>
4.2 Compiler 模板編譯
? 完成 4.1 創(chuàng)建并引入 MVVM 對象 后,頁面顯示的是模板字符串,需采用 Compiler 模板編譯,將模板字符串內(nèi)容替換為實(shí)例數(shù)據(jù)。
4.2.1 創(chuàng)建并使用 Compiler 對象
? 創(chuàng)建 Compiler 對象 并在 MVVM 對象中使用,由于需要對文檔 DOM 中模板內(nèi)容使用實(shí)例中的數(shù)據(jù)進(jìn)行替換,故在 Compiler 中引入文檔節(jié)點(diǎn) el 與實(shí)例 vm(this)。
4.2.1.1 創(chuàng)建 Compiler 對象
? 用戶在傳入 el 時,可能會傳入 '#app' 或 document.getElementById('app') 形式,對此,需進(jìn)行是否是節(jié)點(diǎn)判斷。
? 為實(shí)現(xiàn)解耦,將 Compiler 對象的方法整體氛圍三部分:核心方法、輔助方法、編譯工具。核心方法主要用來真實(shí)替換模板與數(shù)據(jù),輔助方法用來進(jìn)行是判斷否是元素、是否是文本及提取指令等操作。
// Compiler.js
class Compiler {
constructor (el, vm) { // el 為模板,vm 為 this 實(shí)例
// el 的值可能是字符串 '#app',也有可能是元素 document.getElementById('#app')
// 判斷 el屬性 是否是元素,如果不是元素,則獲取它
// 為了擴(kuò)展時所有類的屬性都能在原型上取到,將所有值都綁定到實(shí)例上
this.el = this.isElementNode(el) ? el : document.querySelector(el)
this.vm = vm
}
}
/**
* 輔助方法,如判斷是否是元素,判斷是否是文本,判斷指令
*/
/**
* 判斷是否是元素節(jié)點(diǎn)
* @param {*} node 節(jié)點(diǎn)
*/
isElementNode (node) {
return node.nodeType === 1
}
4.2.1.2 在 MVVM 對象 中使用 Compiler 對象
? 引入 ompiler 對象后的 MVVM 對象如下,此時需注意,只有在用戶傳入 DOM 節(jié)點(diǎn)即 this.$el 為 true 時才進(jìn)行編譯。
// MVVM.js
class MVVM {
constructor (options) {
this.$el = options.el
this.$data = options.data
// 如果有需要編譯的模板,則開始編譯
if (this.$el) {
// 用數(shù)據(jù)和元素進(jìn)行編譯
new Compiler(this.$el, this) // 后期 this 上可能會有很多屬性,this.$el 模板中也需要很多屬性而不僅僅是 this.$data,所以此處使用范圍更廣的 this
}
}
}
4.2.2 編譯執(zhí)行
? 在匹配 data 時,如果每匹配到一個數(shù)據(jù)就渲染一次,會造成頁面不停的回流與重繪,可先將模板放入內(nèi)存中,在內(nèi)存中完全替換完畢后,再放回頁面,性能比每匹配到一個就替換性能更佳。此部分主要分為三步:
? 1. 將真實(shí) DOM 節(jié)點(diǎn)放入內(nèi)存;2. 在內(nèi)存內(nèi)對模板內(nèi)容進(jìn)行替換;3. 替換好的節(jié)點(diǎn)重新渲染回頁面
// Compiler.js
class Compiler {
constructor (el, vm) {
this.el = this.isElementNode(el) ? el : document.querySelector(el)
this.vm = vm
// 在匹配 data 時,如果每匹配到一個數(shù)據(jù)就渲染一次,會造成頁面不停的回流與重繪,可先將模板放入內(nèi)存中,在內(nèi)存中完全替換完畢后,再放回頁面,性能比每匹配到一個就替換性能更佳。
// 把當(dāng)前節(jié)點(diǎn)中的元素獲取到,放到內(nèi)存中
if (this.el) { // 如果能獲取到這個元素,才開始編譯
// 1. 通過文檔碎片 fragemnt 先把真實(shí) DOM 移入到內(nèi)存中,在內(nèi)存中操作 DOM 比在真實(shí) DOM 中操作快
let fragement = this.node2fragement(this.el)
// 2. 提取 fragement 中的元素節(jié)點(diǎn) v-model 和文本節(jié)點(diǎn) {{}} 進(jìn)行編譯,對節(jié)點(diǎn)中的內(nèi)容進(jìn)行替換
this.compile(fragement)
// 3. 把編譯好的 fragement 放回頁面中
this.el.appendChild(fragement)
}
}
}
4.2.2.1 將真實(shí) DOM 節(jié)點(diǎn)放入內(nèi)存
? 此處注意 appendChild 的移動性,其在將頁面 DOM 節(jié)點(diǎn)移入內(nèi)存中的同時,會將頁面中原有節(jié)點(diǎn)移除。
// Compiler.js
class Compiler {
/**
* 核心方法
*/
/**
* 頁面 DOM 節(jié)點(diǎn) 轉(zhuǎn) 文檔碎片 節(jié)點(diǎn)
* @param {*} node 頁面 DOM 節(jié)點(diǎn)
*/
node2fragement (node) { // 需要將 node(el)中的內(nèi)容全部放入到內(nèi)存中
let fragement = document.createDocumentFragment() // 創(chuàng)建文檔碎片,存放于內(nèi)存中
let firstChild
while (firstChild = node.firstChild) { // firstChild = node.firstChild 這樣永遠(yuǎn)拿到的第第一個子元素,會出現(xiàn)死循環(huán),所以可以拿到一個子元素就將其放入內(nèi)存中,然后使用 appendChild 將 node 中對應(yīng)的子節(jié)點(diǎn)移除,下次遍歷時自動獲取到下一個子節(jié)點(diǎn)。
// 頁面 DOM 都具有 DOM映射,將頁面一個節(jié)點(diǎn)移入內(nèi)存中,則頁面節(jié)點(diǎn)少一個
// appendChild 具有移動性,可以對 DOM 節(jié)點(diǎn)進(jìn)行移動
fragement.appendChild(firstChild)
}
return fragement
}
}
4.2.2.2 在內(nèi)存內(nèi)對模板內(nèi)容進(jìn)行替換
? 注意在遍歷節(jié)點(diǎn)時,對于元素節(jié)點(diǎn),其還有可能存在子元素及更深層內(nèi)容,此時需要遞歸檢查。編譯主要分為編譯元素(含 'v-' 指令)部分和編譯文本兩部分。
? 這一步只是原始 data 中的數(shù)據(jù)在初始化頁面時替換模板顯示于頁面,即初始化賦值,并未考慮更新。
// Compiler.js
class Compiler {
/**
* 核心方法
*/
/**
* 核心編譯方法:編譯所有節(jié)點(diǎn)
* @param {*} fragement 文檔碎片(內(nèi)存中的所有節(jié)點(diǎn))
*/
compile (fragement) {
let childNodes = [...fragement.childNodes] // 拿到的是 類數(shù)組,需轉(zhuǎn)為數(shù)組
childNodes.forEach(node => {
if (this.isElementNode(node)) { // 元素節(jié)點(diǎn)
// 編譯元素
this.compileElement(node)
// 如果是元素節(jié)點(diǎn),還需要遞歸深入檢查子元素節(jié)點(diǎn)和文本節(jié)點(diǎn)
this.compile(node) // 注意:此處還是使用 this,因?yàn)?forEach 中回調(diào)用的箭頭函數(shù),現(xiàn)在的 this 還是指向?qū)嵗? } else { // 文本節(jié)點(diǎn)
// 編譯器文本
this.compileText(node)
}
})
}
}
? 編譯元素
// Compiler.js
class Compiler {
/**
* 核心方法
*/
/**
* 編譯元素
* @param {*} node 節(jié)點(diǎn)
*/
compileElement (node) {
let attributes = [...node.attributes]
attributes.forEach(attr => {
// 判斷是否是指令
let {name: attrName, value: expr} = attr
if (this.isDirective(attrName)) {
// 取到對應(yīng)的值,放到節(jié)點(diǎn)中
let [, directive] = attrName.split('-')
let [directiveName, eventName] = directive.split(':')
CompileUtil[directiveName](node, this.vm, expr, eventName) // 去 this.vm 中 找到 expr 值放入到 node 中
}
})
}
}
// 通過 CompileUtil,將 compileElement 和 compileText 中的實(shí)際編譯內(nèi)容拆分解耦,以后增加新的指令方法只需在 CompileUtil 中增加對應(yīng)方法即可
/**
* 編譯工具
*/
CompileUtil = {
/**
* 根據(jù)表達(dá)式獲取實(shí)例上對應(yīng)的數(shù)據(jù)
* @param {*} vm
* @param {*} expr
*/
getVal (vm, expr) {
return expr.split('.').reduce((prev, next) => {
return prev[next]
}, vm.$data)
}
},
/**
* 編譯輸入框
* @param {*} node
* @param {*} vm
* @param {*} expr
*/
model (node, vm, expr) { // 這里的 expr 是 字符串 person.name 形式,正常getVal()取值
let updateFn = this.updater['modelUpdater']
updateFn && updateFn(node, this.getVal(vm, expr)) // 數(shù)據(jù)初始化賦值(注意,這一步只是原始 data 中的數(shù)據(jù)在初始化頁面時替換模板顯示于頁面,并未考慮更新)
},
updater: {
// 輸入框更新
modelUpdater (node, value) {
node.value = value
}
}
}
? 編譯文本
// Compiler.js
class Compiler {
/**
* 核心方法
*/
/**
* 編譯文本
* @param {*} node 節(jié)點(diǎn)
*/
compileText (node) {
let expr = node.textContent // 取文本中的內(nèi)容
if (/\{\{(.+?)\}\}/.test(expr)) { // 找到所有文本
CompileUtil['text'](node, this.vm, expr)
}
}
}
// 通過 CompileUtil,將 compileElement 和 compileText 中的實(shí)際編譯內(nèi)容拆分解耦,以后增加新的指令方法只需在 CompileUtil 中增加對應(yīng)方法即可
/**
* 編譯工具
*/
CompileUtil = {
/**
* 根據(jù)表達(dá)式獲取實(shí)例上對應(yīng)的數(shù)據(jù)
* @param {*} vm
* @param {*} expr
*/
getVal (vm, expr) {
return expr.split('.').reduce((prev, next) => {
return prev[next]
}, vm.$data)
},
/**
* 獲取編譯文本后的結(jié)果
* @param {*} vm
* @param {*} expr
*/
getTextVal (vm, expr) {
return expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
return this.getVal(vm, args[1])
})
},
/**
* 編譯文本
* @param {*} node
* @param {*} vm
* @param {*} expr
*/
text (node, vm, expr) { // 這里的 expr 是 插值表達(dá)式 {{person.name}} 形式,通過 getTextVal() 正則匹配后 getVal() 取值
let updateFn = this.updater['textUpdater']
expr = this.getTextVal(vm, expr)
updateFn && updateFn(node, expr)
},
updater: {
// 文本更新
textUpdater (node, value) {
node.textContent = value
}
}
}
4.2.2.3 替換好的節(jié)點(diǎn)重新渲染回頁面
// Compiler.js
class Compiler {
constructor (el, vm) {
this.el = this.isElementNode(el) ? el : document.querySelector(el)
this.vm = vm
if (this.el) {
let fragement = this.node2fragement(this.el)
this.compile(fragement)
// 把編譯好的 fragement 放回頁面中
this.el.appendChild(fragement)
}
}
}
? 至此,模板編譯基本邏輯結(jié)束。
4.3 Observer 數(shù)據(jù)劫持
4.3.1 創(chuàng)建并使用 Observer 對象
? 在編譯前進(jìn)行響應(yīng)式定義(數(shù)據(jù)劫持),即將對象所有屬性改為 get 和 set 方法。 創(chuàng)建 Compiler 對象并在 MVVM 對象中使用,由于需要響應(yīng)式定義的數(shù)據(jù)存在于 data 屬性上,故在 Observer 中引入實(shí)例 數(shù)據(jù) vm.data (this.$data)。
4.3.1.1 創(chuàng)建 Observer 對象
// Observer.js
class Observer {
constructor (data) {
}
}
4.3.1.2 在 MVVM 對象中使用 Observer 對象
// MVVM.js
class MVVM {
constructor (options) {
this.$el = options.el
this.$data = options.data
if (this.$el) {
// 數(shù)據(jù)劫持(觀察對象,給對象添加 Object.defineProperty,把數(shù)據(jù)全部轉(zhuǎn)化為用 Object.defineProperty 來定義)
new Observer(this.$data)
new Compiler(this.$el, this)
}
}
}
4.3.2 劫持?jǐn)?shù)據(jù)
? 此處主要采用了 Object.defineProperty() 方法,以往我們采用的是 obj.key 取值, obj.key = value 賦值的形式,但是如果我們想在取值或賦值的時候進(jìn)行其他操作,如彈窗,這種取值賦值方法是無法做到的。此時可采用 Object.defineProperty(),這里我們采用此種形式,方便后期訂閱發(fā)布事件的執(zhí)行,以達(dá)到數(shù)據(jù)雙向綁定。[5] [6]
? 另外,還需注意深層次數(shù)據(jù)的響應(yīng)式劫持,故需進(jìn)行深度遞歸。
class Observer {
constructor (data) {
this.observe(data)
}
observe (data) { // 對 data 數(shù)據(jù)原有屬性改為 set 和 get 形式
// 如果 data 數(shù)據(jù)不存在或者不是對象,不進(jìn)行劫持
if (!data || typeof data !== 'object') return
// 對數(shù)據(jù)一一劫持,現(xiàn)獲取到 data 的 key 和 value
Object.keys(data).forEach(key => {
// 劫持
this.defineReactive(data, key, data[key])
this.observe(data[key]) // 深度遞歸劫持,因?yàn)閷ο蟮闹颠€有可能是對象,都要賦予 get 與 set
})
}
/**
* 定義響應(yīng)式(數(shù)據(jù)劫持)
*/
defineReactive(obj, key, value) {
// 以往我們采用的是 obj.key 取值, obj.key = value 賦值的形式,但是如果我們想在取值或賦值的時候進(jìn)行其他操作,如彈窗,這種取值賦值方法是無法做到的。此時可采用 Object.defineProperty()
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: () => { // 取值操作,代替 obj.key
return value
},
set: (newValue) => { // 賦值操作,代替 obj.key = value
this.observe(newValue) // 新值劫持(如果是對象,繼續(xù)劫持)
if (newValue !== value) value = newValue
}
})
}
}
? 至此,數(shù)據(jù)劫持基本邏輯結(jié)束。
4.4 發(fā)布訂閱實(shí)現(xiàn)數(shù)據(jù)雙向綁定
? 通過 4.2 Compiler 模板編譯 與 4.3 Observer 數(shù)據(jù)劫持 已經(jīng)完成了數(shù)據(jù)在頁面的渲染和數(shù)據(jù)的響應(yīng)式綁定,但此時還未將響應(yīng)式數(shù)據(jù)與其在頁面渲染相關(guān)聯(lián)。
? 據(jù)此,可以采用觀察者模式(發(fā)布訂閱模式),當(dāng)頁面初次渲染時,為所有的數(shù)據(jù)綁定監(jiān)聽事件(訂閱),當(dāng)數(shù)據(jù)變化時,觸發(fā)監(jiān)聽事件(發(fā)布),使用新數(shù)據(jù)渲染頁面。由此實(shí)現(xiàn)數(shù)據(jù)的雙向綁定。
4.4.1 Watcher 訂閱者
? 創(chuàng)建訂閱者(觀察者),即每個數(shù)據(jù)的監(jiān)聽對象,并使用于每個數(shù)據(jù)。
4.4.1.1 創(chuàng)建 Watcher 對象
? 給需要變化的元素添加訂閱者,將新值與老值進(jìn)行比對,當(dāng)數(shù)據(jù)變化時,執(zhí)行對應(yīng)的更新方法。
// Watcher.js
// 訂閱者(觀察者):給需要變化的元素添加觀察者,將新值與老值進(jìn)行比對,當(dāng)數(shù)據(jù)變化時,執(zhí)行對應(yīng)的更新方法。
// 例如,為 <input type="text" v-nodel="name" /> 元素添加觀察者,當(dāng) data 中 { name:'zhangsan' } 變化時,執(zhí)行更新方法。
class Watcher {
constructor (vm, expr, cb) {
this.vm = vm
this.expr = expr
this.cb = cb
// 由于要對比新值與老值,所以 new Watcher() 時即先獲取到老值
this.oldVal = this.get()
}
/**
* 根據(jù)表達(dá)式獲取實(shí)例上對應(yīng)的數(shù)據(jù)
* @param {*} vm
* @param {*} expr
*/
getVal (vm, expr) {
return expr.split('.').reduce((prev, next) => { //[9]
return prev[next]
}, vm.$data)
}
get () {
let value = this.getVal(this.vm, this.expr)
return value
}
/**
* 對外暴露的更新方法
*/
update () {
let newVal = this.getVal(this.vm, this.expr) // 獲取新值
if (newVal !== this.oldVal) { // 比較老值與新值
this.cb() // 調(diào)用 Watcher 的 callback
}
}
}
4.4.1.2 為數(shù)據(jù)綁定觀察者
? 前面 Compiler.js 中,通過 updateFn 實(shí)現(xiàn)了將初始化數(shù)據(jù)代替頁面模板數(shù)據(jù)而將 data 內(nèi)容顯示于頁面,但在數(shù)據(jù)更新時并不能重新渲染頁面數(shù)據(jù)。所以,可以在此處設(shè)置訂閱者,使每個數(shù)據(jù)都有一個單獨(dú)的訂閱者(new Watcher),以在數(shù)據(jù)變化時重新渲染頁面數(shù)據(jù)。
// Compiler.js
/**
* 編譯工具
*/
CompileUtil = {
/**
* 編譯文本
*/
text (node, vm, expr) {
let updateFn = this.updater['textUpdater']
// 增加觀察者
expr.replace(/\{\{(.+?)\}\}/g, (...args) => { // expr 是 插值表達(dá)式 {{person.name}} 形式,故需通過正則取出其文本值進(jìn)行比較
new Watcher(vm, args[1], () => {
// 如果數(shù)據(jù)變化了,文本節(jié)點(diǎn)需要重新獲取依賴的屬性更新文本中的內(nèi)容
updateFn && updateFn(node, this.getTextVal(vm, expr)) // 更新視圖
})
})
updateFn && updateFn(node, this.getTextVal(vm, expr)) // 初始化視圖
},
/**
* 編譯輸入框
*/
model (node, vm, expr) {
let updateFn = this.updater['modelUpdater']
// 增加觀察者
new Watcher(vm, expr, () => {
updateFn && updateFn(node, this.getVal(vm, expr)) // cb 中監(jiān)測到值變化時,再次調(diào)用此更新頁面節(jié)點(diǎn)數(shù)據(jù)方法,實(shí)現(xiàn)頁面數(shù)據(jù)更新
})
updateFn && updateFn(node, this.getVal(vm, expr)) // 初始化視圖
}
}
4.4.2 Dep 發(fā)布者
? 通過 4.4.1 Watcher 訂閱者 為每個數(shù)據(jù)實(shí)例化了一個 watcher,其中的更新方法 update 只有在數(shù)據(jù)變化時才會更新。此時需要進(jìn)行訂閱的發(fā)布,以獲取到所有的 watcher 并在數(shù)據(jù)變化時進(jìn)行依次更新。
4.4.2.1 創(chuàng)建 Dep 對象
// Observer.js
class Dep {
constructor () {
this.subs = [] // 訂閱的數(shù)組
}
/**
* 添加訂閱
*/
addSub (watcher) {
this.subs.push(watcher)
}
/**
* 發(fā)布
*/
notify () {
this.subs.forEach(watcher => watcher.update())
}
}
4.4.2.2 使用 Dep,訂閱發(fā)布,連接視圖與數(shù)據(jù)
? 我們使用發(fā)布訂閱的目的是在數(shù)據(jù)或頁面變化時更新響應(yīng)的頁面或數(shù)據(jù),而在初始化時已經(jīng)創(chuàng)建了每個數(shù)據(jù)的 watcher,但綁定到數(shù)據(jù)上。所以,我們可以在初始化(get 取值)時,為每個數(shù)據(jù)定義一個發(fā)布者,并存儲其監(jiān)聽 watcher,在數(shù)據(jù)變化(set 賦值)時,調(diào)用 watcher 進(jìn)行發(fā)布,實(shí)現(xiàn)更新。
// Observer.js
class Observer {
/**
* 定義響應(yīng)式(數(shù)據(jù)劫持)
*/
defineReactive(obj, key, value) {
let dep = new Dep() // 每個變化的數(shù)據(jù)都會對應(yīng)一個存放所有更新的數(shù)組
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: () => {
Dep.target && dep.addSub(Dep.target) // 獲取訂閱者
return value
},
set: (newValue) => {
if (newValue !== value) {
this.observe(newValue)
value = newValue
dep.notify() // 通知數(shù)據(jù)更新
}
}
})
}
}
}
? 同時,在訂閱(new Watcher)時,要將 watcher 實(shí)例賦予發(fā)布者存儲,以施行 update 發(fā)布更新。每次賦值完畢清空發(fā)布中的 watcher,以防影響下一個數(shù)據(jù)取值。
// Watcher.js
class Watcher {
get () {
Dep.target = this // this 指 new 的 watcher 實(shí)例
let value = this.getVal(this.vm, this.expr)
Dep.target = null
return value
}
}
? 至此,實(shí)現(xiàn)了一個簡單的 MVVM 框架。

5 完整代碼
5.1 github 地址
? https://github.com/trp1119/MVVM.git
5.2 代碼拆分
5.2.1 index.html
<!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">
<input type="text" v-model="person.name">
<div>{{person.name}}</div>
{{person.name}}{{person.age}}
</div>
<script src="./Dep.js"></script>
<script src="./Watcher.js"></script>
<script src="./Observer.js"></script>
<script src="./Compiler.js"></script>
<script src="./MVVM.JS"></script>
<script>
// 實(shí)例化 MVVM 對象
let vm = new MVVM({
el: '#app',
data: {
person: {
name: 'zhangsan',
age: 12
}
}
})
</script>
</body>
</html>
5.2.2 MVVM.js
// MVVM是連接 Compiler 模板編譯與 Observer 數(shù)據(jù)劫持的橋梁
class MVVM {
constructor (options) {
// 一般情況下,在寫庫或者框架時,都需要將屬性掛載到實(shí)例上,保證其原型或方法能夠取到該屬性
this.$el = options.el
this.$data = options.data
// 如果有需要編譯的模板,則開始編譯
if (this.$el) {
// 數(shù)據(jù)劫持(觀察對象,給對象添加 Object.defineProperty,把數(shù)據(jù)全部轉(zhuǎn)化為用 Object.defineProperty 來定義)
new Observer(this.$data)
// 用數(shù)據(jù)和元素進(jìn)行編譯
new Compiler(this.$el, this) // 后期 this 上可能會有很多屬性,this.$el 模板中也需要很多屬性而不僅僅是 this.$data,所以此處使用范圍更廣的 this
}
}
}
5.2.3 Compiler.js
class Compiler {
constructor (el, vm) { // el 為模板,vm 為 this 實(shí)例
// el 的值可能是字符串 '#app',也有可能是元素 document.getElementById('#app')
// 判斷 el屬性 是否是元素,如果不是元素,則獲取它
// 為了擴(kuò)展時所有類的屬性都能在原型上取到,將所有值都綁定到實(shí)例上
this.el = this.isElementNode(el) ? el : document.querySelector(el)
this.vm = vm
// 在匹配 data 時,如果每匹配到一個數(shù)據(jù)就渲染一次,會造成頁面不停的回流與重繪,可先將模板放入內(nèi)存中,在內(nèi)存中完全替換完畢后,再放回頁面,性能比每匹配到一個就替換性能更佳。
// 把當(dāng)前節(jié)點(diǎn)中的元素獲取到,放到內(nèi)存中
if (this.el) { // 如果能獲取到這個元素,才開始編譯
// 1. 通過文檔碎片 fragemnt 先把真實(shí) DOM 移入到內(nèi)存中,在內(nèi)存中操作 DOM 比在真實(shí) DOM 中操作快
let fragement = this.node2fragement(this.el)
// 2. 提取 fragement 中的元素節(jié)點(diǎn) v-model 和文本節(jié)點(diǎn) {{}} 進(jìn)行編譯,對節(jié)點(diǎn)中的內(nèi)容進(jìn)行替換
this.compile(fragement)
// 3. 把編譯好的 fragement 放回頁面中
this.el.appendChild(fragement)
}
}
/**
* 輔助方法,如判斷是否是元素,判斷是否是文本,判斷指令
*/
/**
* 判斷是否是元素節(jié)點(diǎn)
* @param {*} node 節(jié)點(diǎn)
*/
isElementNode (node) {
return node.nodeType === 1 // [7]
}
/**
* 判斷是否是指令(判斷屬性名是否包含 v-)
* @param {*} attrName 屬性名
*/
isDirective (attrName) {
return attrName.startsWith('v-')
}
/**
* 核心方法
*/
/**
* 頁面 DOM 節(jié)點(diǎn) 轉(zhuǎn) 文檔碎片 節(jié)點(diǎn)
* @param {*} node 頁面 DOM 節(jié)點(diǎn)
*/
node2fragement (node) { // 需要將 node(el)中的內(nèi)容全部放入到內(nèi)存中
let fragement = document.createDocumentFragment() // 創(chuàng)建文檔碎片,存放于內(nèi)存中 [8]
let firstChild
while (firstChild = node.firstChild) { // firstChild = node.firstChild 這樣永遠(yuǎn)拿到的第第一個子元素,會出現(xiàn)死循環(huán),所以可以拿到一個子元素就將其放入內(nèi)存中,然后使用 appendChild 將 node 中對應(yīng)的子節(jié)點(diǎn)移除,下次遍歷時自動獲取到下一個子節(jié)點(diǎn)。
// 頁面 DOM 都具有 DOM映射,將頁面一個節(jié)點(diǎn)移入內(nèi)存中,則頁面節(jié)點(diǎn)少一個
// appendChild 具有移動性,可以對 DOM 節(jié)點(diǎn)進(jìn)行移動
fragement.appendChild(firstChild)
}
return fragement
}
/**
* 核心編譯方法:編譯所有節(jié)點(diǎn)
* @param {*} fragement 文檔碎片(內(nèi)存中的所有節(jié)點(diǎn))
*/
compile (fragement) {
let childNodes = [...fragement.childNodes] // 拿到的是 類數(shù)組,需轉(zhuǎn)為數(shù)組
childNodes.forEach(node => {
if (this.isElementNode(node)) { // 元素節(jié)點(diǎn)
// 編譯元素
this.compileElement(node)
// 如果是元素節(jié)點(diǎn),還需要遞歸深入檢查子元素節(jié)點(diǎn)和文本節(jié)點(diǎn)
this.compile(node) // 注意:此處還是使用 this,因?yàn)?forEach 中回調(diào)用的箭頭函數(shù),現(xiàn)在的 this 還是指向?qū)嵗? } else { // 文本節(jié)點(diǎn)
// 編譯器文本
this.compileText(node)
}
})
}
/**
* 編譯元素
* @param {*} node 節(jié)點(diǎn)
*/
compileElement (node) {
let attributes = [...node.attributes]
attributes.forEach(attr => {
// 判斷是否是指令
let {name: attrName, value: expr} = attr
if (this.isDirective(attrName)) {
// 取到對應(yīng)的值,放到節(jié)點(diǎn)中
let [, directive] = attrName.split('-')
let [directiveName, eventName] = directive.split(':')
CompileUtil[directiveName](node, this.vm, expr, eventName) // 去 this.vm 中 找到 expr 值放入到 node 中
}
})
}
/**
* 編譯文本
* @param {*} node 節(jié)點(diǎn)
*/
compileText (node) {
let expr = node.textContent // 取文本中的內(nèi)容
if (/\{\{(.+?)\}\}/.test(expr)) { // 找到所有文本
CompileUtil['text'](node, this.vm, expr)
}
}
}
// 通過 CompileUtil,將 compileElement 和 compileText 中的實(shí)際編譯內(nèi)容拆分解耦,以后增加新的指令方法只需在 CompileUtil 中增加對應(yīng)方法即可
/**
* 編譯工具
*/
CompileUtil = {
/**
* 根據(jù)表達(dá)式獲取實(shí)例上對應(yīng)的數(shù)據(jù)
* @param {*} vm
* @param {*} expr
*/
getVal (vm, expr) {
return expr.split('.').reduce((prev, next) => {
return prev[next]
}, vm.$data)
},
/**
* 設(shè)置值,輸入框使用
* @param {*} vm
* @param {*} expr
*/
setVal (vm, expr, value) {
expr.split('.').reduce((prev, next, currentIndex, arr) => { // 收斂
if (currentIndex === arr.length - 1) {
return prev[next] = value
}
return prev[next]
}, vm.$data)
},
/**
* 獲取編譯文本后的結(jié)果
* @param {*} vm
* @param {*} expr
*/
getTextVal (vm, expr) {
return expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
return this.getVal(vm, args[1])
})
},
/**
* 編譯文本
* @param {*} node
* @param {*} vm
* @param {*} expr
*/
text (node, vm, expr) { // 這里的 expr 是 插值表達(dá)式 {{person.name}} 形式,通過 getTextVal() 正則匹配后 getVal() 取值
let updateFn = this.updater['textUpdater']
// 增加觀察者
expr.replace(/\{\{(.+?)\}\}/g, (...args) => { // expr 是 插值表達(dá)式 {{person.name}} 形式,故需通過正則取出其文本值進(jìn)行比較
new Watcher(vm, args[1], () => {
// 如果數(shù)據(jù)變化了,文本節(jié)點(diǎn)需要重新獲取依賴的屬性更新文本中的內(nèi)容
updateFn && updateFn(node, this.getTextVal(vm, expr)) // 更新視圖
})
})
updateFn && updateFn(node, this.getTextVal(vm, expr)) // 初始化視圖
},
/**
* 編譯輸入框
*/
model (node, vm, expr) { // 這里的 expr 是 字符串 person.name 形式,正常 getVal() 取值
let updateFn = this.updater['modelUpdater']
// 增加觀察者
new Watcher(vm, expr, () => {
updateFn && updateFn(node, this.getVal(vm, expr)) // cb 中監(jiān)測到值變化時,再次調(diào)用此更新頁面節(jié)點(diǎn)數(shù)據(jù)方法,實(shí)現(xiàn)頁面數(shù)據(jù)更新
})
updateFn && updateFn(node, this.getVal(vm, expr)) // 數(shù)據(jù)初始化賦值(注意,這一步只是原始 data 中的數(shù)據(jù)在初始化頁面時替換模板顯示于頁面,并未考慮更新)
node.addEventListener('input', (e) => {
let value = e.target.value // 獲取用戶輸入的內(nèi)容
this.setVal(vm, expr, value)
})
},
updater: {
// 文本更新
textUpdater (node, value) {
node.textContent = value
},
// 輸入框更新
modelUpdater (node, value) {
node.value = value
}
}
}
5.2.4 Observer.js
class Observer {
constructor (data) {
this.observe(data)
}
observe (data) { // 對 data 數(shù)據(jù)原有屬性改為 set 和 get 形式
// 如果 data 數(shù)據(jù)不存在或者不是對象,不進(jìn)行劫持
if (!data || typeof data !== 'object') return
// 對數(shù)據(jù)一一劫持,現(xiàn)獲取到 data 的 key 和 value
Object.keys(data).forEach(key => {
// 劫持
this.defineReactive(data, key, data[key])
this.observe(data[key]) // 深度遞歸劫持,因?yàn)閷ο蟮闹颠€有可能是對象,都要賦予 get 與 set
})
}
/**
* 定義響應(yīng)式(數(shù)據(jù)劫持)
*/
defineReactive(obj, key, value) {
let dep = new Dep() // 每個變化的數(shù)據(jù)都會對應(yīng)一個存放所有更新的數(shù)組
// 以前我們采用的是 obj.key 取值, obj.key = value 賦值的形式,但是如果我們想在取值或賦值的時候進(jìn)行其他操作,如彈窗,這種取值賦值方法是無法做到的。此時可采用 Object.defineProperty()
Object.defineProperty(obj, key, { // 通過 object.defineProperty 的方式定義 data 屬性
enumerable: true,
configurable: true,
get: () => { // 取值操作,代替 obj.key
Dep.target && dep.addSub(Dep.target) // 獲取訂閱者
return value
},
set: (newValue) => { // 賦值操作,代替 obj.key = value // 注意此處使用箭頭函數(shù),以將 this 指向 Observer,取到其 observer 方法。否則 this 是 obj
if (newValue !== value) { // 只有新值與老值不同才更新
this.observe(newValue) // 新值劫持(如果是對象,繼續(xù)劫持)
value = newValue
dep.notify() // 通知數(shù)據(jù)更新
}
}
})
}
}
5.2.5 Watcher.js
// 訂閱者(觀察者):給需要變化的元素添加觀察者,將新值與老值進(jìn)行比對,當(dāng)數(shù)據(jù)變化時,執(zhí)行對應(yīng)的更新方法。
// 例如,為 <input type="text" v-nodel="name" /> 元素添加觀察者,當(dāng) data 中 { name:'zhangsan' } 變化時,執(zhí)行更新方法。
class Watcher {
constructor (vm, expr, cb) {
this.vm = vm
this.expr = expr
this.cb = cb
// 由于要對比新值與老值,所以 new Watcher() 時即先獲取到老值
this.oldVal = this.get()
}
/**
* 根據(jù)表達(dá)式獲取實(shí)例上對應(yīng)的數(shù)據(jù)
* @param {*} vm
* @param {*} expr
*/
getVal (vm, expr) {
return expr.split('.').reduce((prev, next) => {
return prev[next]
}, vm.$data)
}
get () {
Dep.target = this // this 指 new 的 watcher 實(shí)例
let value = this.getVal(this.vm, this.expr)
Dep.target = null
return value
}
/**
* 對外暴露的更新方法
*/
update () {
let newVal = this.getVal(this.vm, this.expr) // 獲取新值
if (newVal !== this.oldVal) { // 比較老值與新值
this.cb() // 調(diào)用 Watcher 的 callback
}
}
}
5.2.6 Dep.js
// 發(fā)布者
class Dep {
constructor () {
this.subs = [] // 訂閱的數(shù)組
}
/**
* 添加訂閱
*/
addSub (watcher) {
this.subs.push(watcher)
}
/**
* 發(fā)布
*/
notify () {
this.subs.forEach(watcher => watcher.update())
}
}
6 參考資料
[1] MVVM [EB/OL]. (2019-12-04)[2020-05-04]. https://baike.baidu.com/item/MVVM/96310?fr=aladdin.
[2] 隔壁老主. 由淺入深講述MVVM [EB/OL]. (2019-03-18)[2020-05-04]. https://www.cnblogs.com/wzfwaf/p/10553160.html.
[3] 廖雪峰. MVVM[EB/OL]. [2020-05-04]. https://www.liaoxuefeng.com/wiki/1022910821149312/1108898947791072.
[4] 前端問答. MVVM的優(yōu)缺點(diǎn)?[EB/OL]. (2019-11-24)[2020-05-04]. https://developer.aliyun.com/ask/259836?groupCode=othertech
[5] 趙望野, 梁杰. 你不知道的JavaScript(上卷)[M]. 北京: 人民郵電出版社, 2015: 111-119.
[6] Object.defineProperty() [EB/OL]. (2020-03-02)[2020-05-04]. https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty.
[7] Node.nodeType [EB/OL]. (2019-07-28)[2020-05-04]. https://developer.mozilla.org/zh-CN/docs/Web/API/Node/nodeType
[8] Document.createDocumentFragment() [EB/OL]. (2019-03-23)[2020-05-04]. https://developer.mozilla.org/zh-CN/docs/Web/API/Document/createDocumentFragment
[9] Array.prototype.reduce() [EB/OL]. (2020-04-29)[2020-05-04]. https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce