从 MVC 在前端开发中的局限性谈起

阅读数:8370 2014 年 1 月 20 日

话题:语言 & 开发架构前端

有名无实的 Router

ASP.NET MVC

如果你还没有接触过后端的 MVC 框架的话,不妨先看看下面这段 ASP.NET MVC 代码并且了解一下后端 MVC 的工作原理。它摘自 ASP.NET MVC 教程中非常著名的项目MVC Music Store一段 Controller 组件代码:

public class StoreManagerController : Controller
{
    private MusicStoreEntities db = new MusicStoreEntities();
    // GET: /StoreManager/
    public ViewResult Index()
    {
        var albums = db.Albums.Include(a => a.Genre).Include(a => a.Artist);
        return View(albums.ToList());
    }

    // GET: /StoreManager/Details/5

    public ViewResult Details(int id)
    {
        Album album = db.Albums.Find(id);
        return View(album);
    }
}

我们知道 Controller 的职责之一是负责响应用户在视图上的行为,而具体每个行为应如何进行响应,需要落实到 Controller 具体的方法上,这个方法我们可以称之为 action。上面代码中的两个公开方法Index()Details()就是两个 action。它们都属于StoreManager这个 Controller。如果你有使用过前端的 Ember.js 的话,应该对这两个概念非常熟悉。

但问题来了,如何将用户在视图上的行为,与响应行为的方法 action 关联起来?甚至与 Controller 关联起来? URL 便是方法之一。上面代码每个 action 上的注释便代表这个这个 action 对应的 URL。也就是说,当用户点击该 URL 时,框架中的 Router 服务便能通过 URL 解析出应该调用哪个 Controller 及该 Controller 下的哪一个 action 进行响应,以上面的例子为例,可以知道 URL 的规则为{controller}/{action}/{id}

那么响应的结果应该是什么呢?从上面的代码ViewResultreturn View()两处可以看出,两个 action 返回的都是新的视图。

举一个最熟悉的现有 MVC 站点的例子便是github。你会发现你在 github 网站上的每一处点击都有唯一的 URL 对应,每一次交互的结果都是服务器返回新的页面。它使用 javascript 非常少,比如当你选择编辑时,它也会跳转到一个新的页面,而非在当前页弹出一个编辑框。

为什么首先要聊这么多的服务器端 MVC 框架的特性。因为接下来回过头来看前端的 MVC 框架时,你会发现有非常多的差异之处。

Javascript MVC

从上面可以看出,服务器端的 MVC 框架服务的是整个站点,它依靠不断的返回页面来响应用户请求,因此 Router 服务至关重要。而使用 MVC 框架的前端页面,大多数是 Single Page Application,甚至还不如单页面,只是页面上的某一个组件,比如一个 Slide。因此将用户的行为转化为 URL 是不现实。你或许会说的确无法生产新的页面,那么降低页面粒度如何呢?也就是说在服务器端一个 URL 映射的是一个页面,那么我们将 URL 映射为页面的某个区域或者功能呢?

比如以下面这段 Backbone.js 的 TodoList 应用 Router 为例:

var TodoRouter = Backbone.Router.extend({
    routes: {
        'todo/add': 'add', // 新增项 
        'todo/edit/:id': 'edit', // 编辑项 
        'todo/remove/:id': 'remove', // 删除项 

        'filter/completed': 'filterCompleted', // 过滤出已完成 
        'filter/uncompleted': 'filterUncompleted' // 过滤出未完成 
    }
    // Todo
});

如果依照这样 Route 规划,我们希望当用户输入http://example.com#todo/add时,我们弹出的是一个新增输入框;而当用户输入http://example.com#todo/edit/123456页面出现编辑 id 为 123456 的这条记事的编辑框。这样我们便将 URL 映射的页面粒度降低为输入框粒度。

