一個只有十行的精簡MVVM框架

前言

MVVM模式相信作前端的人都不陌生,去網上搜MVVM,會出現一大堆關於MVVM模式的博文,可是這些博文大多都只是用圖片和文字來進行抽象的概念講解,對於剛接觸MVVM模式的新手來講,這些概念雖然可以讀懂,可是也很難作到理解透徹。所以,我寫了這篇文章。javascript

這篇文章旨在經過代碼的形式讓你們更好的理解MVVM模式,相信大多數人讀了這篇文章以後再去看其餘諸如regular、vue等基於MVVM模式框架的源碼,會容易不少。前端

若是你對MVVM模式已經很熟悉而且也已經研讀過並深入理解了當下主流的前端框架,能夠忽略下面的內容。若是你沒有一點JavaScript基礎,也請先去學習下再來閱讀讀此文。vue

引子

來張圖來鎮壓此文:java

alt MVVM模式

MVVMModel-View-ViewModel的縮寫。簡單的講,它將ViewModel層分隔開,利用ViewModel層將Model層的數據通過必定的處理變成適用於View層的數據結構並傳送到View層渲染界面,同時View層的視圖更新也會告知ViewModel層,而後ViewModel層再更新Model層的數據。算法

咱們用一段學生信息的代碼做爲引子,而後一步步再重構成MVVM模式的樣子。前端框架

編寫相似下面結構的學生信息:數據結構

  • Name: Jessica Bre
  • Height: 1.8m
  • Weight: 70kg

用常規的js代碼是這樣的:app

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"。dom

能夠發現,代碼裏寫了不少遍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,若是新增一個需求,要求學生的身高的單位能夠在mcm之間切換呢?

首先須要一個變量來保存度量單位,所以這裏必須用一個新的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 
})

上述代碼引用了一個外部庫lodashisEqual方法來比較數據模型是否有更新。此段代碼應用了輪詢,每秒都會檢測數據是否發生變化,有變化了再更新視圖。這是最笨的方法,而且在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行(包含註釋和空格)。

總結

此時用一張圖來做爲總結再好不過了!

alt MVVM模式

固然這裏還有不少細節須要進一步探討,好比如何選擇或設計一個更加友好的View層的視圖工具,如何更新和什麼時候更新視圖比較合適等等。若是把這些問題都解決了,相信這種MVVM框架會更加健壯。

相關文章
相關標籤/搜索