【Architectural Pattern】前端框架中的 MVC、MVP 和 MVVM

Posted by 西维蜀黍 on 2019-11-10, Last Modified on 2022-12-10

1 原始的MVC (Model - View - Controller)

MVC (Model - View - Controller)架构是一种当前非常流行的架构模式(Architectural Pattern),被广泛使用于传统的桌面GUI(Graphical user interface)、Web应用和手机移动应用中。

  • 1979年,MVC的概念被 Trygve Reenskaug 在 Smalltalk提出。这种架构模式,极大地降低了GUI应用程序的管理难度,而后被大量用于构建桌面和服务器端应用程序;
  • 从1996年后,MVC逐渐被广泛应用;
  • 2002年,基于Java的Web应用框架Spring引入了MVC的概念;
  • 2005年,基于PythonDjango和基于RubyRails也引入了MVC的概念。

在最初以Smalltalk实现中MVC概念中,MVC是由组合模式(Composite Pattern)、策略模式(Strategy Pattern)和观察者模式(Observer Pattern)三种设计模式组合而成的。

如图,实线代表方法调用,虚线代表事件通知(Event notification),通过观察者模式实现。

在前端框架的 MVC 中, View 代表UI界面(或者说 DOM),它关注于数据的显示和与用户的交互,Controller 中包含了前端页面的业务逻辑,而 Model中通常会包含 call backend APIs 的逻辑。

用户的输入操作本质上是传给了 View 层,而 View 会将这个输入操作传递给 Controller。此后可能会有两种情况:

  • Controller 能自己处理,而不需要调用 Model 时,此时,Controller 会直接调用 View 的一个特定的函数。比如,这个用户的操作只需要触发网页上一个button 的隐藏(可以通过修改特定 DOM 元素的 style 来实现隐藏)。
  • Controller 不能自己处理,此时,就需要调用 Model。在 Model 中,通常会调用backend APIs。比如,显示一个订单的详细内容。

Demo

这里有一个可以对数值进行加减操作的组件:上面显示数值,两个按钮可以对数值进行加减操作(这个操作需要实时地更新到 backend 的数据库中),操作后的数值会更新显示。

Model

Model层用来存储业务的数据,一旦数据发生变化,Model将会通过观察者模式,通知有关的View。

myapp.Model = function() {
    var val = 0;

    this.add = function(v) {
        // 这里省略了调用后端 API 以将这个值更新到 DB 中的逻辑
        if (val < 100) val += v;
    };

    this.sub = function(v) {
        // 这里省略了调用后端 API 以将这个值更新到 DB 中的逻辑
        if (val > 0) val -= v;
    };

    this.getVal = function() {
        // 这里省略了调用后端 API 以读取这个值的逻辑
        return val;
    };

    * 观察者模式 *
    var self = this, 
        views = [];

    this.register = function(view) {
        views.push(view);
    };

    this.notify = function() {
        for(var i = 0; i < views.length; i++) {
            views[i].render(self);
        }
    };
};

Model和View之间使用了观察者模式,因此View事先需要在此Model上注册,以观察Model。最终,当在 Model 中发生事件(数据变化)时,View 能得到通知。

View

View 和 Controller 之间使用了策略模式,这里View引入了Controller的实例来实现特定的响应策略,比如这个栗子中按钮的 click 事件:

myapp.View = function(controller) {
    var $num = $('#num'),
        $incBtn = $('#increase'),
        $decBtn = $('#decrease');
		
    // 更新数据到页面中
    this.render = function(model) {
        $num.text(model.getVal() + 'rmb');
    };

    /*  绑定事件  */
    $incBtn.click(controller.increase);
    $decBtn.click(controller.decrease);
};

如果要实现不同的响应的策略只要用不同的Controller实例替换即可。

你会发现,用户的输入操作本质上是传给了View 层,只是 View 将这个操作直接传给了Controller。

Controller

Controller 是 Model 和 View 之间的纽带,MVC将响应机制封装在controller对象中,当用户和你的应用产生交互时,控制器中的事件触发器就开始工作了。

