原文:https://addyosmani.com/resources/essentialjsdesignpatterns/book/#detailmvc

MVC 是一种架构设计模式,它鼓励通过职责分离来提升代码的组织性。它通过一个传统的管理逻辑和用户输入的第三方组件(控制器),强制将业务数据(模型)从我们的用户界面(视图)独立开来。这个模式最初是由 Trygve Reenskaug 在他 Smalltalk-80 的工作期间(1979)提出的,它最初被称为模型-视图-控制器-编辑器。MVC 在 1995 年的 “Design Patterns: Elements of Reusable Object-Oriented Software” (GoF) 一书中被进一步的讲述,该书在普及其使用方面做出了贡献。

Smalltalk-80 MVC

了解原始的 MVC 模式是为了解决什么问题很重要,因为自它诞生起,已经发生了巨大的变化。早在70年代,用户图形界面比较少见,一个名为 Separated Presentation 的概念开始用来作为分离领域数据和呈现对象,领域数据是用来为现实世界(例如,照片或者一个人)建模,呈现对象是用于渲染到用户界面。

smalltalk-80 实现的 MVC 进一步推动了这个概念,目的是将应用程序逻辑从用户界面分离出来。它的思路是将应用程序中的这些部分解耦,并能提升模型在应用其他界面中的复用性。关于 Smalltalk-80 的 MVC 架构,还有一些有趣的地方值得关注:

  • 模型表示是特定领域的数据,并且感知不到用户界面(视图和控制器)。当模型变更时,会通知它的观察者。
  • 视图代表的是模型当前时刻的状态。观察者模式用于让视图知道模型何时更新或者被修改。
  • 呈现是由视图负责,但不是仅有一个视图和控制器 - 屏幕显示中的是每个部分或者元素都需要的一对视图-控制器。
  • 控制器在这组合中的角色是处理用户交互(比如键盘输入和点击等动作),并为视图做决策。

当开发者得知观察者模式(现在通常为实现为发布/订阅变体)在数十年前就已经作为 MVC 架构的一部分时,他们有时会感到惊讶。在 Smalltalk-80 的 MVC 中,视图观察模型。正如上面要点所述,任何模型的变更,视图都会做出响应。一个简单的例子是股票数据支持的应用程序 - 为了让应用更有用,任何我们模型上的数据变化都应该要使视图立即更新。

多年来,Martin Fowler 在撰写 MVC 的起源方面做了出色的工作,如你想进一步了解 SmallTalk-80 的 MVC 的历史信息,我推荐你阅读他的作品。

适用 JavaScript 开发者的 MVC

我们已经回顾了70年代的历史,接下来让我们讲讲现在。在现代,MVC 模式已经被应用到了很多种不同的编程语言中,其中就包括了我们最熟悉的 JavaScript。JavaScript 现在已经有一些框架宣称自己支持 MVC(或者它的变体,我们称其为 MV*),框架允许开发者轻松的将结构应用到他们的程序中。

这些框架包括 Backbone、Ember.js 以及 AngularJS 之类。考虑到避免“意大利面”式的代码(一个术语用于描述代码由于缺少合理的结构导致难以阅读和维护)的重要性,现代 JavaScript 开发者必须理解这种模式能提供什么。它使我们能够有效地理解这些框架能使我们做不同的事情。

我们都知道 MVC 是由三个核心的组件组成:

模型

模型为应用管理数据。它们既不关心用户界面,也不关心展示层,而是只关心应用程序可能需要的独特结构的数据。当一个模型发生变化时(比如当它更新时),它通常会通知所有它的观察者(如视图,我们随后将讲到这个概念)有变更发生,以便观察者们能作出相应的响应。