但是这样会引起另一个问题,注意上面 route 的差别:todo/域名下操作的是单条的记录,而filter/域名下操作的是对列表进行筛选。所以还不得不考虑一种情况,如果用户想在筛选的情况下可否对每一项进行操作?如果允许的话,参考排列组合,route 是否需要新增为 2 x 3 = 6 项?如新增http://example.com#filter/completed/todo/add这样的路由。

这样的设定明显是不合理的。之所以会产生这样的问题是因为对后端而言 URL 与页面是一一对应的关系。而如果降低页面粒度的话,无法将页面功能与 URL 对应起来,或者说如果想让 URl 覆盖单一页面上的所有功能的成本太高了。

前端的解决之道

当然 Route 在前端 MVC 框架中并非武功尽废,我们仍然可以保留这样的机制,但是仅用于高粒度的操作,比如上面例子中的筛选功能。

其实 Route 仅仅是桥梁,将用户的行为与响应用户行为的方法联系起来。Route 机制已经在前端行不通了,问题便是寻求另一种联系的机制。

Observer Pattern + MV

Google 程序员Addy Osmani(同时也是Learning JavaScript Design Patterns的作者) 有一个非常著名的项目——Todomvc。他用所有的前端 MVC 框架将 Todo List 这个 app 重写了一遍。我们看看他的解决这样的问题的。

Backbone

以 Backbone.js 为例,为了方便说明,在他的基础上我再次进行了简化,简化到只有 50 行代码,只保留了新增和删除方法。实际演示效果在这里

var Todo = Backbone.Model.extend();

var Todos = Backbone.Collection.extend({
    model: Todo
});

var todos = new Todos();

var ItemView = Backbone.View.extend({
    tagName: "li",
    template: _.template($("#item-template").html()),
    render: function () {
        this.$el.html(this.template(this.model.toJSON()));
        return this;
    },
    initialize: function () {
        this.listenTo(this.model, 'remove', this.remove);
    },
    events: {
        "click .delete": "clear"
    },
    clear: function () {
        todos.remove(this.model);
    }
});

var AppView = Backbone.View.extend({
    el: $("body"),
    initialize: function () {
        this.listenTo(todos, 'add', this.addOne);
    },  
    addOne: function(todo) {
        var view = new ItemView({
            model: todo
        });
        this.$("#list").append(view.render().el);
    },    
    events: {
        "click #create": "create"
    },
    create: function () {
        var model = new Todo({
            title: this.$("#input").val()
        });
        todos.add(model);
    }
})

var app = new AppView();

这个应用有两个视图,全局视图`AppView`和单个项目视图`ItemView`,还有一个相当于 Model 层的 Collection`todos`。它没有显式的将 Controller 层独立出来。你可能会说 Backbone 不像 Angular 和 Ember,它本就没有 Controller 的设定,我不这么认为,Backbone 里 Router 就是 Controller 与 Router 的结合体。比如它可以加入一个 Router 这么干:

var TodoRouter = Backbone.Router.extend({
    routes: {
        'todo/add': 'add'
    },
    add: function () {
        // do something
    }
});

翻译过来是:#todo/add对应的 action 是add——如果放在服务端的话这不就是把用户的行为 (URL) 与 action 联系起来了吗。

回到这个应用,它没有将 Controller 显式的独立出来并不代表 Controller 不存在。我们需要的不是一个叫做 Controller 的东西,而是一些能够响应用户行为的方法。那么它把 Controller 放在哪了?它使用了的是最原始的解决办法,放在用户行为事件的回调函数中,比如这段代码:

events: {
    "click #create": "create"
},
create: function () {
    var model = new Todo({
        title: this.$("#input").val()
    });
    todos.add(model);
}

虽然这种方法比较原始,但不应该认为它是一种低级的解决方案。当然接下来我们会在 Emberjs 和 Knockoutjs 看到一些其他的解决方法,从职责上来说,他们的目的是一致的。

回想在文章开头介绍的 ASP.NET MVC 的一个 action,它做了哪些事情:

public ViewResult Details(int id)
    {   
        // 执行了 Model 层的查找 (query) 操作 
        Album album = db.Albums.Find(id);
        // 将查找返回的结果传递给视图 (View)
        return View(album);
    }