myapp.Controller = function() {
    var model = null,
        view = null;

    this.init = function() {
        /* 初始化Model和View */
        model = new myapp.Model();
        view = new myapp.View(this);

        /* View向Model注册,当Model更新就会去通知View啦 */
        model.register(view);
        model.notify();
    };

    /* 调用Model以更新数值,且 Model 通知 View 以更新数据 */
    this.increase = function() {
        model.add(1);
        model.notify();
    };

    this.decrease = function() {
        model.sub(1);
        model.notify();
    };
};

启动 MVC

这里我们实例化View并向对应的Model实例注册,当Model发生变化时就去通知View做更新,这里用到了观察者模式。

当我们执行应用的时候,使用Controller做初始化:

(function() {
    var controller = new myapp.Controller();
    controller.init();
})();

不足

View承接了部分Controller的功能,负责接收用户输入(但并不负责处理,而是无脑地传给 Controller)。

事实上,前端的View已经具备了独立处理用户事件的能力,如果每个事件都要流经Controller,势必增加复杂性。

同时,View 应该委托Controller来调用Model(这样 Model 对于 View 来说就是透明的),而不是Model 与 View 之间存在耦合。

你会发现,View 和 Controller 是一对一的关系。而事实上,前端的View其实已经具备了独立处理用户事件的能力,当每个事件都需要流经Controller时,Controller会变得十分臃肿

在Backbone中,Backbone.View和Backbone.Router一起承担了Controller的责任。这就为MVC中controller的衍变埋下了伏笔。这其实是因为在原始的 MVC 中 View 和 Controller 一般是一一对应的,捆绑起来表示一个组件,View 与 Controller 间的过于紧密的连接让Controller的复用性成了问题,如果想多个View共用一个Controller该怎么办呢?这里有一个解决方案:MVP(Model-View-Presenter)。

2 优化后的MVC - MVP(Model-View-Presenter)

MVP(Model-View-Presenter)是MVC模式的改良,由IBM的子公司Taligent提出。和MVC的相同之处在于:Controller/Presenter负责业务逻辑,Model管理数据,View负责显示。

在原始的 MVC 里,View 是可以直接访问 Model 的,但MVP中,View并不能直接访问Model,而是 Presenter 给 View 提供接口,通过Presenter去更新Model,再通过观察者模式更新View。

与MVC相比,MVP模式通过解耦View和Model,完全分离 View 和 Model 使职责划分更加清晰;由于View不依赖Model,可以将View抽离出来做成组件,它只需要提供一系列接口提供给上层操作。

而且,MVP 还解决了 Controller - View的捆绑关系,将进行改造,使View不仅拥有UI组件的结构,还拥有处理用户事件的能力,这样就能将 Controller 独立出来。为了对用户事件进行统一管理,View只负责将用户产生的事件传递给Controller,由Controller来统一处理,这样的好处是多个View可共用同一个Controller。此时的Controller也由组件级别上升到了应用级别

Demo

Model

myapp.Model = function() {
    var val = 0;

    this.add = function(v) {
        if (val < 100) val += v;
    };

    this.sub = function(v) {
        if (val > 0) val -= v;
    };

    this.getVal = function() {
        return val;
    };
};

Model层依然是主要与业务相关的数据和对应处理数据的方法。

View

myapp.View = function() {
    var $num = $('#num'),
        $incBtn = $('#increase'),
        $decBtn = $('#decrease');

    this.render = function(model) {
        $num.text(model.getVal() + 'rmb');
    };

    this.init = function() {
        var presenter = new myapp.Presenter(this);

        $incBtn.click(presenter.increase);
        $decBtn.click(presenter.decrease);
    };
};

MVP定义了Presenter和View之间的接口,用户对View的操作都转移到了Presenter。比如这里可以让View暴露setter接口以便Presenter调用,待Presenter通知Model更新后,Presenter调用View提供的接口更新视图。

Presenter

myapp.Presenter = function(view) {
    var _model = new myapp.Model();
    var _view = view;

    _view.render(_model);

    this.increase = function() {
        _model.add(1);
        _view.render(_model);
    };

    this.decrease = function() {
        _model.sub(1);
        _view.render(_model);
    };
};

