前端MVC、MVVM的簡單實現

B_Cornelius發表於2018-02-21

MVC

MVC是一種設計模式,它將應用劃分為3個部分:資料(模型)、展示層(檢視)和使用者互動層。結合一下下圖,更能理解三者之間的關係。

前端MVC、MVVM的簡單實現
換句話說,一個事件的發生是這樣的過程

  1. 使用者和應用互動
  2. 控制器的事件處理器被觸發
  3. 控制器從模型中請求資料,並將其交給檢視
  4. 檢視將資料呈現給使用者

模型:用來存放應用的所有資料物件。模型不必知曉檢視和控制器的細節,模型只需包含資料及直接和這些資料相關的邏輯。任何事件處理程式碼、檢視模版,以及那些和模型無關的邏輯都應當隔離在模型之外。 檢視:檢視層是呈現給使用者的,使用者與之產生互動。在javaScript應用中,檢視大都是由html、css和JavaScript模版組成的。除了模版中簡單的條件語句之外,檢視不應當包含任何其他邏輯。事實上和模型類似,檢視也應該從應用的其他部分中解耦出來 控制器:控制器是模型和檢視的紐帶。控制器從檢視獲得事件和輸入,對它們進行處理,並相應地更新檢視。當頁面載入時,控制器會給檢視新增事件監聽,比如監聽表單提交和按鈕單擊。然後當使用者和應用產生互動時,控制器中的事件觸發器就開始工作。 例如JavaScript框架早期框架backbone就是採用的MVC模式。

上面的例子似乎太過空洞,下面講一個生活中的例子進行講解: 1、使用者提交一個新的聊天資訊 2、控制器的事件處理器被觸發 3、控制器建立了一個新的聊天模型 4、然後控制器更新檢視 5、使用者在聊天視窗看到新的聊天資訊 講了一個生活的例子,我們用程式碼的方式更加深入瞭解MVC。

Model

MVC中M表示model,與資料操作和行為相關的邏輯都應當放入模型中。例如我們建立一個Model物件,所有資料的操作都應該都放在這個名稱空間中。下面是一些簡化的程式碼,首先建立新模型和例項

var Model = {
	create: function() {
		this.records = {}
		var object = Object.create(this)
		object.prototype = Object.create(this.prototype)
		return object
	}
}
複製程式碼

create用於建立一個以Model為原型的物件,然後就是一些包括資料操作的一些函式包括查詢,儲存

var Model = {
	/*---程式碼片段--*/
	find: function () {
		return this.records[this.id]
	},
	save: function () {
		this.records[this.id] = this 
	}
}
複製程式碼

下面我們就可以使用這個Model了:

user = Model.create()
user.id = 1
user.save()
asset = Model.create()
asset.id = 2
asset.save()
Model.find(1)
=> {id:1}
複製程式碼

可以看到我們就已經查詢到了這個物件。模型也就是資料的部分我們也就完成了。

Control

下面來講講mvc中的控制器。當載入頁面的時候,控制器將事件處理程式繫結在檢視中,並適時地處理回撥,以及和模型必要的對接。下面是控制器的簡單例子:

var ToggleView = {
	init: function (view) {
		this.view = $(view)
		this.view.mouseover(this.toggleClass, true)
		this.view.mouseout(this.toggleClass, false)
	},
	this.toggleClass: function () {
		this.view.toggleClass('over', e.data)
	}
}
複製程式碼

這樣我們就實現了對一個檢視的簡單控制,滑鼠移入元素新增over class,移除就移除over class。然後在新增一些簡單的樣式例如

    ex:
    	.over {color: red}
    	p{color: black}

這樣控制器就和檢視建立起了連線。在MVC中有一個特性就是一個控制器控制一個檢視,隨著專案體積的增大,就需要一個狀態機用於管理這些控制器。先來建立一個狀態機
var StateMachine = function() {}
SateMachine.add = function (controller) {
	this.bind('change', function (e, current) {
		if (controller == current) {
			controller.activate()
		} else {
			controller.deactivate()
		}
	})

	controller.active = function () {
		this.trigger('change', controller)
	}
}
// 建立兩個控制器
var con1 = {
	activate: funtion() {
		$('#con1').addClass('active')
	},
	deactivate: function () {
		$('#con1').removeClass('active')
	}
}

var con2 = {
	activate: funtion() {
		$('#con2').addClass('active')
	},
	deactivate: function () {
		$('#con2').removeClass('active')
	}
}

// 建立狀態機,新增狀態
var sm = new StateMachine
sm.add(con1)
sm.add(con2)

// 啟用第一個狀態
con1.active()
複製程式碼

