一个只有十行的精简MVVM框架
本文来自网易云社区。
前言
MVVM模式相信做前端的人都不陌生,去网上搜MVVM,会出现一大堆关于MVVM模式的博文,但是这些博文大多都只是用图片和文字来进行抽象的概念讲解,对于刚接触MVVM模式的新手来说,这些概念虽然能够读懂,但是也很难做到理解透彻。因此,我写了这篇文章。
这篇文章旨在通过代码的形式让大家更好的理解MVVM模式,相信大多数人读了这篇文章之后再去看其他诸如regular、vue等基于MVVM模式框架的源码,会容易很多。
如果你对MVVM模式已经很熟悉并且也已经研读过并深刻理解了当下主流的前端框架,可以忽略下面的内容。如果你没有一点JavaScript基础,也请先去学习下再来阅读读此文。
引子
来张图来镇压此文:
MVVM
是Model-View-ViewModel
的缩写。简单的讲,它将View
与Model
层分隔开,利用ViewModel
层将Model
层的数据经过一定的处理变成适用于View
层的数据结构并传送到View
层渲染界面,同时View
层的视图更新也会告知ViewModel
层,然后ViewModel
层再更新Model
层的数据。
我们用一段学生信息的代码作为引子,然后一步步再重构成MVVM模式的样子。
编写类似下面结构的学生信息:
Name: Jessica Bre
Height: 1.8m
Weight: 70kg
用常规的js代码是这样的:
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
来创建节点,但是由于列表项都是相似的结构,所以我们没有必要一遍一遍的写。因此,进行如下封装:
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 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
,如果新增一个需求,要求学生的身高的单位可以在m
和cm
之间切换呢?
首先需要一个变量来保存度量单位,因此这里必须用一个新的Model:
const tk = { 'first-name': 'Jessica', 'last-name': 'Bre', 'height': 180, 'weight': 70, } const measurement = 'cm'
为了让tk
更方便的被其他模块重用,这里选择增加一个measurement
数据源,而不是直接修改tk
。
在视图部分要增加一个radio单选表单,用来切换身高单位。