Presenter作为View和Model之间的“中间人”,除了基本的业务逻辑外,还有大量代码需要对从View到Model和从Model到View的数据进行“手动同步”,这样Presenter显得很,维护起来会比较困难。而且由于没有数据绑定,如果Presenter对视图渲染的需求增多,它不得不过多关注特定的视图,一旦视图需求发生改变,Presenter也需要改动。

运行程序时,以View为入口:

(function() {
    var view = new myapp.View();
    view.init();
})();

MVVM

MVVM(Model-View-ViewModel)最初是由微软在使用Windows Presentation Foundation和SilverLight时定义的,2005年John Grossman在一篇关于Avalon(WPF 的代号)的博客文章中正式宣布了它的存在。

WPF Demo

如果你用过Visual Studio, 新建一个WPF Application,然后在“设计”中拖进去一个控件、双击后在“代码”中写事件处理函数、或者绑定数据源。就对这个MVVM有点感觉了。比如VS自动生成的如下代码:

<GroupBox Header="绑定对象">
    <StackPanel Orientation="Horizontal" Name="stackPanel1">
        <TextBlock Text="学号:"/>
        <TextBlock Text="{Binding Path=StudentID}"/>
        <TextBlock Text="姓名:"/>
        <TextBlock Text="{Binding Path=Name}"/>
        <TextBlock Text="入学日期:"/>
        <TextBlock Text="{Binding Path=EntryDate, StringFormat=yyyy-MM-dd}"/>
        <TextBlock Text="学分:"/>
        <TextBlock Text="{Binding Path=Credit}"/>
    </StackPanel>
</GroupBox>
stackPanel1.DataContext = new Student() {
    StudentID=20130501,
    Name="张三",
    EntryDate=DateTime.Parse("2013-09-01"),
    Credit=0.0
};

其中最重要的特性之一就是数据绑定(Data-binding)。没有前后端分离,一个开发人员全搞定,一只手抓业务逻辑、一只手抓数据访问,顺带手拖放几个UI控件,绑定数据源到某个对象或某张表,一步到位。

MVVM

首先,view和model是不知道彼此存在的,同MVP一样,将view和model清晰地分离开来。

与 MVP 不同的是,MVVM把View和Model的同步逻辑自动化了。以前Presenter负责的View和Model同步不再手动地进行操作,而是交给框架所提供的数据绑定功能进行负责,只需要告诉它View显示的数据对应的是Model哪一部分即可。

其次,view是对viewmodel的外在显示,与viewmodel保持同步,viewmodel对象可以看作是view的上下文。view绑定到viewmodel的属性上,如果viewmodel中的属性值变化了,这些新值通过数据绑定会自动传递给view。反过来viewmodel会暴露model中的数据和特定状态给view。 所以,view不知道model的存在,viewmodel和model也觉察不到view。事实上,model也完全忽略viewmodel和view的存在。这是一个非常松散耦合的设计。

这里我们使用Vue来完成这个栗子。

Vue.js Demo

Model

在MVVM中,我们可以把Model称为数据层,因为它仅仅关注数据本身,不关心任何行为(格式化数据由View的负责),这里可以把它理解为一个类似 JSON 的数据对象。

var data = {
    val: 0
};

View

和MVC/MVP不同的是,MVVM中的View通过使用模板语法来声明式的将数据渲染进DOM,当ViewModel对Model进行更新的时候,会通过数据绑定更新到View。写法如下:

<div id="myapp">
    <div>
        <span>{{ val }}rmb</span>
    </div>
    <div>
        <button v-on:click="sub(1)">-</button>
        <button v-on:click="add(1)">+</button>
    </div>
</div>

ViewModel

ViewModel大致上就是MVC的Controller和MVP的Presenter了,也是整个模式的重点,业务逻辑也主要集中在这里,其中的一大核心就是数据绑定,后面将会讲到。

与MVP不同的是,没有了View为Presente提供的接口,之前由Presenter负责的View和Model之间的数据同步交给了ViewModel中的数据绑定进行处理,当Model发生变化,ViewModel就会自动更新;ViewModel变化,Model也会更新。

new Vue({
    el: '#myapp',
    data: data,
    methods: {
        add(v) {
            if(this.val < 100) {
                this.val += v;
            }
        },
        sub(v) {
            if(this.val > 0) {
                this.val -= v;
            }
        }
    }
});

