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

凭空来写一个框架是不现实的,所以在这篇文章中我们会尝试写一些简单的应用代码。
我们一开始先写基础的 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 可以通过 heightweight 字段计算得出。

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 框架的示例。

< 上一篇                                                                                                         下一篇 >


原文地址

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,442评论 25 707
  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,019评论 4 62
  • 自然界是奇妙的,一花一草,都存在着它成长的轨迹。而观察,便是对于奇妙事物最好的阐述,让我们竖起耳朵,聆听大自...
    燕子_2f24阅读 285评论 0 0
  • 儿时,您扶我蹒跚学步,晚年,我是否可以搀您夕阳漫步? 儿时,您逗我乐哄我笑,晚年,我是否可以陪您生活喜乐? 儿时,...
    波斯猫123阅读 229评论 1 1