为了进一步了解模型,假设我们有一个 JavaScript 实现的相册应用。在相册中,照片的概念应该拥有一个独立的模型,因为它代表一种特定领域数据。这样的模型通常会包含一些相关的属性,如标题,图片资源和额外的元数据。一个特定的照片会存储在一个模型实例中,模型可能还能复用。下面是我们用 Backbone 实现的简单模型示例。

  1. var Photo = Backbone.Model.extend({
  2. // 照片的默认属性
  3. defaults: {
  4. src: 'placeholder.jpg',
  5. caption: 'A default image',
  6. viewed: false
  7. },
  8. // 保证创建的每一张照片都有 `src`
  9. initialize: function() {
  10. this.set({ src: this.defaults.src });
  11. }
  12. });

模型的内置的能力因框架而异,但是支持属性验证对它们来说是一个通用的能力,其中的属性(attributes)表示的是模型的属性(properties),如模型的标识符。当在真实的应用中使用模型时,我们通常还需要模型 persistence。persistence 允许我们编辑和更新模型,并且知道其最新的状态将存储在:内存、用户的 localstorage 或者是从数据库中同步过来。

另外,一个模型还可能有多个视图在观察它。也就是说,我们的照片模型包含的元数据(如经纬度等位置信息)、好友信息(一个表示谁在照片中的标识符列表)和一系列的标签,开发者可以决定使用视图来展示其中一种的信息。

提供一个表示一组模型集合的概念(如,Backbone 中的 “collections” )并不是现代 MVC/MV* 框架通用的能力。在分组中管理模型可以让我们能基于组中任意模型发生变更时发出的通知来实现应用。这就避免了我们要观察每个模型实例的必要。

使用 Backbone 的 collection 实现的一个简单的模型组如下:

  1. var PhotoGallery = Backbone.Collection.extend({
  2. // 指向这个集合中的模型
  3. model: Photo,
  4. // 过滤出所有查找过的照片
  5. viewed: function() {
  6. return this.filter(function(photo) {
  7. return photo.get('viewed');
  8. });
  9. },
  10. // 过滤出所有没被查看过的照片
  11. unviewed: function() {
  12. return this.without.apply(this, this.viewed());
  13. }
  14. });

早期的关于 MVC 的文献可能还会提到管理应用状态的模型的概念。在 JavaScript 中,状态有很多不同的含义,通常是表示当前的“状态”,如用户界面上某个时间点的视图或者子视图(拥有特定的数据)。状态是当我们讨论单页应用经常提到的一个话题,其中的状态通常需要我们构造。

所以总的来说,模型主要与业务数据有关。

视图

视图是模型可视化的表示,它展示的当前状态的过滤视图。JavaScript 视图是关于构建和维护一个 DOM 元素,然而 Smalltalk 的视图是关于绘制和维护一个位图。

视图通常是观察模型,并且当模型发生变化时会被通知,这样视图就可以相应地更新自己。设计模式文献中经常将视图称作 “哑巴”,因为它们对应用中的模型和控制器的了解很有限。

用户能够与视图进行交互,这就相当于获得读取和编辑(如读、写其中的属性值)模型的能力。由于视图是展示层,我们通常会以用户友好的方式提供编辑和更新的能力。例如,在前面我们讨论过的相册应用中,可以通过比“编辑”视图来简化模型编辑,在该视图中,选择特定照片的用户可以编辑它的元数据。

实际更新模型的任务就落到了控制器上(我们随后将会讲到它)。

让我们通过一个原生 JavaScript 简单实现的视图来进一步了解它。下面我们将看到一个用于创建单个照片视图的函数,会同时使用到模型实例和控制器实例。

我们在我们的视图内定义一个 render() 方法,它将负责使用 JavaScript 模板引擎(Underscore 模板)来渲染 photoModel 的内容,并且根据 photoEl 更新我们的视图的内容。

随后 photoModel 将我们的 render() 方法当做它其中一个订阅者的回调函数,这样通过观察者模式我们就在模型变更时触发视图的更新。

可能有人会好奇用户交互在这里充当的是什么角色。当用户点击视图内任意元素,并不是视图来负责了解接下来要做什么,它是由控制器来决定的。在我们的示例实现中,它是通过为 photoEl 绑定一个事件监听器来实现的,它将委托控制器来处理点击行为,如果有需要的话还会携带模型信息一起传递。