整体来看,比MVC/MVP精简了很多,不仅仅简化了业务与界面的依赖,还解决了数据频繁更新(以前用jQuery操作DOM很繁琐)的问题。因为在MVVM中,View不知道Model的存在,ViewModel和Model也察觉不到View,这种低耦合模式可以使开发过程更加容易,提高应用的可重用性。

数据绑定(Data Binding)

在Vue中,使用了双向绑定技术(Two-Way-Data-Binding),就是View的变化能实时让Model发生变化,而Model的变化也能实时更新到View。

不同的MVVM框架中,实现双向数据绑定的技术有所不同。目前一些主流的前端框架实现数据绑定的方式大致有以下几种:

  • 数据劫持 (Vue)
  • 发布-订阅模式 (Knockout、Backbone)
  • 脏值检查 (Angular)

一些MV*框架

Backbone.js

Backbone通过提供模型Model、集合Collection、视图View赋予了Web应用程序分层结构,其中模型包含领域数据和自定义事件;集合Colection是模型的有序或无序集合,带有丰富的可枚举API; 视图可以声明事件处理函数。最终将模型、集合、视图与服务端的RESTful JSON接口连接。

Backbone在升级的过程中,去掉了controller,由view和router代替controller,view集中处理了用户事件(如click,keypress等)、渲染HTML模板、与模型数据的交互。Backbone的model没有与UI视图进行数据绑定,而是需要在view中自行操作DOM来更新或读取UI数据。Router为客户端路由提供了许多方法,并能连接到指定的动作(actions)和事件(events)。

Backbone是一个小巧灵活的库,只是帮你实现一个MVC模式的框架,更多的还需要自己去实现。适合有一定Web基础,喜欢原生JS去操作DOM(因为没有数据绑定)的开发人员。为什么称它为库,而不是框架,不仅仅是由于仅4KB的代码,更重要的是 使用一个库,你有控制权。如果用一个框架,控制权就反转了,变成框架在控制你。库能够给予灵活和自由,但是框架强制使用某种方式,减少重复代码。这便是Backbone与Angular的区别之一了。

至于Backbone属于MV*中的哪种模式,有人认为不是MVC,有人觉得更接近于MVP,事实上,它借用多个架构模式中一些很好的概念,创建一个运行良好的灵活框架。不必拘泥于某种模式。

// view:
var Appview = Backbone.View.extend({
    // 每个view都需要一个指向DOM元素的引用,就像ER中的main属性。
    el: '#container',

    // view中不包含html标记,有一个链接到模板的引用。
    template: _.template("<h3>Hello <%= who %></h3>"),

    // 初始化方法
    initialize: function(){
      this.render();
    },

    // $el是一个已经缓存的jQuery对象
    render: function(){
      this.$el.html("Hello World");
    },

    // 事件绑定
    events: {'keypress #new-todo': 'createTodoOnEnter'}
});
var appview = new Appview();

// model:
// 每个应用程序的核心、包含了交互数据和逻辑
// 如数据验证、getter、setter、默认值、数据初始化、数据转换
var app = {};

app.Todo = Backbone.model.extend({
  defaults: {
    title: '',
    completed: false
  }
});

// 创建一个model实例
var todo = new app.Todo({title: 'Learn Backbone.js', completed: false});
todo.get('title'); // "Learn Backbone.js"
todo.get('completed'); // false
todo.get('created_at'); // undefined
todo.set('created_at', Date());
todo.get('created_at'); // "Wed Sep 12 2012 12:51:17 GMT-0400 (EDT)"

// collection:
// model的有序集合,可以设置或获取model
// 监听集合中的数据变化,从后端获取模型数据、持久化。
app.TodoList = Backbone.Collection.extend({
  model: app.Todo,
  localStorage: new Store("backbone-todo")
});

// collection实例
var todoList = new app.TodoList()
todoList.create({title: 'Learn Backbone\'s Collection'});

// model实例
var model = new app.Todo({title: 'Learn models', completed: true});
todoList.add(model);
todoList.pluck('title');
todoList.pluck('completed');

Reference