淺析MVVM原理,實現一個mini-vue
目錄
前言
MVVM實際上是MVC的改進版,其立足于MVP框架。使用Vue時,我們會體會到其與React的區別,綁定表單數據時react對輸入框讀寫需要input事件設置state,以及value綁定,而vue則只需將數據與model綁定即可,這種數據驅動視圖卻與視圖解耦的編程方式使用起來很方便。以前面試官問vue原理,若能說出雙向綁定實現和Object.defineProperty就已經夠了,現在隨著對vue深入的學習,面試官已經不僅僅局限于此。所以,為了深入體驗mvvm模式,我實現了一個mini-vue。
MVVM
在開始前,我們先試著參照下圖實現一個簡單的雙向綁定案例

DOM通過eventListener修改Model,Model通過修改data驅動視圖

在html>body中添加以下代碼就可以實現
<input id="input-box" type="text">
<div id="show-text"></div>
<script>
const showText = document.querySelector('#show-text')
const inputBox = document.querySelector('#input-box')
class VM {
data = {
value: ''
}
constructor() {
Object.defineProperty(this.data, 'value', {
set(v) {
showText.textContent = v
},
get() {
return showText.textContent
}
})
}
}
const vm = new VM()
inputBox.addEventListener('input', function (e) {
vm.data.value = e.target.value
console.log(vm.data.value)
})
</script>
在上述代碼中,我們可以使用Object.defineProperty將data和textContent的值綁定,從而達到數據驅動視圖的效果,那么這樣就夠了嗎?
mini-vue實現
下面是雙向綁定的流程
通過上圖我們可以得知:new MVVM后會進行兩步操作,一是compile指令解析,將v-if,@click,{{ }}解析出來,獲取data中的數據,并且都與watcher綁定,第一次初始化和watcher監聽到數據變化時會執行updater,重新渲染頁面,二是observer數據劫持,將data中的數據通過defineProperty添加讀寫監聽,并將數據變化與watcher綁定在一起,那么此時watcher就是連接數據變化和視圖更新的樞紐。
下面我們一步一步實現上述代碼
Compile(指令解析)
我們回顧一下vue是如何使用的,標簽中各種v-if,v-show,v-html,以及@click等等屬性,綁定著data中的屬性和methods中的函數
<div id="app">
<span v-text='title.name'></span>
<div v-if='isRender'>
<span>1</span>
<span>2</span>
<span>3</span>
</div>
<ul>
<li v-if='isRender'>{{info.name}}---{{info.age}}---{{modelData}}---{{inputVal.item.value}}</li>
<li v-show='isShow'>{{info.age}}</li>
<li v-if='isRender'>{{modelData}}</li>
<li v-show='isShow'>{{inputVal.item.value}}</li>
</ul>
<span v-text='inputVal.item.value'></span>
<div v-html='htmlTemp'></div>
<div v-show='isShow'>world</div>
<button v-on:click='handlerShow'>點擊顯示</button>
<button @click='handlerRender'>點擊渲染</button>
<input v-model='modelData' type="text">
<input v-model='inputVal.item.value' type="text">
</div>
而實例化vue則是將數據和函數初始化到vue中
let vm = new Vue({
el: '#app',
data: {
title: {
name: 'hello'
},
info: {
name: '張三',
age: 23,
},
isShow: true,
isRender: true,
modelData: 123,
htmlTemp: '<span style="color:red;">html</span>',
inputVal: {
item: {
value: 'abc'
}
}
},
methods: {
handlerShow() {
this.isShow = !this.isShow
},
handlerRender() {
this.isRender = !this.isRender
}
},
})
那么,我們要如何去讓js識別這些指令并渲染視圖呢
首先,創建標簽碎片,將Dom元素獲取到DocumentFragment中,以便于解析指令及根據指令對視圖響應,其次,將標簽屬性分離,每種指令對應一種響應方式(updater)。最后綁定watcher監聽到數據變化時,再次觸發updater
以下是compile.js,用來解析標簽內容和屬性
// 指令解析器
const textRegex = /\{\{(.+?)\}\}/g //解析{{}}的正則
class Compile {
constructor(elem, vm) {
this.elem = isElemNode(elem) === '1' ? elem : document.querySelector(elem)
this.vm = vm
const fragment = this.createFragment(this.elem)
this.getTemp(fragment, this.vm)
this.elem.appendChild(fragment);
}
// 遞歸子元素,查找所有元素
getTemp(fragment, vm) {
const fragmentChild = Array.from(fragment.childNodes)
fragmentChild.forEach(item => {
this.filterElem(item, vm)
item.childNodes && item.childNodes.length && this.getTemp(item, vm)
})
}
// 創建標簽碎片,將dom元素添加到標簽碎片中
createFragment(elem) {
const fragment = document.createDocumentFragment();
while (elem.firstChild) {
fragment.append(elem.firstChild)
}
return fragment
}
// 針對不同元素節點進行分離
filterElem(elem, vm) {
switch (isElemNode(elem)) {
case 1: //元素節點
this.renderNode(elem, vm)
break;
case 3: //文本節點
this.renderText(elem, vm)
break;
}
}
// 渲染文本,主要解析‘{{}}’及多個‘{{}}’
renderText(elem, vm) {
textRegex.test(elem.textContent) && updater(elem, vm, elem.textContent, 'text-content')
}
// 渲染標簽
renderNode(elem, vm) {
//取出所有屬性和值
Array.from(elem.attributes).forEach(attr => {
const {
name,
value
} = attr;
// 過濾‘v-’和‘@’操作,并移除標簽屬性
name.startsWith('v-') ? (this.compileV_Command(elem, vm, name, value), removeAttr(elem, name)) : name.startsWith('@') ? (this.compileEventComment(elem, vm, name.split('@')[1], value), removeAttr(elem, name)) : null
})
}
// v- 指令解析,指令
compileV_Command(elem, vm, name, value) {
const key = name.split('v-')
const eventCommand = key[1] && key[1].split(':')[1]
// v-model事件
key[1] === 'model' && this.compileEventComment(elem, vm, 'input', value, e => {
setDeepData(vm, value, e.target.value)
})
// 過濾指令是否為事件
eventCommand ? this.compileEventComment(elem, vm, eventCommand, value) : updater(elem, vm, value, key[1])
}
// @ 指令解析,事件
compileEventComment(elem, vm, name, value, fn) {
!fn && elem.addEventListener(name, vm.options.methods[value].bind(vm))
fn && elem.addEventListener(name, fn.bind(vm))
}
}
Updater(視圖更新)
指令解析完后自然需要updater.js,對當前元素進行下一步渲染,在此之前,我們的值需要從vue.data中取,這樣才能將data數據綁定到標簽中,lodash有兩個函數一個是_.get(),另一個是_.set(),作用是獲取和設置對象某一層某個值,所以我們需要在utils(工具函數)中實現一下
utils.js
//lodash中的 _.get(),獲取對象多級屬性
function getDeepData(object, path, defaultValue) {
const paths = path.split('.')
for (const i of paths) { //逐層遍歷path
object = object[i]
if (object === undefined) { //不能用 '!object' null,0,false等等會等于false
return defaultValue
}
}
return object
}
//lodash中的 _.set(),賦值對象某級屬性
function setDeepData(object, path, value) {
const paths = path.split('.')
const last = paths[paths.length - 1]//為何要在length - 1時賦值:因為object的引用關系使得我們可以一級一級賦值,而當最后一項是基本類型時,無法將引用的值賦給原始的object
let _obj = object
for (const i of paths) {
last === i && (_obj[last] = value)
_obj = _obj[i]
}
}
// 移除屬性值
function removeAttr(elem, key) {
elem.removeAttribute(key)
}
// 獲取標簽類型
function isElemNode(elem) {
return elem.nodeType
}
updater.js
// 更新視圖,標簽中指令屬性處理
function updater(elem, vm, value, type) {
switch (type) {
case 'text':
elem.textContent = getDeepData(vm.data, value)
break;
case 'text-content':
elem.textContent = value.replace(textRegex, (..._) => getDeepData(vm.data, _[1]))
break;
case 'html':
elem.innerHTML = getDeepData(vm.data, value)
break;
case 'model':
elem.value = getDeepData(vm.data, value)
break;
case 'if':
const temp = document.createTextNode('')
elem.parentNode.insertBefore(temp, elem);
getDeepData(vm.data, value) ? temp.parentNode.insertBefore(elem, temp) : temp.parentNode.removeChild(elem)
break;
case 'show':
elem.hidden = !getDeepData(vm.data, value)
break;
}
}
完成這一步后,我們在vue.js中調用
class VueDemo {
constructor(options) {
this.options = options //配置信息
this.data = options.data;
// 判斷options.el是否存在
(this.el = options.el) && Object.defineProperties(this, {
compile: {
value: new Compile(options.el, this) //指令解析器
}
})
}
}
效果出來了,指令被解析出來并且在頁面中顯示

