前端静径

从零开始编写一个MVVM框架(二)(译)

前言

凭空写一个框架是很难的,因此我们在这篇文章中将要尝试去写一些简单的应用。我们从基础的JavaScript开始,然后将其重构为基于MVVM模式的程序。

我把所有的代码都放在了JSbin上,并且用了babel/ES6模式。如果你对任何地方有困惑,请别犹豫,去那里试着敲一下。

MVVM方式写你的代码

基本JS编写学生信息

我们继续用上篇文章中提到的学生信息。如果我们想要写这样的一个app,我们应该从下面的代码开始:

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
const student = {
'first-name': 'Tracy',
'last-name': 'Kent',
'height': 170,
'weight': 50,
}
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)

其结果类似于下面:

  • Name: Tracy Kent
  • Height: 1.7m
  • Weight: 50kg

一个三行的列表却花费了一大推代码,这真恐怖。

重构可复用性

为什么程序员着迷于各种最佳实践?这是他们懒惰的结果。

怠惰是程序员的美德。

这个行业最伟大的想法之一就是“重用”。我们现在的代码里面包含了一大堆重复的行,而程序设计中最广泛接受的规则之一是“DRY”:

Do not Repeat Yourself

现在我们让这个App更加Drier:

我们会发现,我们写了好几遍document.createElement来为列表创建HTML节点,但实际上我们没有必要这样做,因为所有的列表项都具有相似的结构。

对,那应该是一个共享函数。

我们首先复制name行的代码,将其放在function中:

1
2
3
4
5
6
7
8
9
const createListItem = function (label, content) {
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_)
}

上面这段代码不会起作用,让我们来修复它:

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
}

因此,这整个App就变成了这样:

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
const student = {
'first-name': 'Tracy',
'last-name': 'Kent',
'height': 170,
'weight': 50,
}
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)

更短了并且可读性更强了。在刚开始混乱的Node-creation的代码中,你无法知道我在做什么。但是在新的版本中,很明显地可以看出来我正在创建一个列表及其列表项。对于那些读你代码的人,他们可能不关心你是如何创建一个列表项的,他们只知道你在创建列表项。对于那些对列表项有兴趣的人,他们可以去参阅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
41
42
43
const student = {
'first-name': 'Tracy',
'last-name': 'Kent',
'height': 170,
'weight': 50,
}
// The list creation util
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
}
//The business logic
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更进一步

现在我们的App看上去有点像MVVM风格了。student对象是我们的原始数据,在我们的重构中它从来没有变过。我们可以称呼它为’Model‘。createList函数返回了一个DOM树,因此可以叫它’View‘。那么’View-Model‘呢?不幸的是,目前为止我们还没有独立的View-Model‘。我的意思是,现在的’View-Model‘还不是独立的,但是确实是存在的。我们传给createList函数的参数就是’Model‘的改造。也就是说,我们通过人工创建的数组来将’Model‘向’View‘适配。

让我们隔离它:

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
45
46
47
48
//Model
const tk = {
'first-name': 'Tracy',
'last-name': 'Kent',
'height': 170,
'weight': 50,
}
//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
}
//View-Model
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
10
const run = function (root, {model, view, vm}) {
const rendered = view(vm(model))
root.appendChild(rendered)
}
run(document.body, {
model: tk,
view: createList,
vm: formatStudent
})

需求改变: BMI

我们的产品叫为BMI(身体质量指数)新添加一行。使用原始的代码来做这个很烦人,我不会在这里做的。我恨复制粘贴document.createElement几十次。

作为对比,对于MVVM版本确是容易的:我们只要修改’View-Model‘就可以了,因为身体质量指数可以从身高和体重计算得来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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'
},
{
key: 'BMI: ',
value: student['weight'] / (student['height'] * student['height'] / 10000)
}]
}

我们可以这样做,或者在函数内做一些进一步的优化,但这不是我们这里的关注的点。我想说的是:为什么我们选择改变’View-Model’?

在MVVM模式中,如果需要改变,我们总是将改变’View-Model’作为首要选择。我觉得这个不难理解:

View可用于显示其他数据集,它只关注数据如何显示。Model可以以其他形式显示,它只关注业务所做的工作。

他们都有重用的潜力,因此我们最好把它们做成通用的。

View-Model是很难被重用的功能。这是一个特定View和某个Model之间专用的适配器。

由于它是专用的,修改它不会使您面临破坏程序的其他部分的风险。但是,如果您想使用View或Model做某事,则需要检查解决方案中所有使用它们的地方。

切换高度度量

在中国有一个玩笑话:一个程序员可以和任何人做朋友除了产品经理。因为产品经理们总是改变他们的需求。

想象一下产品经理叫你添加一个切换来改变高度的度量…

实际上,我不想在这里解释很多如何管理用户输入。这有点复杂,所以我打算在以后的文章里做。但用户输入在UI开发中非常重要,我认为有必要在这个问题上说几句话。

要添加按钮,我们需要修改我们的视图。我们的视图可能被别人重复使用,所以我们不应该轻率地改变现在的视图。这里我们将复用旧的代码来结合一些新的代码。首先,我们需要一些东西来代替现在的度量,所以我们必须调用一个新的Model:

1
2
3
4
5
6
7
8
const tk = {
'first-name': 'Tracy',
'last-name': 'Kent',
'height': 170,
'weight': 50
}
const measurement = 'cm'

我们添加了一个measurement数据源,而不是修改tk,这样tk可以仍然被其他模块使用。

对于View部分,我们可以重用列表视图作为新视图的一部分:

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
45
46
47
48
49
50
51
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
}

createToggle函数返回一系列单选框按钮表单。但是从目前的代码来看,我们不知道这个在我们的App中扮演了什么角色。换句话说,这是业务隔离的。

最后,View-Model部分:我们可以看到,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
43
44
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封装成了一个对象。根据度量单位,我们使用不同的方式去计算height。当任何一个radio被点击,数据的度量单位将会改变。

看上去很完美,但是当你点击radio按钮的时候它不会有效果,因为我们没有为数据改变做更新算法。这个部分,有关MVVM框架如何处理数据更新有点扭曲(可以认为不困难)。我想把它放在下一篇文章中。这里,我们将用最简单的方式来实现它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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
})

这种机制在计算机科学中被称为“轮询”。这在浏览器app中不是一个好方法,虽然它被浏览器广泛应用。这里我们引入了一个外部库,我真是太懒了以至于我不想自己写一个areEqual函数,因此我用lodash来检测数据模型的更新。

run函数每秒都会检查数据更新是否发生:如果发生了,我们将会重新渲染整个视图(如果有很多DOM节点的话,这将导致性能问题);如果没有,我们不会做任何事情,继续等待下一秒。

这就是简单的MVVM风格的App的示例,下一篇文章我们将基于这个App创建一个小型MVVM框架。