这个体系结构的好处就是每个组件在使应用程序发挥功能时都扮演着各自独立的角色。

  1. var buildPhotoView = function(photoModel, photoController) {
  2. var base = document.createElement('div'),
  3. photoEl = document.createElement('div');
  4. base.appendChild(photoEl);
  5. var render = function() {
  6. // 我们使用 Undercore 之类的模板引擎,
  7. // 它会为我们生成照片条目的 html
  8. photoEl.innerHTML = _.template('#photoTemplate', {
  9. src: photoModel.getSrc()
  10. });
  11. };
  12. photoModel.addSubscriber(render);
  13. photoEl.addEventListener('click', function() {
  14. photoController.handleEvent('click', photoModel);
  15. });
  16. var show = function() {
  17. photoEl.style.display = '';
  18. };
  19. var hide = function() {
  20. photoEl.style.display = 'none';
  21. };
  22. return {
  23. showView: show,
  24. hideView: hide
  25. };
  26. };

模板

在支持 MVC/MV* 的 JavaScript 框架中,我们有必要简单的讨论一下 JavaScript 模板及其与视图的关系,正如我们在上一节简要介绍的那样。

通过字符串在内存中手工创建大量的 HTML 标记块的做法长久以来都被认为(和证实)是一个错误的实践。这样做的开发者成为遍历其数据的牺牲品,要使用嵌套的 div 包裹它,然后使用 document.write 这类过时的技术将“模板”插入到 DOM 中。因为这意味着脚本化的标记要与我们标准的标记保持一致,它很快就会变得难以阅读,更重要的是维护这样的灾难,尤其是在构建非小型应用时。

JavaScript 模板解决方案(如 HandleBar.js 和 Mustache)经常用于将视图定义为含有模板变量的标记(存储在外部或者自定义标签内,如 text/template)。变量可以用变量语法(如 {{name}} )来界定,框架通常足够智能到能接收 JSON 格式的数据(模型实例可以转换成这个格式),这样我需要关心维护干净的模型和模板。

与总体有关的大多数繁重工作都是由框架来完成的。这样做有很多的好处,特别是在选择将模板存储在外部时,因为它使我们能在大型应用开发中,按需加载我们的模板。

下面我将看到两个 HTML 模板的示例。一个是使用流行的 Handlebars.js 实现,另一个是使用 Underscore 的模板。

Handlebars.js:

  1. <li class="photo">
  2. <h2>{{ caption }}</h2>
  3. <img class="source" src="{{ src }}" />
  4. <div class="meta-data">
  5. {{ metadata }}
  6. </div>
  7. </li>

Underscore.js Microtemplates:

  1. <li class="photo">
  2. <h2><%= caption %></h2>
  3. <img class="source" src="<%= src %>" />
  4. <div class="meta-data">
  5. <%= metadata %>
  6. </div>
  7. </li>

需要注意模板本身不是视图。使用 Struts Model 2 架构的开发者可能会觉得模板 视图,但它并不是。视图是一个对象,它观察着模型,并保持它视觉上展示是最新的。模板 可能 是指定视图对象的一部分甚至全部的声明性方式,这样它就可以从模板规范中生成。

另外还要注意的是在经典的 web 开发中,在独立的视图之间跳转需要用到刷新页面。然而在 SPA 应用中,一旦数据通过 Ajax 从服务器中同步过来,它可以简单地在同一页面的新视图中动态呈现,而不需要此类的刷新操作。

导航的功能就落在了“路由”的身上,它来协助管理应用的状态(如,允许用户为他们导航到特定的视图添加书签)。然而,由于路由不是 MVC 或者任何类 MVC 框架的一部分,我不会在本章再深入的讲解它。

总的来说,视图是我们应用数据的一个可视化表示。

控制器