用户的操作会涉及数据和视图更改,一般来说视图会被动一些,Model 的修改可以通知视图的更新,当然视图也可以向 Model 请求数据(注意从这里可以看出 Model 与 View 可以直接通信而非通过 Controller)。上面的代码很好的说明了这一点,并且视图的更新和数据的操作都是在同一个 action 里完成。

但是前端 MVC 框架却不这么做。

因为在前后端代码与视图的关系是不同的,后端代码生产出页面交给浏览器之后,基本上就不关心也无法关心页面了;而在前端不同,虽然前端也有“生产”这么一说(比如说使用模板引擎),但前端代码时刻都可以对 Html 代码和 DOM 元素进行监听和操作。

上面的 Todo 里 Backbone 是这么做的:

var AppView = Backbone.View.extend({
    el: $("body"),
    initialize: function () {
        // 在初始化该视图时 (执行当前 initialize 函数),
        // 它会对这个应用唯一的 Model 层,就是 Collection`todos`进行监听;

        // 更准确来说是对它的新增操作进行监听 
        // 一旦发现数据有新增操作,那么更新视图 (调用 addOne 方法)
        this.listenTo(todos, 'add', this.addOne);
    },  
    addOne: function(todo) {
        var view = new ItemView({
            model: todo
        });
        this.$("#list").append(view.render().el);
    }

这个时候你再看看这个 AppView 的代码会觉得有一些绕,因为我们明明可以在 create 方法中,在新增数据之后立即调用更新视图的方法:

create: function () {
    var model = new Todo({
        title: this.$("#input").val()
    });
    todos.add(model);
    // 更新视图 
    // this.addOne(model)
}

但我想这么做的原因无非是为了解耦。考虑今后可能增加多个新增数据的入口 (如 create1, create2, create3),在每一个新增方法中调用更新视图的方法会增加代码的维护量,那么就不如采用这种观察者模式 (Observer pattern)(或者采用 fire 事件机制也行),只在指定处更新视图。

ItemView 采用的也是同样机制,就不在这里赘述了。

值得一提的是,todomvc Backbone 的原版本中仍然保留了 Router 服务:

var TodoRouter = Backbone.Router.extend({
    routes: {
        '*filter': 'setFilter'
    },

    setFilter: function (param) {
        // Set the current filter to be used
        app.TodoFilter = param || '';

        // Trigger a collection filter event, causing hiding/unhiding
        // of Todo view items
        app.todos.trigger('filter');
    }
});

如果有兴趣浏览一下原版代码,你会发现这只是作为更新视图的触发器而已。

Ember.js

不仅仅 Backbone 采用这样的机制,我们也可以看看 todomvc 中简化过后的 Emberjs 代码,采用的也是同样机制,线上DEMO

Html:

<script type="text/x-handlebars" data-template-name="index">
{{ input type="text" value=title }}
<button {{action "create"}}>Add</button>
<ul>
{{#each item in content itemController="todo"}}
    <li>
        <span>{{item.title}}</span>
        <button {{action "remove"}}>delete</button>
    </li>
{{/each}}
</ul>
</script>

Javascript:

App = Ember.Application.create({});

App.ApplicationAdapter = DS.LSAdapter.extend();

App.Todo = DS.Model.extend({
    title: DS.attr("string")
})

App.IndexRoute = Ember.Route.extend({
    setupController: function(controller) {
        var todos = this.store.find('todo');
        controller.set("content", todos);
    }
});

App.TodoController = Ember.ObjectController.extend({
    actions: {
        remove: function () {
            var todo = this.get('model');
            todo.deleteRecord();
            todo.save();
        }
    }
})

App.IndexController = Ember.ArrayController.extend({
    actions: {
        create: function () {
            var title = this.get("title");
            var todo = this.store.createRecord('todo', {
                title: title
            });
            todo.save();
        }
    }
});

这一段代码不做详解,与前一个 Backbone 的机制是一致的,需要注意的是:

  1. 如果你觉得 Backbone 中将用户行为与方法联系起来的做法比较原始的话,不妨看看 Emberjs 的解决方法,它直接写进需要编译的模板中:

    // 指定调用 IndexController 中的 create 的方法 (action) 响应按钮的点击事件 <button {{action "create"}}>Add ......

    // 指定 TodoController 中的 remove 的方法 (action) 响应 <button {{action "remove"}}>delete

  2. 在新增事项的 create 方法中,它做的也仅仅是对 Model 层进行数据操作,
    create: function () {
        var title = this.get("title");
        var todo = this.store.createRecord('todo', {
            title: title
        });
        todo.save();
    }

    但你不用调用任何的视图方法,视图已经随数据更新了。这不也是一种 Observer 模式嘛。

  3. 值得一提的是 Ember.js 中的 Router 有一些特别,除了一般路由拥有的定义 URL 规则之外,它还负责向对应的 Controller 提供 model 层数据。

MVP(Passive View)

Addy 的解决方案是 Observer Pattern。但我更提倡另一种解决方案,MVP(Model-View-Presenter) 模式中的 Passive View 模式。

为什么会有 MVC,MVP,甚至 MVVM?三个模式其实主要围绕的是两个问题:

  1. 一是 Model 与 View 之间的通信问题, 完全隔离的? 单向通信还是双向通信?
  2. 二是 M-V-XX 中的"XX"需要完成哪些功能, 简单流程调度? 还是复杂规则处理?

MVP 模式将用户的交互逻辑的处理流程定义在 Presenter 层中,但是具体的实现并不是完全在 Presenter 中。View 和 Presenter 采用单向的沟通方式。View 单纯地将用户的交互请求汇报给 Presenter;Presenter 接收到请求之后,整合相应的资源、执行相应的处理逻辑。对处理流程的某一个步骤,如果设置到业务逻辑和数据模型,则调用 Model,如果涉及到对视图的更新,还会调用 View。Presenter 和 View 接口都应该只包含返回类型为 void 的方法即可

同时参考 Kjell-Sverre Jerijærvi 提出的 MVP 的设计原则Design Rules for Model-View-Presenter,可以找到 MVP 模式中一些非常重要的特征,例如以下几条:

  • View 不允许通过 Presenter 直接调用 Model 和 Service,并且 Presenter 的方法应该是不具有返回值的;

  • Presenter 必须通过 View 接口的方式调用 View

  • 除了对 View 接口成员的实现外,View 中的其他方法不应该是 public 的;

  • View 接口的成员应该仅限于方法,不应该包含属性;

  • 所有的数据应用保持在 Model 中

既然 Presenter 对于 View 是相对透明的,View 不能直接对 Presenter 进行操作(目的是实现 Presenter 和 View 之间的分离)。那么如何实现 View 与 Presenter 层之间的通信呢——通过注册事件,在 Presenter 上注册 View 的事件,并且这样以来数据的传递就不是 View 向 Presenter 去“拉 (pull)”,而是 Presenter“推 (push)”给 View 层。

所以我们的目标非常的简单,分别定义三个层:

  1. Model 层:提供操作数据的接口

  2. View 层:提供更新视图的接口,当用户有行为发生时触发事件

  3. Presenter 层:定义用户的交互逻辑与流程,但所有与数据操作和视图的更新都通过调用 Model 层与 View 层的接口实现,注册 View 层事件。

实现代码如下:

var Todo = Backbone.Model.extend({
});

var Todos = Backbone.Collection.extend({
    model: Todo
});

var ItemView = Backbone.View.extend({
    tagName: "li",
    template: _.template($("#item-template").html()),
    render: function (container) {
        this.$el.html(this.template(this.model.toJSON()));
        container.append(this.$el);
    },
    events: {
        "click .delete": "_clear"
    },
    _clear: function () {
        this.$el.trigger("delete", this);
    }
});

var AppView = Backbone.View.extend({
    el: $("body"),
    events: {
        "click #create": "_create"
    },
    _create: function () {
        var data = {
            title: this.$("#input").val()
        };

        this.$el.trigger("create", data);
    }   
})

var Presenter = Backbone.View.extend({
    el: $("body"),
    initialize: function () {

        this.appView = new AppView({
            container: $("#list")
        });
        this.todos = new Todos;
        var _this = this;

        this.$el.on("create", function (e, attrs) {
            var model = new Todo(attrs);
            _this.todos.add(model);
            var itemView = new ItemView({
                model: model
            })
            itemView.render($("#list"));
        });

        this.$el.on("delete", function (e, view) {
            _this.todos.remove(view.model);
            view.remove();
        });        
    }
})

var p = new Presenter;

[DEMO](http://jsfiddle.net/JPL94/8/)

让我们来看看它是否复合我们的要求:

  • AppView 不提供任何接口 (所有的私有方法都以 _ 开始),只负责捕捉用户的行为。一旦用户点击新增,即向外广播“新增”事件.trigger("create", data)

  • ItemView 提供一个插入新视图的接口render与移除视图接口remove, 并且捕捉用户的删除行为,广播“删除”事件

  • Presenter 层捕获到事件后,执行一系列的流程,但皆以调用 Model 层与 View 层接口的方式执行,而非直接操纵实例属性。

上面的两个例子我们可以看到,介于前端的特殊性,在前端 MVC 框架中 View 层与 Controller 层中的 action 联系较为密切,例如把 action 注册到视图元素的事件回调中。虽然这样能够代替服务器端的路由方案,但代价是牺牲了不同层之间的低耦合性,并且不易测试。

MVP 在 MVC 的基础上,进一步进行了解耦,把视图和数据抽象仅剩下接口,并且把业务逻辑全部归纳到 Presenter 层中。这样不仅能够把 View 单独拎出测试,还能提高这一套 MVP 组件的可复用性。如果我们想更换 View 层或者 Model 层的话,只要保证新层具有与原层相同的接口,而不用涉及到其他层的修改。

MVVM

最后一个解决方案是 MVVM(Model-View-ViewModel),以 Knockout.js 为例,完成相同功能只需要非常少的代码:

Html:

<input type="text" data-bind="value: title">
<button data-bind="click: create">Add</button>
<ul data-bind="foreach: todos">
    <li>
        <span data-bind="text: title"> </span>
        <button data-bind="click: $root.remove">delete</button>
    </li>
</ul>

Javascript:

function Todo (title) {
    this.title = title;
}

var ViewModel = function() {
    
    var _this = this;
    this.todos = ko.observableArray([new Todo("test1"), new Todo("test2")]);
    this.title = ko.observable();

    this.create = function () {
        var title = this.title().trim();
        if (title) {
            _this.todos.push(new Todo(title));
        }
    }

    this.remove = function (todo) {
        _this.todos.remove(todo);
    } 
};
 
ko.applyBindings(new ViewModel());

[DEMO](http://jsfiddle.net/mfUVk/)

在 MVP 与 MVC 模式中,用于渲染视图的数据往往是 action 处理 Model 层返回的数据之后的结果。而 MVVM 中的 ViewModel 则是视图所需要渲染数据的直接映射,无需再经过其他服务的处理。

我不确定 MVVM 在 WPF 中 (MVVM 起源于微软 Windows Presentation Foundation 框架) 是如何处理 ViewModel 层方法与数据的关系。但是从上面 Knockoutjs 的代码可以看出,ViewModel 的代码是比较混乱,方法和属性都书写在一起。其实从 html 的绑定方式也可以看出,无论是数据还是 action,都一视同仁的使用data-bind属性。

虽然 Knockout 的代码写起来很舒服 (我们近乎只用关心 Model 层的数据操作),但这样的代码无疑对代码的复用和维护提出了挑战。仅作参考,不作推荐。

参考文献:


感谢崔康对本文的审校。

给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ)或者腾讯微博(@InfoQ)关注我们,并与我们的编辑和其他读者朋友交流。