這樣就實現了簡單的控制器管理,最後我們在新增一些css樣式。

#con1, #con2 { display: none }
#con2.active, #con2.active { display: block }
複製程式碼

當con1啟用的時候樣式就發生了變化,也就是檢視發生了變化。 控制器也就講到了這裡,下面來看看MVC中的View部分,也就是檢視

View

檢視是應用的介面,它為使用者提供視覺呈現並與使用者產生互動。在javaScript種,檢視是無邏輯的HTML片段,又應用的控制器來管理,檢視處理事件回撥以及內嵌資料。簡單來說就是在javaScript中寫HTML程式碼,然後將HTML片段插入到HTML頁面中,這裡講兩種方法:

動態渲染檢視

使用document.createElement建立DOM元素,設定他們的內容然後追加到頁面中,例如 var views = documents.getElementById('views') views.innerHTML = '' // 元素清空 var wapper = document.createElement('p') wrapper.innerText = 'add to views' views.appendChild(wrapper) 這樣就完成了用createElement建立元素,然後新增到HTML頁面中。

模板

如果以前有過後端開發經驗,那麼對模版應該比較熟悉。例如在nodejs中常用的就是ejs,下面是ejs的一個小例子,可以看到的是ejs將javascript直接渲染為HTML

str = '<h1><%= title %></h1>'
ejs.render(str, {
    title: 'ejs'
});
複製程式碼

那麼這個渲染後的結果就是

ejs

當然實際中ejs的功能更強大,我們甚至可以在其中加入函式,模板語言是不是覺得跟vue,React的書寫方式特別像,我也覺得像。那麼view的作用就顯而易見了,就是將HTML和javaScript連線起來。剩下一個問題就是在mvc原理圖我們看到了檢視和模型之間的關係,當模型更改的時候,檢視也會跟著更新。那麼檢視和模型就需要進行繫結,它意味著當記錄發生改變時,你的控制器不需要處理檢視的更新,因為這些更新是在後臺自動完成的。為了將javaScript物件和檢視繫結在一起,我們需要設定一個回撥函式,當物件的屬性發生改變時傳送一個更新檢視的通知。下面是值發生變化的時候呼叫的回撥函式,當然現在我們可以使用更簡單的set,get進行資料的監聽,這在我們後面的MVVM將會講到。
var addChange = function (ob) {
	ob.change = function (callback) {
		if (callback) {
			if (!this._change) this._change = {}
			this._change.push(callback)
		} else {
			if (!this._change) return 
			for (var i = this._change.length - 1; i >= 0; i--) {
				this._change[i].apply(this)
			}
		}
	}
}
複製程式碼

我們來看看一個實際的例子

var addChange = function (ob) {
	ob.change = function (callback) {
		if (callback) {
			if (!this._change) this._change = {}
			this._change.push(callback)
		} else {
			if (!this._change) return 
			for (var i = this._change.length - 1; i >= 0; i--) {
				this._change[i].apply(this)
			}
		}
	}
}

var object = {}
object.name = 'Foo'

addChange(object)
object.change(function () {
	console.log('Changed!', this)
	// 更新檢視的程式碼
})
obejct.change()
object.name = 'Bar'
object.change()
複製程式碼

這樣就實現了執行和觸發change事件了。 我相信大家對MVC有了比較深刻的理解,下面來學習MVVM模式。

MVVM

如今主流的web框架基本都採用的是MVVM模式,為什麼放棄了MVC模式,轉而投向了MVVM模式呢。在之前的MVC中我們提到一個控制器對應一個檢視,控制器用狀態機進行管理,這裡就存在一個問題,如果專案足夠大的時候,狀態機的程式碼量就變得非常臃腫,難以維護。還有一個就是效能問題,在MVC中我們大量的操作了DOM,而大量操作DOM會讓頁面渲染效能降低,載入速度變慢,影響使用者體驗。最後就是當Model頻繁變化的時候,開發者就主動更新View,那麼資料的維護就變得困難。世界是懶人創造的,為了減小工作量,節約時間,一個更適合前端開發的架構模式就顯得非常重要。這時候MVVM模式在前端中的應用就應運而生。 MVVM讓使用者介面和邏輯分離更加清晰。下面是MVVM的示意圖,可以看到它由Model、ViewModel、View這三個部分組成。

前端MVC、MVVM的簡單實現
下面分別來講講他們的作用

View

View是作為檢視模板,用於定義結構、佈局。它自己不處理資料,只是將ViewModel中的資料展現出來。此外為了和ViewModel產生關聯,那麼還需要做的就是資料繫結的宣告,指令的宣告,事件繫結的宣告。這在當今流行的MVVM開發框架中體現的淋淋盡致。在示例圖中,我們可以看到ViewModel和View之間是雙向繫結,意思就是說ViewModel的變化能夠反映到View中,View的變化也能夠改變ViewModel的資料值。那如何實現雙向繫結呢,例如有這個input元素:

<input type='text' yg-model='message'>
複製程式碼

隨著使用者在Input中輸入值的變化,在ViewModel中的message也會發生改變,這樣就實現了View到ViewModel的單向資料繫結。下面是一些思路:

  1. 掃描看哪些節點有yg-xxx屬性
  2. 自動給這些節點加上onchange這種事件
  3. 更新ViewModel中的資料,例如ViewModel.message = xx.innerText

那麼ViewModel到View的繫結可以是下面例子:

<p yg-text='message'></p>
複製程式碼

渲染後p中顯示的值就是ViewModel中的message變數值。下面是一些思路:

  1. 首先註冊ViewModel
  2. 掃描整個DOM Tree 看哪些節點有yg-xxx這中屬性
  3. 記錄這些被單向繫結的DOM節點和ViewModel之間的隱射關係
  4. 使用innerText,innerHTML = ViewModel.message進行賦值

ViewModel

ViewModel起著連線View和Model的作用,同時用於處理View中的邏輯。在MVC框架中,檢視模型通過呼叫模型中的方法與模型進行互動,然而在MVVM中View和Model並沒有直接的關係,在MVVM中,ViewModel從Model獲取資料,然後應用到View中。相對MVC的眾多的控制器,很明顯這種模式更能夠輕鬆管理資料,不至於這麼混亂。還有的就是處理View中的事件,例如使用者在點選某個按鈕的時候,這個行動就會觸發ViewModel的行為,進行相應的操作。行為就可能包括更改Model,重新渲染View。

Model

Model 層,對應資料層的域模型,它主要做域模型的同步。通過 Ajax/fetch 等 API 完成客戶端和服務端業務 Model 的同步。在層間關係裡,它主要用於抽象出 ViewModel 中檢視的 Model。

MVVM簡單實現

實現效果:

<div id="mvvm">
	<input type="text" v-model="message">
	<p>{{message}}</p>
	<button v-click='changeMessage'></button>
</div>
<script type="">
	const vm = new MVVM({
		el: '#mvvm',
		methods: {
			changeMessage: function () {
				this.message = 'message has change'
			}
		},
		data: {
			message: 'this is old message'
		}
	})
</script>
複製程式碼

這裡為了簡單,借鑑了Vue的一些方法

Observer

MVVM為我們省去了手動更新檢視的步驟,一旦值發生變化,檢視就重新渲染,那麼就需要對資料的改變就行檢測。例如有這麼一個例子:

hero = {
	name: 'A'
}
複製程式碼

這時候但我們訪問hero.name 的時候,就會列印出一些資訊:

hero.name 
// I'm A
複製程式碼

當我們對hero.name 進行更改的時候,也會列印出一些資訊:

hero.name = 'B'
// the name has change
複製程式碼

這樣我們是不是就實現了資料的觀測了呢。 在Angular中實現資料的觀測使用的是髒檢查,就是在使用者進行可能改變ViewModel的操作的時候,對比以前老的ViewModel然後做出改變。 而在Vue中,採取的是資料劫持,就是當資料獲取或者設定的時候,會觸發Object.defineProperty()。 這裡我們採取的是Vue資料觀測的方法,簡單一些。下面是具體的程式碼

function observer (obj) {
	let keys = Object.keys(obj)
	if (typeof obj === 'object' && !Array.isArray(obj)) {
		keys.forEach(key => {
			defineReactive(obj, key, obj[key])
		})	
	}
}

function defineReactive (obj, key, val) {
	observer(val)
	Object.defineProperty(obj, key, {
		enumerable: true,
    	configurable: true,
		get: function () {
			console.log('I am A')
			return val
		},
		set: function (newval) {
			console.log('the name has change')
			observer(val)
			val = newval
		}
	}) 
}
複製程式碼

把hero帶入observe方法中,結果正如先前預料的一樣的結果。這樣資料的檢測也就實現了,然後在通知訂閱者。如何通知訂閱者呢,我們需要實現一個訊息訂閱器,維護一個陣列用來收集訂閱者,資料變動觸發notify(),然後訂閱者觸發update()方法,改善後的程式碼長這樣:

function defineReactive (obj) {
	dep = new Dep()
	Object.defineProperty(obj, key, {
		enumerable: true,
    	configurable: true,
		get: function () {
			console.log('I am A')
			Dep.target || dep.depend()
			return val
		},
		set: function (newval) {
			console.log('the name has change')
			dep.notify()
			observer(val)
			val = newval
		}
	}) 
}

