前言
MVVM模式相信做前端的人都不陌生,去網上搜MVVM,會出現一大堆關於MVVM模式的博文,但是這些博文大多都只是用圖片和文字來進行抽象的概念講解,對於剛接觸MVVM模式的新手來說,這些概念雖然能夠讀懂,但是也很難做到理解透徹。因此,我寫了這篇文章。
這篇文章旨在通過程式碼的形式讓大家更好的理解MVVM模式,相信大多數人讀了這篇文章之後再去看其他諸如regular、vue等基於MVVM模式框架的原始碼,會容易很多。
如果你對MVVM模式已經很熟悉並且也已經研讀過並深刻理解了當下主流的前端框架,可以忽略下面的內容。如果你沒有一點JavaScript基礎,也請先去學習下再來閱讀讀此文。
引子
來張圖來鎮壓此文:
MVVM
是Model-View-ViewModel
的縮寫。簡單的講,它將View
與Model
層分隔開,利用ViewModel
層將Model
層的資料經過一定的處理變成適用於View
層的資料結構並傳送到View
層渲染介面,同時View
層的檢視更新也會告知ViewModel
層,然後ViewModel
層再更新Model
層的資料。
我們用一段學生資訊的程式碼作為引子,然後一步步再重構成MVVM模式的樣子。
編寫類似下面結構的學生資訊:
- Name: Jessica Bre
- Height: 1.8m
- Weight: 70kg
用常規的js程式碼是這樣的:
const student = {
'first-name': 'Jessica',
'last-name': 'Bre',
'height': 180,
'weight': 70,
}
const root = document.createElement('ul')
const nameLi = document.createElement('li')
const nameLabel = document.createElement('span')
nameLabel.textContent = 'Name: '
const name_ = document.createElement('span')
name_.textContent = student['first-name'] + ' ' + student['last-name']
nameLi.appendChild(nameLabel)
nameLi.appendChild(name_)
const heightLi = document.createElement('li')
const heightLabel = document.createElement('span')
heightLabel.textContent = 'Height: '
const height = document.createElement('span')
height.textContent = '' + student['height'] / 100 + 'm'
heightLi.appendChild(heightLabel)
heightLi.appendChild(height)
const weightLi = document.createElement('li')
const weightLabel = document.createElement('span')
weightLabel.textContent = 'Weight: '
const weight = document.createElement('span')
weight.textContent = '' + student['weight'] + 'kg'
weightLi.appendChild(weightLabel)
weightLi.appendChild(weight)
root.appendChild(nameLi)
root.appendChild(heightLi)
root.appendChild(weightLi)
document.body.appendChild(root)複製程式碼
好長的一堆程式碼呀!別急,下面我們一步步優化!
DRY一下如何
程式設計中最廣泛接受的規則之一就是“DRY”: "Do not Repeat Yourself"。很顯然,上面的一段程式碼有很多重複的部分,不僅與這個準則相違背,而且給人一種不舒服的感覺。是時候做下處理,來讓這段學生資訊更"Drier"。
可以發現,程式碼裡寫了很多遍document.createElement
來建立節點,但是由於列表項都是相似的結構,所以我們沒有必要一遍一遍的寫。因此,進行如下封裝:
const createListItem = function (label, content) {
const li = document.createElement('li')
const labelSpan = document.createElement('span')
labelSpan.textContent = label
const contentSpan = document.createElement('span')
contentSpan.textContent = content
li.appendChild(labelSpan)
li.appendChild(contentSpan)
return li
}複製程式碼
經過這步轉化之後,整個學生資訊應用就變成了這樣:
const student = {
'first-name': 'Jessica',
'last-name': 'Bre',
'height': 180,
'weight': 70,
}
const createListItem = function (label, content) {
const li = document.createElement('li')
const labelSpan = document.createElement('span')
labelSpan.textContent = label
const contentSpan = document.createElement('span')
contentSpan.textContent = content
li.appendChild(labelSpan)
li.appendChild(contentSpan)
return li
}
const root = document.createElement('ul')
const nameLi = createListItem('Name: ', student['first-name'] + ' ' + student['last-name'])
const heightLi = createListItem('Height: ', student['height'] / 100 + 'm')
const weightLi = createListItem('Weight: ', student['weight'] + 'kg')
root.appendChild(nameLi)
root.appendChild(heightLi)
root.appendChild(weightLi)
document.body.appendChild(root)複製程式碼
是不是變得更短了,也更易讀了?即使你不看createListItem
函式的實現,光看const nameLi = createListItem('Name: ', student['first-name'] + ' ' + student['last-name'])
也能大致明白這段程式碼時幹什麼的。
但是上面的程式碼封裝的還不夠,因為每次建立一個列表項,我們都要多呼叫一遍createListItem
,上面的程式碼為了建立name,height,weight
標籤,呼叫了三遍createListItem
,這裡顯然還有精簡的空間。因此,我們再進一步封裝:
const student = {
'first-name': 'Jessica',
'last-name': 'Bre',
'height': 180,
'weight': 70,
}
const createList = function(kvPairs){
const createListItem = function (label, content) {
const li = document.createElement('li')
const labelSpan = document.createElement('span')
labelSpan.textContent = label
const contentSpan = document.createElement('span')
contentSpan.textContent = content
li.appendChild(labelSpan)
li.appendChild(contentSpan)
return li
}
const root = document.createElement('ul')
kvPairs.forEach(function (x) {
root.appendChild(createListItem(x.key, x.value))
})
return root
}
const ul = createList([
{
key: 'Name: ',
value: student['first-name'] + ' ' + student['last-name']
},
{
key: 'Height: ',
value: student['height'] / 100 + 'm'
},
{
key: 'Weight: ',
value: student['weight'] + 'kg'
}])
document.body.appendChild(ul)複製程式碼
有沒有看到MVVM風格的影子?student
物件是原始資料,相當於Model
層;createList
建立了dom
樹,相當於View
層,那麼ViewModel
層呢?仔細觀察,其實我們傳給createList
函式的引數就是Model
的資料的改造,為了讓Model
的資料符合View
的結構,我們做了這樣的改造,因此雖然這段函式裡面沒有獨立的ViewModel
層,但是它確實是存在的!聰明的同學應該想到了,下一步就是來獨立出ViewModel
層了吧~
// Model
const tk = {
'first-name': 'Jessica',
'last-name': 'Bre',
'height': 180,
'weight': 70,
}
//View
const createList = function(kvPairs){
const createListItem = function (label, content) {
const li = document.createElement('li')
const labelSpan = document.createElement('span')
labelSpan.textContent = label
const contentSpan = document.createElement('span')
contentSpan.textContent = content
li.appendChild(labelSpan)
li.appendChild(contentSpan)
return li
}
const root = document.createElement('ul')
kvPairs.forEach(function (x) {
root.appendChild(createListItem(x.key, x.value))
})
return root
}
//ViewModel
const formatStudent = function (student) {
return [
{
key: 'Name: ',
value: student['first-name'] + ' ' + student['last-name']
},
{
key: 'Height: ',
value: student['height'] / 100 + 'm'
},
{
key: 'Weight: ',
value: student['weight'] + 'kg'
}]
}
const ul = createList(formatStudent(tk))
document.body.appendChild(ul)複製程式碼
這看上去更舒服了。但是,最後兩行還能封裝~
const run = function (root, {model, view, vm}) {
const rendered = view(vm(model))
root.appendChild(rendered)
}
run(document.body, {
model: tk,
view: createList,
vm: formatStudent
})複製程式碼
這種寫法,熟悉vue或者regular的同學,應該會覺得似曾相識吧?
讓我們來加點互動
前面學生資訊的身高的單位都是預設m
,如果新增一個需求,要求學生的身高的單位可以在m
和cm
之間切換呢?
首先需要一個變數來儲存度量單位,因此這裡必須用一個新的Model:
const tk = {
'first-name': 'Jessica',
'last-name': 'Bre',
'height': 180,
'weight': 70,
}
const measurement = 'cm'複製程式碼
為了讓tk
更方便的被其他模組重用,這裡選擇增加一個measurement
資料來源,而不是直接修改tk
。
在檢視部分要增加一個radio單選表單,用來切換身高單位。
const createList = function(kvPairs){
const createListItem = function (label, content) {
const li = document.createElement('li')
const labelSpan = document.createElement('span')
labelSpan.textContent = label
const contentSpan = document.createElement('span')
contentSpan.textContent = content
li.appendChild(labelSpan)
li.appendChild(contentSpan)
return li
}
const root = document.createElement('ul')
kvPairs.forEach(function (x) {
root.appendChild(createListItem(x.key, x.value))
})
return root
}
const createToggle = function (options) {
const createRadio = function (name, opt){
const radio = document.createElement('input')
radio.name = name
radio.value = opt.value
radio.type = 'radio'
radio.textContent = opt.value
radio.addEventListener('click', opt.onclick)
radio.checked = opt.checked
return radio
}
const root = document.createElement('form')
options.opts.forEach(function (x) {
root.appendChild(createRadio(options.name, x))
root.appendChild(document.createTextNode(x.value))
})
return root
}
const createToggleableList = function(vm){
const listView = createList(vm.kvPairs)
const toggle = createToggle(vm.options)
const root = document.createElement('div')
root.appendChild(toggle)
root.appendChild(listView)
return root
}複製程式碼
接下來是ViewModel
部分,createToggleableList
函式需要與之前的createList
函式不同的引數。因此,對View-Model結構重構是有必要的:
const createVm = function (model) {
const calcHeight = function (measurement, cms) {
if (measurement === 'm'){
return cms / 100 + 'm'
}else{
return cms + 'cm'
}
}
const options = {
name: 'measurement',
opts: [
{
value: 'cm',
checked: model.measurement === 'cm',
onclick: () => model.measurement = 'cm'
},
{
value: 'm',
checked: model.measurement === 'm',
onclick: () => model.measurement = 'm'
}
]
}
const kvPairs = [
{
key: 'Name: ',
value: model.student['first-name'] + ' ' + model.student['last-name']
},
{
key: 'Height: ',
value: calcHeight(model.measurement, model.student['height'])
},
{
key: 'Weight: ',
value: model.student['weight'] + 'kg'
},
{
key: 'BMI: ',
value: model.student['weight'] / (model.student['height'] * model.student['height'] / 10000)
}]
return {kvPairs, options}
}複製程式碼
這裡為createToggle
新增了ops
,並且將ops
封裝成了一個物件。根據度量單位,使用不同的方式去計算身高。當任何一個radio
被點選,資料的度量單位將會改變。
看上去很完美,但是當你點選radio標籤的時候,檢視不會有任何改變。因為這裡還沒有為檢視做更新演算法。有關MVVM
如何處理檢視更新,那是一個比較大的課題,需要另闢一個博文來講,由於本文寫的是一個精簡的MVVM
框架,這裡就不再贅述,並用最簡單的方式實現檢視更新:
const run = function (root, {model, view, vm}) {
let m = {...model}
let m_old = {}
setInterval( function (){
if(!_.isEqual(m, m_old)){
const rendered = view(vm(m))
root.innerHTML = ''
root.appendChild(rendered)
m_old = {...m}
}
},1000)
}
run(document.body, {
model: {student:tk, measurement},
view: createToggleableList,
vm: createVm
})複製程式碼
上述程式碼引用了一個外部庫lodash
的isEqual
方法來比較資料模型是否有更新。此段程式碼應用了輪詢,每秒都會檢測資料是否發生變化,有變化了再更新檢視。這是最笨的方法,並且在DOM結構比較複雜時,效能也會受到很大的影響。還是同樣的話,本文的主題是一個精簡的MVVM框架,因此略去了很多細節性的東西,只把主要的東西提煉出來,以達到更好的理解MVVM模式的目的。
MVVM框架的誕生
以上便是一個簡短精簡的MVVM風格的學生資訊的示例。至此,一個精簡的MVVM框架其實已經出來了:
/**
* @param {Node} root
* @param {Object} model
* @param {Function} view
* @param {Function} vm
*/
const run = function (root, {model, view, vm}) {
let m = {...model}
let m_old = {}
setInterval( function (){
if(!_.isEqual(m, m_old)){
const rendered = view(vm(m))
root.innerHTML = ''
root.appendChild(rendered)
m_old = {...m}
}
},1000)
}複製程式碼
什麼?你確定不是在開玩笑?一個只有十行的框架?請記住:
框架是對如何組織程式碼和整個專案如何通用運作的抽象。
這並不意味著你應該有一堆程式碼或混亂的類,儘管企業可用的API列表經常都很可怕的長。但是如果你研讀一個框架倉庫的核心資料夾,你可能發現它會出乎意料的小(相比於整個專案來說)。其核心程式碼包含主要工作程式,而其他部分只是幫助開發人員以更加舒適的方式構建應用程式的附件。有興趣的同學可以去看看cycle.js,這個框架只有124行(包含註釋和空格)。
總結
此時用一張圖來作為總結再好不過了!
當然這裡還有很多細節需要進一步探討,比如如何選擇或設計一個更加友好的View層的檢視工具,如何更新和何時更新檢視比較合適等等。如果把這些問題都解決了,相信這種MVVM框架會更加健壯。