控制器是模型和视图的中间人,它通常是负责用户修改视图时对模型进行更新操作。
在我们的相册应用中,控制器将负责处理用户对指定照片的修改,当用户完成编辑时更新指定的照片模型。

要记住控制器在 MVC 中只充当一个角色:作为视图的策略模式的快捷方式。在策略模式方面,视图根据自己的判断委托给控制器来处理。这就是策略模式的运作方式。当视图认为合适时,它可以委托控制器来处理用户事件。如果视图觉得合适的话,它 可以 委托控制器来处理模型变更的事件,但这通常不是控制器的角色。

然而,大多数 JavaScript MVC 框架被认为与传统 “MVC” 框架不同的地方就在于控制器。它的原因有很多种,但在我看来,它是因为框架的作者最初是参考的服务端 MVC 的解释,意识到并不能在客户端 1:1 的还原将其还原,然后将 MVC 中的 C 解释成其他的会更有意义。然而它的问题是太过于主观,从而导致增加了理解经典 MVC 模式以及控制器在现代框架中的所扮演的角色的复杂度。

例如,让我们简单地回顾一下流行的架构框架 Backbone.js 的架构。Backbone 包含模型和视图(有点类似我们之前讲到的那些),但是它并没有真正的控制器。它的视图和路由的行为与控制器有点类似,但是它俩都不算是控制器。

在这方面,与官方文档或者博客中提到的相反,Backbone 既不是一个真正的 MVC/MVP 也不是一个 MVVM 框架。它最好被认为是以自己的方式实现架构的 MV 中的一员。这当然没有错,但是区分经典 MVC 与 MV 很重要,如果我们应该开始依靠经典文献中关于前者的建议来帮助后者。

其他框架的控制器(Spine.js)vs Backbone.js

Spine.js

现在我们知道控制器通常是负责在视图更新时更新模型。有趣的是,在撰写本文时,最流行的 JavaScript MVC/MV 框架(Backbone)并没 *自己 明确的控制器的概念。

因此对比其他的 MVC 框架来了解他们实现上的差异对我们来说很有帮助,同时还示范了在非传统框架中控制器所承担的角色。为此,让我们看一下 Spine.js 中的控制器示例:

在这个示例中,我们有一个名为 PhotosController 的控制器,它将负责应用中独立的照片。它会保证当视图更新时(如,用户编辑了照片的元数据)对应的模型也会更新数据。

注意:我们不会深入研究 Spine.js,只是简单的看一下它的控制器能做什么:

  1. // Spine 中的控制器是通过继续 Spine.Controller 来实现的
  2. var PhotosController = Spine.Controller.sub({
  3. init: function() {
  4. this.item.bind('update', this.proxy(this.render));
  5. this.item.bind('destroy', this.proxy(this.remove));
  6. },
  7. render: function() {
  8. // 处理模板
  9. this.replace($('#photoTemplate').tmpl(this.item));
  10. return this;
  11. },
  12. remove: function() {
  13. this.el.remove();
  14. this.release();
  15. }
  16. });

在 Spine 中,控制器被看作是应用的胶水,添加和响应 DOM 事件,渲染模板并保证视图和模型是同步的(在我们知道是当做控制器的情况下很有意义)。

上面这个示例中,我们所做的是使用 render()remove() 分别为 updatedestroy 事件设立监听器。当某张照片更新时,我们会重新渲染视图来展示更新后的元数据。与之相似,如果照片从相册中删除,我们也会将它从视图中移除。在 render() 函数中,我们使用 Underscore 的微模板(通过 _.template() )来渲染 ID 为 #photoTemplate 的模板。它只是简单地返回一个编译后的 HTML 字符,用来填充 photoEl 元素。

它给我们带来的是一个轻量的、简单的方式来管理模型和视图间的变化。

Backbone.js

本节的后面,我们将重新讨论 Backbone 和传统 MVC 间的区别,但现在我们先关注控制器。

