原文:https://addyosmani.com/resources/essentialjsdesignpatterns/book/#detailmvvm
MVVM(Model View ViewModel)是一种基于 MVC 和 MVP 的架构模式,它视图将用户界面(UI)的开发与应用中的业务逻辑行为更清晰地分隔开来。为此,这个模式的很多实现版本都使用声明性的数据绑定来将视图与其他层进行分离。
这使得UI和开发工作可以在同一代码仓库中几乎同时地进行。UI 开发者在他们负责的文档标记(HTML)中加入与视图模型的绑定,模型和视图模型则是由负责应用逻辑的开发者进行维护。
历史
MVVM(命名上)最初是由 Microsoft 为 Windows Presentation Foundation(WPF)和 Sliverlight 定义,它是由 John Grossman 在 2005 年的一篇关于 Avalon(WPF 的代号)的博文中正式的提出。它在 Adobe Flex 社区中也有一定的流行度,作为简单的 MVC 替代方案使用。
在 Microsoft 采用 MVVM 的命名之前,社区中还出现过从 MVP 转变到 MVPM 的行动:Model View PresentationModel。早在 2004 年,Martin Fowler 就写了一篇关于 PresentationModels 的文章,对 PresentationModels 感兴趣的可以阅读这篇文章。PresentationModel 的思想比本文还要长,但由于它突破性的想法,使它还是得到很多关注。
Microsoft 宣布 MVVM 作为 MVPM 的替代方案在 “alt.net” 引起了轩然大波。许多人认为由于该公司在 GUI 届的统治地位使他们有机会接管整个社区,并根据自己的喜好将现有概念进行重新命名,已达到营销的目的。一群变革派人士意识到,尽管 MVVM 和 MVPM 实际想法想法上是相同的,但是做法上仍有所不同。
近年来,MVVM 在 JavaScript 中的架构框架中被实现,如 KnockoutJS, Kendo MVVM 和 Knockback.js ,并得到了社区的总体积极回应。
现在让我们看一下组成 MVVM 的三个部分。
模型
与其他 MV* 家族成员一样,MVVM 中的模型表示特定领域的数据或者是我们应用要使用的那些信息。特定领域数据的一个经典例子可能是用户账号(如姓名、头像和邮件等)或者一个音乐曲目(如标题、年份、专辑等)。
模型存储信息,但通常不处理行为。它们不会去格式化信息或者影响数据在浏览器如何显示,因为这不是它们的职责。相反,格式化数据是由视图来处理,同时那些被认为是业务逻辑的行为应该由另一个与模型进行交互的层来处理 - 视图模型。
这条规则唯一的例外是校验,应该由模型来负责校验那些用于定义或者更新模型中的数据(如输入的某条邮件地址是否满足某条正则的要求?)。
在 KnockoutJS 中,模型完全符合上面的定义,但是它经常会向服务端发起 Ajax 调用,以读写模型的数据。
如果我们要创建一个简单的 Todo 应用,使用 KnockoutJS 的模型表示单个 Todo 项可能会是如下这个样子:
var Todo = function(content, done) {
this.content = ko.observable(content);
this.done = ko.observable(done);
this.editing = ko.observable(false);
};
注意:可能有人已经注意到了在上面的代码片段中我们调用了 KnockoutJS 命名空间 ko
上的 observable()
方法。在 KnockoutJS 中,observable 是特殊的 JavaScript 对象,它们可以将变更通知给订阅者,并自动检查依赖。这就使得我们能够在模型属性值的被修改时同步模型和视图模型的数据。
视图
与 MVC 一样,视图是应用中唯一实际与用户进行交互的部分。它们是一个可互动的界面,用于展示视图模型的状态。也就是说,视图被认为是主动的而不是被动的,在 MVC 和 MVP 中也是如此。在 MVC、MVP 和 MVVM 中,视图也可以是被动的,但是这是什么意思呢?
一个被动的视图只输出一个界面,不接受任何用户的输入。
这样一个视图可能感知不到我们应用中模型的存在,并且可以由 presenter 维护。MVVM 的主动视图包含数据绑定、事件和依赖视图模型的行为。尽管这些行为可以被映射到属性上,但视图仍然是负责处理从视图模型传来的事件。
要明白视图在这里并不负责处理状态 - 它只是负责保持状态与视图模型的同步。
KnockoutJS 的视图只是一个包含与视图模型绑定的简单的 HTML 文档。KnockoutJS 视图展示视图模型中的信息、把命令传递给它(如用户正在点击某个元素)并随着视图模型更新而更新。但是使用视图模型的数据和对应模板生成标签也能达到这样的目的。
举一个简单示例,我们可以看看 JavaScript MVVM 框架 knockoutJS 中是如何定义视图模型和它标签中相关绑定的:
ViewModel:
var aViewModel = {
contactName: ko.observable('John')
};
ko.applyBindings(aViewModel);
View:
<p>
<input id="source" data-bind="value: contactName, valueUpdate: 'keyup'" />
</p>
<div data-bind="visible: contactName().length > 10">
You have a really long name!
</div>
<p>Contact name: <strong data-bind="text: contactName"></strong></p>
我们的文本输入框(source)从 contactName
获取它的初始值,当 contactName 改变时自动的更新这个值。由于数据绑定是双向的,向文本框输入值时 contactName
的值也会随着改变,这样两边的值就是同步的了。
尽管实现是特定于 KnockoutJS,包含 “you have a really long name!” 文本的 <div>
同样包含一个简单的验证(也是数据绑定的形式)。如果输入超过 10 个字符,它才会展示出来,其他情况都是隐藏着的。
来看看更高级的示例,我们可以回到我们的 Todo 应用。改为使用 KnockoutJS View,并加上必要的数据绑定后将是这个样子。
<div id="todoapp">
<header>
<h1>Todos</h1>
<input
id="new-todo"
type="text"
data-bind="value: current, valueUpdate: 'afterkeydown', enterKey: add"
placeholder="What needs to be done?"
/>
</header>
<section id="main" data-bind="block: todos().length">
<input id="toggle-all" type="checkbox" data-bind="checked: allCompleted" />
<label for="toggle-all">Mark all as complete</label>
<ul id="todo-list" data-bind="foreach: todos">
<!-- item -->
<li data-bind="css: { done: done, editing: editing }">
<div class="view" data-bind="event: { dblclick: $root.editItem }">
<input class="toggle" type="checkbox" data-bind="checked: done" />
<label data-bind="text: content"></label>
<a class="destroy" href="#" data-bind="click: $root.remove"></a>
</div>
<input
class="edit"
type="text"
data-bind="value: content, valueUpdate: 'afterkeydown', enterKey: $root.stopEditing, selectAndFocus: editing, event: { blur: $root.stopEditing }"
/>
</li>
</ul>
</section>
</div>
请注意,标签的基本布局相对简单,包含一个用于添加新项的文本输入框( new-todo
)、将 todo 项标记为已完成的切换器和以 li
形式的 Todo 项模板的列表。
上述标签中的数据绑定可以分解成如下:
- 文本输入框
new-todo
有一个current
属性的数据绑定,这个属性存储当前新增项的值。我们的视图模型(很快讲到)观察current
属性的值,并且还有与add
事件的绑定。当键入 enter 键时,add
事件被触发,然后我们的视图模型可以修改current
的值,并相应地把它加到 Todo 清单中。 - 如果点击复选框
toggle-all
的话,会将所有当前的项目标记为已完成。当被点击时,它会触发allCompleted
事件,这个事件将会被我们的视图模型观察到 li
有样式类done
。当一个任务被标记为已完成时,css 类done
相应的会被标记。如果双击这个元素,$root.editItem
回调函数将会执行- 拥有
toggle
样式类的复选框展示done
属性的状态 - label 标签承载 Todo 项的文本内容(
content
) - 还会有一个移除按钮,点击它时会触发
$root.remove
这个回调函数 - 用于编辑的文本输入框同样包含 Todo 项的
content
。enterKey
事件会将editing
属性设置成 true 或者 false
视图模型
视图模型可以被看作是特殊的控制器,它的作用像是一个数据转换器。它将模型的信息转换成视图的信息,并将命令从视图传递给模型。
例如,假设我们有一个包含 unix 格式日期(如 1333832407)属性的模型。我们可以让模型不感知用户界面要展示的日期(如 04/07/2012 @ 5:00pm),在视图中我们会需要将这个属性转换成对应展示的所需要的格式,模型只需简单的存储原始格式的数据。我们的视图包含格式化后的数据,我们的视图模型的作用是两者的中间人。
也就是说,视图模型可能更多的被看作是模型而不是视图,但是它却处理了很多视图的展示逻辑。视图模型还会暴露一些用于管理视图状态的方法,基于视图的动作和视图上触发的事件来更新模型。
总的来说,视图模型是位于我们的 UI 层之后。它暴露视图所需要的数据(数据来自模型)并且可以被视作是我们视图数据和动作的来源。
KnockoutJS 将视图模型解释为展示的数据和可以在界面上执行的操作。它既不是 UI 本身也不是持久化的数据,而是一层用于维护用户正在使用但是又没有被保存的数据。KnockoutJS 的视图模型是通过 JavaScript 对象来实现的,不需要用到 HTML 标签。这种抽象的实现方式可以让它保持简单,这就意味着可以更轻松地在顶层管理更复杂的行为。
使用 knockoutJS 视图模型实现我们的 Todo 应用的代码大概是这样:
// 我们的主视图模型
var ViewModel = function(todos) {
var self = this;
// 遍历传入的 todo,将其转换成 Todo 对象 的 observableArray 形式
self.todos = ko.observableArray(
ko.utils.arrayMap(todos, function(todo) {
return new Todo(todo.content, todo.done);
})
);
// 存储输入的新 todo 的值
self.current = ko.observable();
// 当回车键被输入时,新增一个 todo
self.add = function(data, event) {
var newTodo,
current = self.current().trim();
if (current) {
newTodo = new Todo(current);
self.todos.push(newTodo);
self.current('');
}
};
// 移除单个 todo
self.remove = function(todo) {
self.todos.remove(todo);
};
// 移除所有已完成的 todo
self.removeCompleted = function() {
self.todos.remove(function(todo) {
return todo.done();
});
};
// 一个可编辑的 computed observable 用于处理标记所有完成/未完成项
self.allCompleted = ko.computed({
// 始终根据所有 todo 项的完成标识返回 true/false
read: function() {
return !self.remainingCount();
},
// 将所有的 todo 设置成传入的值(true/false)
write: function(newValue) {
ko.utils.arrayForEach(self.todos(), function(todo) {
// 即使值相同也要设置,否则订阅者将无法被通知
todo.done(newValue);
});
}
});
// 编辑单个项
self.editItem = function(item) {
item.editing(true);
};
..
在上面的代码中,我们只是简单的提供了用于新增、编辑和移除项的方法,以及用于将所有待办项标记为完成的逻辑。 注意:上例视图模型与前例中唯一需要注意的地方是可观察的数组 。在 KnockoutJS 中,如果我们希望监测并响应某个对象的变更时,我们会用 observables
。然而当我们希望监测一组对象的变更时,我们则会使用 observableArray
。一个如何使用可观察数组的示例大概是这样:
// 初始化一个空的数据组
var myObservableArray = ko.observableArray();
// 向数组中添加值,并通知我们的观察者
myObservableArray.push('A new todo item');
注意:如果感兴趣的话可以在 TodoMVC 中查看这个完整的 Knockout.js 的 Todo 应用。
总结:视图和视图模型
视图和视图模型通过数据绑定和事件进行交互。正如我们在最初的视图模型示例中所看到的,视图模型不仅仅是暴露模型的属性,同样还会访问其他方法和特性,如校验。
我们的使用处理它自己的用户界面的事件,根据需要将它们映射到视图模型。模型和视图模型的属性通过双向绑定的方式同步。
触发器(数据触发器)同样还可以允许我们根据模型中属性状态的变更做出反应。
总结:模型和视图模型
尽管在 MVVM 中视图模型是完全响应模型的,但这种关系中仍有一些值得注意的。视图模型可以暴露一个模型或者模型属性用于数据绑定,并包含用于请求数据和维护导出到视图中的属性的接口。
利弊
为了让大家对 MVVM 是什么以及它如何工作有一个更好的理解。现在我们来总结一些使用这个模式的优缺点:
优点
- MVVM 促进了 UI 和业务逻辑并行开发
- 视图的抽象减少它背后所需要的业务逻辑代码(或者胶水代码)数量
- 相较于事件驱动的代码,视图模型更容易单元测试
可以不关心 UI 自动化以及交互对视图模型(它更像模型而不是视图)进行单独测试
缺点
对于简单的 UI 来说,MVVM 过于冗余
- 尽管数据绑定容易声明且易用,但是它会让调试变得困难,它不能像命令式代码那样可以打断点
- 数据绑定会在大型应用中产生大量的 book-keeping。我们也不希望最终遇到绑定比绑定对象更重的情况
- 在大型应用中,设计视图模型以获得必要的概况可能会更加困难
轻数据绑定的 MVVM
对于那些有着 MVC 或者 MVP 背景的 JavaScript 开发者查看 MVVM 并抱怨它真正的关注点分离并不少见。即,视图的 HTML 标签中维护的内联数据绑定的数量。
我必须承认在我第一次看到 MVVM 的实现(如 KnockoutJS、Knockback),我很惊讶居然有开发者想回到将逻辑代码与标签混合在一起的年代,这些都是不好维护的方式。但实际上 MVVM 这样做有很多好处(我们随后将会讲到),包括便于设计者更轻松地将逻辑绑定到他们的标签中。
对于我们当中的纯粹主义者,你会高兴的发现得益于自定义绑定提供者的特性,我们现在极大地减少了对数据绑定的依赖,这个特性是在 KnockoutJS 1.3 版本起提供的。
KnockoutJS 默认有一个数据绑定的提供者,它会查找所有含 data-bind
属性的元素,如下例这样。
<input
id="new-todo"
type="text"
data-bind="value: current, valueUpdate: 'afterkeydown', enterKey: add"
placeholder="What needs to be done?"
/>
当提供者找到一个拥有这个属性的元素,它会解析它,并根据当前的数据上下文将它转变成一个绑定对象。这是 KnockoutJS 一直以来的工作方式,它使我们可以声明式地向元素添加绑定,KnockoutJS 随后将会为它绑定数据。
一旦我们开始构建那些复杂的视图时,我们最终可能会得到大量的元素以及属性,标签中绑定会变得难以维护。但是使用自定义绑定提供者,它就不再是问题了。
一个绑定提供主要关心两个事:
- 当拿到一个 DOM 节点时,它是否包含任何数据绑定?
- 如果是含数据绑定的话,对应的绑定对象在当前数据上下文是什么样?
绑定提供者实现两个函数:
nodeHasBindings
:它接收一个 DOM 节点,这个节点不一定要是一个元素getBindings
:根据当前的数据上下文返回一个表示绑定的对象
一个绑定提供者的骨架大概是下面这个样子:
var ourBindingProvider = {
nodeHasBindings: function(node) {
// 返回 true/false
},
getBindings: function(node, bindingContext) {
// 返回一个绑定对象
}
};
在我们开始研究 provider 之前,先简单的讨论一下 data-bind 属性的逻辑。
如果我们使用 Knockout 的 MVVM 时,你可能会对应用逻辑与你的视图过于耦合感到不满,我们可以改变它。我们可以实现一个有点像 CSS 类一样的东西来通过名字绑定对应元素。Ryan Niemeyer(来自 knockout.net )早先建议使用 data-class
来做这个事情,以避免展示类与数据类被混淆,那么,就先来实现 nodeHasBindings
函数来支持它:
// 是否有元素声明了绑定
function nodeHasBindings(node) {
return node.getAttribute ? node.getAttribute('data-class') : false;
}
接下来,我们需要可用的 getBindings()
函数。既然我们借鉴的是 CSS 类的思想,为什么不考虑支持空格分隔的多个类的形式?这样我们就可以在不同的元素间共享绑定。
首先我们看一下我们的绑定会是什么样子。我们创建一个对象来存储它们,它的属性名必须要与我们 data-class
中键名保持一致。
注意:将 KnockoutJS 应用中的传统数据绑定转换成使用自定义 Provider 的数据绑定并不需要太多工作。我们只需找出所有的数据绑定属性,使用 data-class 属性替换它们,像下面这样将我们的绑定放到一个绑定对象中。
var viewModel = new ViewModel(todos || []),
bindings = {
newTodo: {
value: viewModel.current,
valueUpdate: 'afterkeydown',
enterKey: viewModel.add,
},
taskTooltip: {
visible: viewModel.showTooltip,
},
checkAllContainer: {
visible: viewModel.todos().length,
},
checkAll: {
checked: viewModel.allCompleted,
},
todos: {
foreach: viewModel.todos,
},
todoListItem: function() {
return {
css: {
editing: this.editing,
},
};
},
todoListItemWrapper: function() {
return {
css: {
done: this.done,
},
};
},
todoCheckBox: function() {
return {
checked: this.done,
};
},
todoContent: function() {
return {
text: this.content,
event: {
dblclick: this.edit,
},
};
},
todoDestroy: function() {
return {
click: viewModel.remove,
};
},
todoEdit: function() {
return {
value: this.content,
valueUpdate: 'afterkeydown',
enterKey: this.stopEditing,
event: {
blur: this.stopEditing,
},
};
},
todoCount: {
visible: viewModel.remainingCount,
},
remainingCount: {
text: viewModel.remainingCount,
},
remainingCountWord: function() {
return {
text: viewModel.getLabel(viewModel.remainingCount),
};
},
todoClear: {
visible: viewModel.completedCount,
},
todoClearAll: {
click: viewModel.removeCompleted,
},
completedCount: {
text: viewModel.completedCount,
},
completedCountWord: function() {
return {
text: viewModel.getLabel(viewModel.completedCount),
};
},
todoInstructions: {
visible: viewModel.todos().length,
},
};
// ....
然而上面的片段还是少了两行代码 - 我们仍然需要我们的 getBindings
函数,它会遍历我们 data-class 属性中所有的键,并根据它们构建出结果对象。如果我们检测到绑定对象是一个函数,我们使用当前的数据环境(通过 this
)来调用它。完整的自定义数据绑定 provider 将会是下面这样:
// 现在我们可以创建一个使用非 data-bind 属性的 bindingProvider
ko.customBindingProvider = function(bindingObject) {
this.bindingObject = bindingObject;
// 检测是否有声明了绑定的元素
this.nodeHasBindings = function(node) {
return node.getAttribute ? node.getAttribute('data-class') : false;
};
};
// 根据 node 和 bindingContext 返回对应的绑定
this.getBindings = function(node, bindingContext) {
var result = {},
classes = node.getAttribute('data-class');
if (classes) {
classes = classes.split('');
// 分析每个类,合并到一个对象中返回
for (var i = 0, j = classes.length; i < j; i++) {
var bindingAccessor = this.bindingObject[classes[i]];
if (bindingAccessor) {
var binding =
typeof bindingAccessor === 'function'
? bindingAccessor.call(bindingContext.$data)
: bindingAccessor;
ko.utils.extend(result, binding);
}
}
}
return result;
};
因此,我们 bindings
对象最后几行会是这样:
// 将 ko 当前的 bindingProvider 设置成我们的自定义的绑定 provider
ko.bindingProvider.instance = new ko.customBindingProvider(bindings);
// 绑定新的 ViewModel 的实例到页面
ko.applyBindings(viewModel);
})();
我们这里做的是定义我们绑定处理器的构造器,它接收一个对象(bindings),我们将用这个对象来查找我们的绑定。然后我们可以按照下列方式使用 data-class 重写我们应用视图中的标签:
<div id="create-todo">
<input
id="new-todo"
data-class="newTodo"
placeholder="What needs to be done?"
/>
<span class="ui-tooltip-top" data-class="taskTooltip" style="display: none;"
>Press Enter to save this task</span
>
</div>
<div id="todos">
<div data-class="checkAllContainer">
<input id="check-all" class="check" type="checkbox" data-class="checkAll" />
<label for="check-all">Mark all as complete</label>
</div>
<ul id="todo-list" data-class="todos">
<li data-class="todoListItem">
<div class="todo" data-class="todoListItemWrapper">
<div class="display">
<input class="check" type="checkbox" data-class="todoCheckBox" />
<div
class="todo-content"
data-class="todoContent"
style="cursor: pointer;"
></div>
<span class="todo-destroy" data-class="todoDestroy"></span>
</div>
<div class="edit">
<input class="todo-input" data-class="todoEdit" />
</div>
</div>
</li>
</ul>
</div>
Neil Kerkin 使用上面的代码创建了一个完整的 TodoMVC 示例应用,你可以在这里预览它。
尽管根据上面解释看我们会需要做很多工作,由于我们已经写好了一个非正式的 getBindings
方法,简单地复用它并使用 data-class 而不是严格的数据绑定来编写我们的 KnockoutJS 应用会更简单。最终希望的结果是我们拥有干净标记的视图,数据绑定逻辑被转移到了绑定对象中去了。
MVC Vs. MVP Vs. MVVM
MVP 和 MVVP 都派生自 MVC。它与它的派生之间的最关键的区别在于层之间的依赖性以及绑定程度。
在 MVC中,视图在整个架构的最上层,控制器在它之后,模型在控制器之后。因此我们的视图知道控制器,控制器又知道模型。在这里,我们的视图可以直接访问模型。然而将整个模型暴露给视图可能会有安全和性能问题,具体要看我们应用的复杂程度。MVVM 就是尝试用来解决这些问题。
在 MVP 中,控制器的角色被 Presenter 替代。Presenter 与视图是同一层级,它监听视图和模型的事件,协调它们之间的动作。与 MVVM 不同,它并没有提供视图与视图模型绑定的机制,所以我们就需要为我们每个视图声明相应的接口供 Presenter 与视图进行交互。
因此 MVVM 创建特定与视图的模型数据的子集,它可以包含状态和逻辑信息,避免我们需要将整个模型暴露给视图。与 MVP 的 Presenter 不同,视图模型与视图并不是对应的关系。视图可以绑定到视图模型的属性,这些属性依次将模型中的数据暴露给视图。正如我们前面提到的,视图的抽象就意味着它背后代码的逻辑更少。
然而它的一个缺点是视图与视图模型之间需要一层解析,这会有一定的性能成本。这个解析的复杂程度可能会有很大的差异 - 它可能是简单的复制数据,也可能是像我们视图中看到的维护表单那样复杂。MVC 不会有这个问题,因为它的整个模型都是可访问的,这样的操作可以被避免。
Backbone.js Vs. KnockoutJS
了解 MVC、MVP 和 MVVP 之间细微的差异很重要,但开发者最终还是会问学完这些知识后他们是不是应该选择 KnockoutJS 来替代 Backone。下面将会给出几条建议:
- Backbone has a solid routing solution built-in, whilst KnockoutJS offers no routing options out of the box. One can however easily fill this behavior in if needed using Ben Alman’s BBQ plugin or a standalone routing system like Miller Medeiros’s excellent Crossroads.
- 两个库都是被设计成解决不同的问题,它并不是像选择 MVC 还是 MVVM 这样简单。
- 如果数据绑定和双向交流是你主要的关心点,KnockoutJS 就是你的选择。几乎任何属性或者存储在 DOM 节点上的值都可以通过这个方式映射到 JavaScript 对象上。
- Backbone 与 RESTful 服务很契合,KnockoutJS 的模型只是简单的对象,用于更新模型的代码需要开发者来写。
- KnockoutJS 专注 UI 自动绑定问题,如果使用 Backnbone 来解决这个问题会有大量的冗余代码。这对 Backbone 本省并不是问题,因为它有意的避开 UI。然而 Knockout 却是用于解决这个问题。
- 通过 KnockoutJS,我们可以绑定我们自己的函数到视图模型可观察者上,它将会在可观察者变化时执行。这使我们具有与 backbone 相似的灵活性。
- Backbone 内置了完整的路由方案,然而 KnockoutJS 不提供开箱即用的路由机制。不过,如果有需要的话可以使用 Ben Alman 的 BBQ 插件 或者单独的路由系统,像 Miller Medeiros 的杰作 Crossroads 来做这件达到同样的效果。
总的来说,我个人认为 KnockoutJS 更适用小型应用,而构建大型应用时,Backbone 就可以发光发热了。也就是说,开发者会使用它们去开发不同复杂程度的应用,并且我推荐在决定在你项目中使用其中一种模式前,先进行小规模的实验。
对于想了解更多关于 MVVM 或 Knockout 的,我推荐阅读下列这些文章: