前端静径

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

译者注

无意中看到了此文,感觉还挺不错的,英文能力有限,有不对的地方,欢迎指出。原文链接在此。下面是翻译:


前言

最近,我在知乎上看到了几篇有关MVVM框架的有趣的提问。近年来由于AngularReact框架的越来越流行,MVVM框架也被推到了风口浪尖上。

在这篇文章和接下来的几篇文章中,我将要解释MVVM是怎么工作的,并且我会写一个小型MVVM框架。

第一篇文章会讲解MVVM的总体介绍和它尝试解决的问题。

如果你是一个老手,可以跳过这篇文章。

MVVM是什么,为什么它是与众不同的

MVVMMVC的变种,如果你已经掌握了MVC,那么MVVM就不难理解了。 你可以简单的理解为:

MVVM是适配器模式的MVC

所有M-V-*模式都有一个相同的目的:为组织数据驱动UI协作提供更好的便于理解的模式。他们之间的区别在于他们如何拆分代码。

(注意:是个开发者对MVC可能会有12种不同的理解,下面的阐述完全是我的个人观点)

Model-View-Controller(简称MVC) 和它的潜在问题

MVC希望将视图与数据分隔开,用一个控制器(controller)来管理用户输入。下图是它的运行机制:

alt MVC示意图

(原图来自于维基百科)

那么问题来了:

在视图层和数据层你都要进行适配(除非你正在创建一个视觉系统),但是无论在哪个层适配都似乎不太理想。数据层负责业务逻辑(是真实世界的数据模型),而视图层专注于布局,因此视图里面的数据结构与其布局紧密地联系在了一起。

如果你不是很明白上面说的话,可以来看下面的一个例子。

现有一个存储有学生信息的数据集,存储为JSON格式,类似于:

1
2
3
4
5
6
7
{
"first-name": "Tracy",
"last-name": "Kennedy",
"grade": 6,
"height": 150,
"weight": 40
}

我们将它放在视图层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<ul>
<li>
<span>Name:</span>
<span>Tracy Kennedy</span>
</li>
<li>
<span>Grade:</span>
<span>6</span>
</li>
<li>
<span>Height:</span>
<span>1.5m</span>
</li>
<li>
<span>Weight:</span>
<span>40kg</span>
</li>
</ul>

这里就有几个问题:

  1. 在数据结构里面没有name字段,因此,我们怎么计算得到它呢?
  2. 数据结构里面的height字段是以厘米作为单位的,我们要在哪里把它转换成米呢?
  3. 最让人头疼的事:既然数据结构是一个对象,谁将负责将其转换为一个数组?

项目的任何一部分解决了上述的问题都将大大减少复用性和可维护性。决定使用继承来制作项目专用的模型或视图很容易。但是,假设你有几十个列表视图,你可能需要记录继承树,那将是真正的世界恶梦。

大型视图控制器(Massive View-Controller

你可能注意到我特意忽略了controller层。事实上,在传统的MVC框架中,一个controller并非是一个‘层’,它经常和视图层一起发送。因此,viewcontroller组成了用户交互的视图控制器层View-Controller)。

但是一个控制器和视图是不一样的:它生于污染。我的意思是,一个控制器负责做一些适配或者对原始数据的翻译(我们使用控制器来翻译用户输入活数据变化)。它与视图和数据紧密联系并且很难复用。

将脏工作分配给一个控制器听上去不错。因此我们稍微修改下MVC模式:

alt MVC模式改版

完美!我们现在将所有的脏工作交给了一个地方。

但是,你知道我会说‘但是’,是不是?

现在你又有了新的问题:你可能会在单个类中打包数千行代码,从而让代码难以阅读和维护。因此,最后大家都将这个名词改为Massive View-Controller,因为这个巨大的控制器真的是很长很复杂。

我不是批评从模型和视图隔离适配器逻辑的尝试。我在说,这是一个很好的尝试,但可以更好。

视图层(ViewModel)作为适配器

从某种程度上讲,对于软件行业,小是最好的。人们经常提及更小的类,更小的函数,更小的组件…因为小经常和简单相挂钩,而大经常是复杂的标志。

所以避免大型控制器带给我们复杂性的最简单的方法就是把它分解成小部分。

一个大型控制器主要有三个逻辑类型:

  1. UI效果,例如页面跳转,页面滚动
  2. UI更新,即数据变化的结果
  3. 指令,即用户操作的结果

UI效果显然不同于其他两个,它应该与视图层一起运行。

那么UI更新呢?它应该属于数据层和视图层的中间层

现在我们把一个控制器至少分成了两个独立的部分。那么指令呢?它们应该去哪里?

在此时此刻,这还是比较难以决定的,我们将先把这个放一边。

先来看几个例子:

继续上面的学生信息页,我们将借助Vue.js中的模板语法:

1
2
3
4
5
6
<ul>
<li v-for="item in items">
<span>{{item.description}}</span>
<span>{{item.content}}</span>
</li>
</ul>

上述的代码的意思是我们要循环items数组,对于数组中的每个元素item,我们将插入一个<li>标签并且在标签内填充<span>元素,元素的内容是item的属性。

因此,我们可能需要一个转移函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function items(student){
function item(description, content){
return {
description: description,
content: content
}
}
let result = []
result.push(
item('Name', `${student['first-name']} ${student['last-name']}`))
result.push(item('Grade', student['grade']))
result.push(item('Height', `${student['height'] * 0.01}m`))
result.push(item('Weight', `${student['weight']}kg`))
}

函数items不是可复用的,但是我们释放了我们的列表视图,并且现在已经脱离的业务逻辑。

如果你能裂解上面的例子,你就了解了M-V-V-M是怎么运作的了。

alt MVVM模式示意图

在我们的例子中,数据函数是一个View-Model。它将数据层的数据转换成了视图层能够接受的结构。

接下来,我们看我们刚刚跳过的话题:谁来处理指令?

为了说明这一点,我们将添加一个按钮来告诉系统一个学生信息是否有效:

1
2
3
4
5
6
7
8
<ul>
<li v-for="item in items">
<span>{{item.description}}</span>
<span>{{item.content}}</span>
</li>
</ul>
<button onclick="{{confirm}}">confirm</button>
<button onclick="{{reject}}">reject</button>

谁应该执行confirmreject函数?

视图层引用了它们,但是视图应该不涉及到业务逻辑。我认为我们应该毫不犹豫地将他们放进View-Model层,它作为一个适配器来执行,所以为何不将它设计成双向适配器呢?

因此,M-V-V-M模式类似于下面:

alt MVVM模式改进

注意:有些M-V-V-M框架提供了双向数据绑定,我认为用双向数据绑定还是单向数据绑定都不是问题。最重要的事情是,视图通过一个适配层与数据交互。至于如何去绑定视图和视图数据,都是你个人的问题。对于我来说,单向数据绑定伴随指令是我最喜欢的。