凭空来写一个框架是不现实的,所以在这篇文章中我们会尝试写一些简单的应用代码。
我们一开始先写基础的 JavaScript,然后将它重构成基于 MVVM 的项目。
我在这篇文章中所有代码都是基于 JSbin 用 babel/ES-6 语法写的。如果你对哪一行代码有疑惑,可以在那里尝试一下。
用 MVVM 方式编写你的代码
基础 js 方式写的学生信息
我们继续上一篇文章中提到的学生信息应用。
如果我们想要完成这样一个应用,我们可能从下面的代码开始:
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).
现在,让我们减少这个应用重复的代码。
我们可以发现,我们执行了很多次 document.createElement
来为列表创建 HTML 节点。事实上,我们不需要这样做,因为所有的列表元素共享同样的结构。
所以,那应该是一个共享函数。
首先,我们先为函数复制行的部分:
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_)
}
光是这样好像并不正常运行,于是我们再修复一下:
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': '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)
变得更简短且易读了吧。
(老版本里,)你一眼看不出在在一堆节点创建的代码里我到底在干什么,但是新版本里,很明显我在创建一个列表和它的元素。
对于阅读里代码的人,可能他们并不在意你如何创建列表元素,他们知道你在创建一个列表元素就够了;对于那些在意列表元素的人,他们可以只查阅 createListItem
函数,而不考虑你如何创建你的列表。于是,应用就变成了:
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 更进一步
说真的,现在我们的应用已经多少有了点 MVVM 的风格。
student
对象是我们的原始数据,在我们的重构中,它永远不曾变动,我们可以称之为“模型”。createList
函数返回了我们需要展示的 DOM 树,我认为它理论上可以被称为“视图”。而 “View-Model” 呢?不幸的是,到目前为止,我们还有没独立出一个“View-Model”。是的,我是说,“View-Model”没有被独立出来,但事实上,它是存在的。我们传递到 createList
中的参数是“模型”转换之后的结果,换句话说,我们通过手动创建的数组来使得“模型”和“视图”相适配。
下面来将其独立出来:
//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)
这看起来好多了,除了最后两行……
好吧,再把它们封装一下:
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(Body Mass Index,身体质量指数)
比如说,我们的产品经理要求我们要为学生信息添加一个新的字段 BMI。在原来的代码基础之上要实现这样的功能缺失让人恼火。至少我不会那样做,我讨厌频繁的复制和粘贴 document.createElement
。
相较而言,在 MVVM 版本中,它却变得轻松了:我们只需要修改 “View-Model”,因为 BMI 可以通过 height
和 weight
字段计算得出。
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”你是基本上没有办法复用的;它相当于是一个专门的针对于一个特定视图和一个特定模型的适配器。
因为它是专门的,修改它将不会让你面临程序其它地方崩溃的风险。但是如果你想修改视图或者模型,你需要检查所有它们被使用过的地方。
转换 height
的度量单位
在中国,有一个笑话是,一个程序员可以和除了产品经理之外的任何人称为朋友,因为产品经理总是变更他们的需求:)。
假设产品经理告诉你,要添加一个转化 height
字段单位的功能……
事实上,
我并不想解释大量关于如何管理用户输入的东西,这会很复杂,所以我计划在后面的文章中讨论它。但是在用户界面开发的时候,用户输入又是如此的重要,所以我觉得还是有必要在这个问题上多说两句。
为了添加一个按钮,我们需要修改我们的视图,而我们的视图又可能在其它地方被复用,所以我们不应该草率地修改我们当前的视图。
这里我们将通过我们将旧的视图与一些新代码相结合来复用它。
首先,我们需要一些新的代码来负责现在的量度,所以我们引入了一个新的模型。
const tk = {
'first-name': 'Tracy',
'last-name': 'Kent',
'height': 170,
'weight': 50
}
const measurement = 'cm'
我们添加了一条测量数据,而非修改 tk
:所以 tk
仍旧能被其它模块所复用。
对于视图部分,我们可以将之前的列表视图作为我们新视图的一个部分:
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
函数返回一个带有一系列单选按钮的表单。但是从目前的代码来看,我们无法得知它在我们的应用中将扮演的角色。换句话说,它是同业务解耦的。
最后,View-Model 部分:
如你所见,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
添加了 opt
参数,我们使用不同的公式来计算 height
;当仍和一个单选按钮被点击的时候,这个模型的度量单位将发生变化。
看起来很完美,但实际上当你点击单选按钮的时候,它并不会生效。因为我们并没有数据变化时的更新机制。
这一部分,关于一个 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
})
这种机制在计算机领域被称为“轮询”。在你的应用运行在浏览器中时,使用它并不是一个好的想法。虽然它被浏览器广泛地使用:)。
这里,我们引入一个国外的库。我懒得去自己实现一个 isEqual
函数。所以我使用 lodash 来检查模型的更新。
每秒钟,run
函数都将检查是否有模型更新:如果更新了,我们将重新渲染整个视图(当你有大量的 DOM
节点的时候,这将导致性能问题);否则,我们什么也不做,静候下一秒。
这是一个简单的 MVVM 风格的应用的示例,下一篇文章,我们将基于它创建一个 MVVM 框架的示例。