在 Backbone 中, Backbone.ViewBackbone.Router 共同承担了控制器的职责。不久前,Backbone 再次提出过自己的 Backbone.Controller ,但是由于这个组件的命名在它使用的上下文没有意义,它随后就被改名为 Router。

Router 承担了更多的控制器的职责,因为它能够为模型绑定事件,还可以用于响应 DOM 事件和渲染。正如 Tim Branyen(另一个基于 Bocoup Backbone 贡献者)早先指出的,是可能在不借 Backbone.Router 下实现做到的,所以使用路由范式来实现大概是这样:

  1. var PhotoRouter = Backbone.Router.extend({
  2. routes: { 'photos/:id': 'route' },
  3. route: function(id) {
  4. var item = photoCollection.get(id);
  5. var view = new PhotoView({ model: item });
  6. $('.content').html(view.render().el);
  7. }
  8. });

总的来说,本节的要点就是控制器是应用中管理模型和视图间逻辑和协作。

MVC 能带给我们什么?

MVC 的分离关注点的概念有助于简化程序模块的模块化,并能够:

  • 整体维护更容易。当对应用程序更新时,很清楚这些改动是否是以数据为中心的,meaning changes to models and possibly controllers, or merely visual, meaning changes to views。
  • 解耦模型和视图意味着为业务逻辑编写单元测试更加直观
  • 模型和控制器代码的重复(即我们可能现在一直在使用的代码)将从应用中消失
  • 根据应用的大小和角色的分离,模块化允许负责核心逻辑的开发者和负责用户界面的开发者同时工作

Smalltalk-80 MVC In JavaScript

尽管现代 JavaScript 框架的主要职责是尽可能的让 MVC 范式更好的适应 web 应用开发中的差异,但仍有一个框架试图遵守 Smalltalk-80 中原始形式。Peter Michaux 开发的 Maria.js 是原版 MVC 的精确还原,Models 是模型、Views 是视图、Controller 只是控制器。尽管一些开发者认为 MV* 框架应该用来解决更多的问题,但如果你想要实现一个原始版的 MVC,它一个很有用的参考。

深入探索

本书到此为止,我们对 MVC 模式的能力应该有了基本的了解,但还有一些有趣的信息值得我们关注。

GoF 并不把 MVC 当作一种设计模式,而是把它看作是 用于构建用户界面的一组类 。他们认为,它实际上是三个经典设计模式的变体:观察者模式、策略模式、复合模式。根据 MVC 在框架中具体的实现方式,它还可以会使用工厂模式和模板模式。GoF 提到的这些模式在与 MVC 一起使用时是很有用的附加功能。

正如我们讨论过的,模型表示应用的数据,而视图则是用户展示在屏幕上的内容。因此,MVC 一些核心通信依赖观察者模式(令人惊讶的是,很多关于 MVC 模式的文章都没有讲到这点)。当模型变更时,它会通知它的观察者(视图)有内容已经更新了 - 这可能是 MVC 中最重要的关系。这种关系的观察者性质也有助于将多个视图附加到同一个模型上。

对于想了解更多 MVC 解耦特性的开发者(再次重申一遍,取决于具体的实现),该模式的目标之一就是帮助在主题(数据对象)和它的观察者之间建立一对多的关系。当话题改变,它的观察者就要更新。视图和控制器之间的关系略有不同。控制器帮助视图响应不同的用户输入,并且是策略模式的一个示例。

总结

回顾了经典的 MVC 模式,我们现在应该明白了它是如何让我们能够清晰的分离应用中的关注点。我们现在也应该意识到 JavaScript MVC 框架在解释 MVC 模式上的差异,尽管它们很容易变化,但它们仍然与原始模式有着一些共同的理念。

当了解一个新的 JavaScript MVC/MV* 框架,要记住 - 回头回顾一下它是如何选择架构的(特别是它是如何实现模型、视图、控制器或者其他方案的),因为这有助于我们更好地了解框架的的预期用途。