Proxy(代理data)
我們雖然將vue.data中的數據渲染到了頁面,但是還是需要通過this.data來獲取數據,而vue可以中直接通過this來拿到數據,此時我們需要新建一個proxy.js將this.data代理到this上
// data數據代理到vue
class DataProxy {
constructor(data, vm) {
for (const key in data) {
Object.defineProperty(vm, key, {
get() {
return data[key];
},
set(val) {
data[key] = val;
}
})
}
return data
}
}
在vue.js中調用,并將updater.js 中的vm.data改成vm
class VueDemo {
constructor(options) {
this.options = options //配置信息
this.$data = options.data;
// 判斷options.el是否存在
(this.el = options.el) && Object.defineProperties(this, {
proxy: {
value: new DataProxy(options.data, this) //data代理到this
},
compile: {
value: new Compile(options.el, this) //指令解析器
}
})
}
}
寫到這里,compile和updater已經實現了,接下來將是數據劫持的實現方式
Observer(數據劫持)
這一步的作用是將data中的數據都加上讀寫響應控制,給所有數據綁定可以更新視圖的函數
// 發布模式
class Observer {
constructor(data) {
this.initObserver(data)
}
// 劫持所有數據
initObserver(data) {
if (data && typeof data === 'object') {
for (const key in data) {
this.defineReactive(data, key, data[key])
}
}
}
// 響應攔截器,遞歸監聽所有層級
defineReactive(data, key, val) {
this.initObserver(val) //劫持子項
Object.defineProperty(data, key, {
enumerable: true, // 允許枚舉
configurable: false, // 不能被定義
get: _ => val,//初始化獲取值時對dep綁定
set: newVal => val = newVal
})
}
}
Dep(調度中心)
watcher的作用是將上面的observer與視圖的刷新函數updater進行連接,當observer監測到數據變化時會通過dep告訴watcher,watcher就會執行updater更新視圖,于是,我們需要先實現observer與watcher之間的觀察者dep,我們先假定watcher中更新視圖的函數名字叫compareVal,將watcher注冊到調度中心中
// 調度中心(觀察者模式)
class Dep {
observerList = [] //調度中心,存放與屬性綁定的事件
//觸發所有與該屬性綁定的事件
fireEvent() {
this.observerList.forEach(target => {
target.compareVal()
})
}
//注冊事件
subscribe(target) {
target.compareVal && this.observerList.push(target)
}
}
Watcher(數據觀察)
watcher的作用是連接observer和compile,使數據和視圖綁定
以下是watcher.js的實現
// 訂閱模式(比較綁定值的變化)
class Watcher {
constructor(vm, val, update) {
this.vm = vm
this.val = val;
this.update = update
this.oldVal = getDeepData(this.vm, this.val)
update() //首次渲染初始化
}
// 對比數據,更新視圖
compareVal() {
const newVal = getDeepData(this.vm, this.val);
newVal !== this.oldVal && (this.update(), this.oldVal = newVal) //更新視圖后將新值賦到oldVal上
}
}
函數的連接
我們來回顧一下以上功能的實現
整個流程中的函數部分已經全部實現,只剩下如何將他們聯系在一起,這時如果你對整個功能實現還有些模糊,那請認真分析一下這張流程圖,并繼續看下去吧
首先我們把watcher和指令解析以及updater之間的關系實現。
在updater中給予每一個指令一個watcher,將更新視圖操作綁定到watcher中,由compareVal來更新視圖
// 更新視圖,標簽中指令屬性處理
function updater(elem, vm, value, type) {
switch (type) {
case 'text':
new Watcher(vm, value, _ => {
elem.textContent = getDeepData(vm, value)
})
break;
case 'text-content':
value.replace(textRegex, (..._) => { //外面的content.replace獲取所有{{}}中的屬性
new Watcher(vm, _[1], _ => { //里面的content.replace獲取data中綁定的值
elem.textContent = value.replace(textRegex, (..._) => getDeepData(vm, _[1]))
})
})
break;
case 'html':
new Watcher(vm, value, _ => {
elem.innerHTML = getDeepData(vm, value)
})
break;
case 'model':
new Watcher(vm, value, _ => {
elem.value = getDeepData(vm, value)
})
break;
case 'if':
const temp = document.createTextNode('')
elem.parentNode.insertBefore(temp, elem);
new Watcher(vm, value, _ => {
getDeepData(vm, value) ? temp.parentNode.insertBefore(elem, temp) : temp.parentNode.removeChild(elem)
})
break;
case 'show':
new Watcher(vm, value, _ => {
elem.hidden = !getDeepData(vm, value)
})
break;
}
}
那么如何告訴watcher數據發生了改變呢?
在watcher中我們獲取oldvalue時采用this.oldVal = getDeepData(this.vm, this.val)
這個操作會使observer中data屬性的get被觸發,此時如果我們將watcher注冊到dep中即可對所有數據變化進行監聽,然鵝,在實現的時候,發現了一些問題,由于defineReactive將data所有屬性都監聽了,導致取屬性時使用{{info.name}}時,data.info和data.info.name都會被劫持,而我們只需要info.name,所以,當dep注冊watcher時需要設置一個開關,并且在observer中根據開關添加監聽,修改的watcher和observer如下:
watcher.js
// 訂閱模式(比較綁定值的變化)
class Watcher {
constructor(vm, val, update) {
this.vm = vm
this.val = val;
this.update = update
this.oldVal = this.getOldVal() //獲取初始值,觸發observer中屬性的get
update() //首次渲染初始化
}
getOldVal() {
Dep.target = this //將watcher暫存到Dep上,在Observer中通過dep.subscribe將watcher傳到dep的observerList(調度中心)中,后續當值發送修改時通過fireEvent觸發watcher.compareVal來更新視圖
const oldVal = getDeepData(this.vm, this.val) //觸發Observer中的getter,將watcher注冊到dep中
Dep.target = null
return oldVal
}
// 對比數據,更新視圖
compareVal() {
const newVal = getDeepData(this.vm, this.val);
newVal !== this.oldVal && (this.update(), this.oldVal = newVal) //更新視圖后將新值賦到oldVal上
}
}
observer.js中的defineReactive
// 響應攔截器,遞歸監聽所有層級
defineReactive(data, key, val) {
this.initObserver(val) //劫持子項
const dep = new Dep() //將observer與watcher連接,當watcher觸發數據變化后,將watcher中的回調函數注冊到dep中
Object.defineProperty(data, key, {
enumerable: true, // 允許枚舉
configurable: false, // 不能被定義
get: _ => {
Dep.target && dep.subscribe(Dep.target); //獲取屬性值時,將watcher中的回調函數注冊到dep中(在頁面初始化時調用)
return val
},
set: newVal => newVal !== val && (val = newVal) //設置屬性時,對比新值和舊值有無差別
})
}
現在,我們只剩下當數據發生改變時,如何通知watcher,因為上述的defineReactive中已經將watcher注冊到了dep,此時我們只需在數據變化時也就是defineReactive的set中對數據更新進行響應,當某條數據被設置時,我們將dep中watcher觸發即可
// 響應攔截器,遞歸監聽所有層級
defineReactive(data, key, val) {
this.initObserver(val) //劫持子項
const dep = new Dep() //將observer與watcher連接,當watcher觸發數據變化后,將watcher中的回調函數注冊到dep中
Object.defineProperty(data, key, {
enumerable: true, // 允許枚舉
configurable: false, // 不能被定義
get: _ => {
Dep.target && dep.subscribe(Dep.target); //獲取屬性值時,將watcher中的回調函數注冊到dep中(在頁面初始化時調用)
return val
},
set: newVal => newVal !== val && (val = newVal, this.initObserver(newVal), dep.fireEvent()) //設置屬性時,對比新值和舊值有無差別,若修改的值是引用型時,將屬性重新注冊到dep中,并更新視圖
})
}
至此,流程圖中的所有功能均已實現,讓我們在vue.js中實例化observer試試效果
class VueDemo {
constructor(options) {
this.options = options //配置信息
this.$data = options.data;
// 判斷options.el是否存在
(this.el = options.el) && Object.defineProperties(this, {
//observer和compile的順序不要錯,否則監聽不到compile中的數據
observer: {
value: new Observer(options.data) // 數據監聽器
},
proxy: {
value: new DataProxy(options.data, this) //data代理到this
},
compile: {
value: new Compile(options.el, this) //指令解析器
}
})
}
}

寫在最后
感謝你看到了最后,希望文章能對你有幫助,同時也歡迎你提出寶貴的建議
最后附上源碼地址
喜歡這篇文章別忘了點個贊,你的支持是作者創作的動力

浙公網安備 33010602011771號