var Dep = function Dep () {
	this.subs = []
}
Dep.prototype.notify = function(){
	var subs = this.subs.slice()
	for (var i = 0, l = subs.length; i < l; i++) {
		subs[i].update()
	}
}
Dep.prototype.addSub = function(sub){
	this.subs.push(sub)
}
Dep.prototype.depend = function(){
	if (Dep.target) {
		Dep.target.addDep(this)
	}
}
複製程式碼

這跟Vue原始碼差不多,就完成了往訂閱器裡邊新增訂閱者,和通知訂閱者。這裡以前我看Vue原始碼的時候,困擾了很久的問題,就是在get方法中Dep是哪兒來的。這裡說一下他是一個全域性變數,新增target變數是用於向訂閱器中新增訂閱者。這裡的訂閱者是Wacther,Watcher就可以連線檢視更新檢視。下面是Watcher的一部分程式碼

Watcher.prototype.get = function(key){
	Dep.target = this
	this.value = obj[key] // 觸發get從而向訂閱器中新增訂閱者
	Dep.target = null // 重置
};
複製程式碼

Compile

在講MVVM概念的時候,在View -> ViewModel的過程中有一個步驟就是在DOM tree中尋找哪個具有yg-xx的元素。這一節就是講解析模板,讓View和ViewModel連線起來。遍歷DOM tree是非常消耗效能的,所以會先把節點el轉換為文件碎片fragment進行解析編譯操作。操作完成後,在將fragment新增到原來的真實DOM節點中。下面是它的程式碼

function Compile (el) {
	this.el = document.querySelector(el)
	this.fragment = this.init()
	this.compileElement()
}

Compile.prototype.init = function(){
	var fragment = document.createDocumentFragment(), chid 
	while (child.el.firstChild) {
		fragment.appendChild(child)
	}
	return fragment
};

Compile.prototype.compileElement = function(){
	fragment = this.fragment 
	me = this 
	var childNodes = el.childNodes 
	[].slice.call(childNodes).forEach(function (node) {
		var text = node.textContent 
		var reg = /\{\{(.*)\}\}/ // 獲取{{}}中的值
		if (reg.test(text)) {
			me.compileText(node, RegExp.$1)
		}

		if (node.childNodes && node.childNodes.length) {
			me.compileElement(node)
		}
	})
}
Compile.prototype.compileText = function (node, vm, exp) {
	updateFn && updateFn(node, vm[exp])
	new Watcher(vm, exp, function (value, oldValue) {
		// 一旦屬性值有變化,就會收到通知執行此更新函式,更新檢視
		updateFn() && updateFn(node, val)
	})
}
// 更新檢視
function updateFn (node, value) {
	node.textContent = value 
}
複製程式碼

這樣編譯fragment就成功了,並且ViewModel中值的改變就能夠引起View層的改變。接下來是Watcher的實現,get方法已經講了,我們來看看其他的方法。

Watcher

Watcher是連線Observer和Compile之間的橋樑。可以看到在Observer中,往訂閱器中新增了自己。dep.notice()發生的時候,呼叫了sub.update(),所以需要一個update()方法,值發生變化後,就能夠觸發Compile中的回撥更新檢視。下面是Watcher的具體實現

var Watcher = function Watcher (vm, exp, cb) {
	this.vm = vm 
	this.cb = cb 
	this.exp = exp 
	// 觸發getter,向訂閱器中新增自己
	this.value = this.get()
}

Watcher.prototype = {
	update: function () {
		this.run()
	},
	addDep: function (dep) {
		dep.addSub(this)
	},
	run: function () {
		var value = this.get()
		var oldVal = this.value 
		if (value !== oldValue) {
			this.value = value 
			this.cb.call(this.vm, value, oldValue) // 執行Compile中的回撥
		}
	},
	get: function () {
		Dep.target = this 
		value = this.vm[exp] // 觸發getter
		Dep.target = null 
		return value 
	}
}
複製程式碼

在上面的程式碼中Watcher就起到了連線Observer和Compile的作用,值發生改變的時候通知Watcher,然後Watcher呼叫update方法,因為在Compile中定義的Watcher,所以值發生改變的時候,就會呼叫Watcher()中的回撥,從而更新檢視。最重要的部分也就完成了。在加一個MVVM的構造器就ok了。推薦一篇文章自己實現MVVM,這裡邊講的更加詳細。

總結

ok,本篇文章就結束了,通過對比希望讀者能夠對前端當前框架能夠更清晰的認識。謝謝大家

相關文章