前端静径

一个只有十行的精简MVVM框架

前言

MVVM模式相信做前端的人都不陌生,去网上搜MVVM,会出现一大堆关于MVVM模式的博文,但是这些博文大多都只是用图片和文字来进行抽象的概念讲解,对于刚接触MVVM模式的新手来说,这些概念虽然能够读懂,但是也很难做到理解透彻。因此,我写了这篇文章。

这篇文章旨在通过代码的形式让大家更好的理解MVVM模式,相信大多数人读了这篇文章之后再去看其他诸如regular、vue等基于MVVM模式框架的源码,会容易很多。

如果你对MVVM模式已经很熟悉并且也已经研读过并深刻理解了当下主流的前端框架,可以忽略下面的内容。如果你没有一点JavaScript基础,也请先去学习下再来阅读读此文。

引子

来张图来镇压此文:

alt MVVM模式

MVVMModel-View-ViewModel的缩写。简单的讲,它将ViewModel层分隔开,利用ViewModel层将Model层的数据经过一定的处理变成适用于View层的数据结构并传送到View层渲染界面,同时View层的视图更新也会告知ViewModel层,然后ViewModel层再更新Model层的数据。

我们用一段学生信息的代码作为引子,然后一步步再重构成MVVM模式的样子。

编写类似下面结构的学生信息:

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

用常规的js代码是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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来创建节点,但是由于列表项都是相似的结构,所以我们没有必要一遍一遍的写。因此,进行如下封装:

1
2
3
4
5
6
7
8
9
10
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
}

经过这步转化之后,整个学生信息应用就变成了这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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,这里显然还有精简的空间。因此,我们再进一步封装:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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层了吧~

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 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)

这看上去更舒服了。但是,最后两行还能封装~

1
2
3
4
5
6
7
8
9
const smvvm = function (root, {model, view, vm}) {
const rendered = view(vm(model))
root.appendChild(rendered)
}
smvvm(document.body, {
model: tk,
view: createList,
vm: formatStudent
})

这种写法,熟悉vue或者regular的同学,应该会觉得似曾相识吧?

让我们来加点互动

前面学生信息的身高的单位都是默认m,如果新增一个需求,要求学生的身高的单位可以在mcm之间切换呢?

首先需要一个变量来保存度量单位,因此这里必须用一个新的Model:

1
2
3
4
5
6
7
const tk = {
'first-name': 'Jessica',
'last-name': 'Bre',
'height': 180,
'weight': 70,
}
const measurement = 'cm'

为了让tk更方便的被其他模块重用,这里选择增加一个measurement数据源,而不是直接修改tk

在视图部分要增加一个radio单选表单,用来切换身高单位。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
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结构重构是有必要的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
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框架,这里就不再赘述,并用最简单的方式实现视图更新:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const smvvm = 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)
}
smvvm(document.body, {
model: {student:tk, measurement},
view: createToggleableList,
vm: createVm
})

上述代码引用了一个外部库lodashisEqual方法来比较数据模型是否有更新。此段代码应用了轮询,每秒都会检测数据是否发生变化,有变化了再更新视图。这是最笨的方法,并且在DOM结构比较复杂时,性能也会受到很大的影响。还是同样的话,本文的主题是一个精简的MVVM框架,因此略去了很多细节性的东西,只把主要的东西提炼出来,以达到更好的理解MVVM模式的目的。

MVVM框架的诞生

以上便是一个简短精简的MVVM风格的学生信息的示例。至此,一个精简的MVVM框架其实已经出来了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* @param {Node} root
* @param {Object} model
* @param {Function} view
* @param {Function} vm
*/
const smvvm = 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框架会更加健壮。

参考文献

Write a MVVM framework from scratch